mirror of
https://github.com/pretix/pretix.git
synced 2026-03-31 19:32:26 +00:00
Compare commits
6 Commits
ai-policy
...
logentryty
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8e5c7867b | ||
|
|
7010752bb0 | ||
|
|
7d8bcd4b10 | ||
|
|
2121566ae5 | ||
|
|
9f6216e6f1 | ||
|
|
c824663946 |
4
.github/workflows/docs.yml
vendored
4
.github/workflows/docs.yml
vendored
@@ -26,10 +26,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Python 3.13
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.13
|
||||
python-version: 3.11
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
|
||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -23,13 +23,13 @@ jobs:
|
||||
name: Tests
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.13"]
|
||||
python-version: ["3.9", "3.10", "3.11"]
|
||||
database: [sqlite, postgres]
|
||||
exclude:
|
||||
- database: sqlite
|
||||
python-version: "3.10"
|
||||
python-version: "3.9"
|
||||
- database: sqlite
|
||||
python-version: "3.11"
|
||||
python-version: "3.10"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
Contributing to pretix
|
||||
======================
|
||||
|
||||
Welcome to pretix, we are happy that you would like to contribute.
|
||||
Before you do so, please make sure to read the following documents:
|
||||
Hey there and welcome to pretix!
|
||||
|
||||
- [Contribution workflow](https://docs.pretix.eu/dev/development/contribution/general.html)
|
||||
- [AI-assisted contribution policy](https://docs.pretix.eu/dev/development/contribution/ai.html)
|
||||
- [Coding style and quality](https://docs.pretix.eu/dev/development/contribution/style.html)
|
||||
- [Development setup](https://docs.pretix.eu/dev/development/setup.html)
|
||||
- [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html)
|
||||
* We've got a contributors guide in [our documentation](https://docs.pretix.eu/dev/development/contribution/) together with notes on the [development setup](https://docs.pretix.eu/dev/development/setup.html).
|
||||
|
||||
Before we can accept your first PR we'll need you to sign [our **Contributor License Agreement** (CLA)](https://pretix.eu/about/en/cla).
|
||||
You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/).
|
||||
* Please note that we have a [Code of Conduct](https://docs.pretix.eu/dev/development/contribution/codeofconduct.html) in place that applies to all project contributions, including issues, pull requests, etc.
|
||||
|
||||
* Before we can accept a PR from you we'll need you to sign [our CLA](https://pretix.eu/about/en/cla). You can find more information about the how and why in our [License FAQ](https://docs.pretix.eu/trust/licensing/faq/) and in our [license change blog post](https://pretix.eu/about/en/blog/20210412-license/).
|
||||
|
||||
**Before contributing new functionality, always open a discussion first.**
|
||||
174
doc/_themes/pretix_theme/layout.html
vendored
174
doc/_themes/pretix_theme/layout.html
vendored
@@ -6,14 +6,10 @@
|
||||
{%- else %}
|
||||
{%- set titlesuffix = "" %}
|
||||
{%- endif %}
|
||||
{%- set lang_attr = 'en' if language == None else (language | replace('_', '-')) %}
|
||||
|
||||
{# Build sphinx_version_info tuple from sphinx_version string in pure Jinja #}
|
||||
{%- set (_ver_major, _ver_minor) = (sphinx_version.split('.') | list)[:2] | map('int') -%}
|
||||
{%- set sphinx_version_info = (_ver_major, _ver_minor, -1) -%}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html class="writer-html5" lang="{{ lang_attr }}"{% if sphinx_version_info >= (7, 2) %} data-content_root="{{ content_root }}"{% endif %}>
|
||||
<!--[if IE 8]><html class="no-js lt-ie9" lang="en" > <![endif]-->
|
||||
<!--[if gt IE 8]><!--> <html class="no-js" lang="en" > <!--<![endif]-->
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
{{ metatags }}
|
||||
@@ -22,50 +18,59 @@
|
||||
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
||||
{% endblock %}
|
||||
|
||||
{#- CSS #}
|
||||
{%- for css_file in css_files %}
|
||||
{%- if css_file|attr("filename") %}
|
||||
{{ css_tag(css_file) }}
|
||||
|
||||
{#- CSS #}
|
||||
{%- for css in css_files %}
|
||||
{%- if css|attr("rel") %}
|
||||
<link rel="{{ css.rel }}" href="{{ pathto(css.filename, 1) }}" type="text/css"{% if css.title is not none %} title="{{ css.title }}"{% endif %} />
|
||||
{%- else %}
|
||||
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
|
||||
<link rel="stylesheet" href="{{ pathto(css, 1) }}" type="text/css" />
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- endfor %}
|
||||
|
||||
{#- FAVICON #}
|
||||
{%- if favicon_url %}
|
||||
<link rel="shortcut icon" href="{{ favicon_url }}"/>
|
||||
{%- endif %}
|
||||
{%- for cssfile in extra_css_files %}
|
||||
<link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" />
|
||||
{%- endfor -%}
|
||||
|
||||
{#- CANONICAL URL (deprecated) #}
|
||||
{%- if theme_canonical_url and not pageurl %}
|
||||
{#- FAVICON
|
||||
favicon_url is the only context var necessary since Sphinx 4.
|
||||
In Sphinx<4, we use favicon but need to prepend path info.
|
||||
#}
|
||||
{%- set _favicon_url = favicon_url | default(pathto('_static/' + (favicon or ""), 1)) %}
|
||||
{%- if favicon_url or favicon %}
|
||||
<link rel="shortcut icon" href="{{ _favicon_url }}"/>
|
||||
{%- endif %}
|
||||
|
||||
{#- CANONICAL URL (deprecated) #}
|
||||
{%- if theme_canonical_url and not pageurl %}
|
||||
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
|
||||
{#- CANONICAL URL #}
|
||||
{%- if pageurl %}
|
||||
{#- CANONICAL URL #}
|
||||
{%- if pageurl %}
|
||||
<link rel="canonical" href="{{ pageurl|e }}" />
|
||||
{%- endif -%}
|
||||
{%- endif -%}
|
||||
|
||||
{#- JAVASCRIPTS #}
|
||||
{%- block scripts %}
|
||||
{%- if not embedded %}
|
||||
{%- for scriptfile in script_files %}
|
||||
{{ js_tag(scriptfile) }}
|
||||
{%- endfor %}
|
||||
{#- JAVASCRIPTS #}
|
||||
{%- block scripts %}
|
||||
<!--[if lt IE 9]>
|
||||
<script src="{{ pathto('_static/js/html5shiv.min.js', 1) }}"></script>
|
||||
<![endif]-->
|
||||
{%- if not embedded %}
|
||||
{# XXX Sphinx 1.8.0 made this an external js-file, quick fix until we refactor the template to inherert more blocks directly from sphinx #}
|
||||
{%- for scriptfile in script_files %}
|
||||
{{ js_tag(scriptfile) }}
|
||||
{%- endfor %}
|
||||
<script src="{{ pathto('_static/js/theme.js', 1) }}"></script>
|
||||
|
||||
{%- if READTHEDOCS or DEBUG %}
|
||||
<script src="{{ pathto('_static/js/versions.js', 1) }}"></script>
|
||||
{%- endif %}
|
||||
|
||||
{#- OPENSEARCH #}
|
||||
{%- if use_opensearch %}
|
||||
<link rel="search" type="application/opensearchdescription+xml"
|
||||
title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}"
|
||||
href="{{ pathto('_static/opensearch.xml', 1) }}"/>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endblock %}
|
||||
{%- endif %}
|
||||
{%- endblock %}
|
||||
|
||||
{%- block linktags %}
|
||||
{%- if hasdoc('about') %}
|
||||
@@ -118,23 +123,23 @@
|
||||
{% 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 %}
|
||||
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
|
||||
{% block menu %}
|
||||
{#
|
||||
The singlehtml builder doesn't handle this toctree call when the
|
||||
toctree is empty. Skip building this for now.
|
||||
#}
|
||||
{% if 'singlehtml' not in builder %}
|
||||
{% set global_toc = toctree(maxdepth=theme_navigation_depth|int, collapse=theme_collapse_navigation, includehidden=True) %}
|
||||
{% endif %}
|
||||
{% if global_toc %}
|
||||
{{ global_toc }}
|
||||
{% else %}
|
||||
<!-- Local TOC -->
|
||||
<div class="local-toc">{{ toc }}</div>
|
||||
{%- endif %}
|
||||
{%- endblock %}
|
||||
</div>
|
||||
{%- endblock %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
{% if theme_display_version %}
|
||||
{%- set nav_version = version %}
|
||||
@@ -153,42 +158,53 @@
|
||||
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
|
||||
|
||||
{# MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #}
|
||||
<nav class="wy-nav-top" aria-label="{{ _('Mobile navigation menu') }}" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}>
|
||||
{%- block mobile_nav %}
|
||||
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
|
||||
<a href="{{ pathto(master_doc) }}">{{ project }}</a>
|
||||
{%- endblock %}
|
||||
</nav>
|
||||
<nav class="wy-nav-top" role="navigation" aria-label="top navigation">
|
||||
{% block mobile_nav %}
|
||||
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
|
||||
<a href="{{ pathto('index') }}">{{ project }}</a>
|
||||
{% endblock %}
|
||||
</nav>
|
||||
|
||||
<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 %}
|
||||
|
||||
{# PAGE CONTENT #}
|
||||
<div class="wy-nav-content">
|
||||
<div class="rst-content">
|
||||
{% include "breadcrumbs.html" %}
|
||||
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
|
||||
<div itemprop="articleBody" class="section">
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
<div class="articleComments">
|
||||
{% block comments %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
{% include "footer.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
{% include "versions.html" %}
|
||||
|
||||
{% if not embedded %}
|
||||
|
||||
<script type="text/javascript">
|
||||
var DOCUMENTATION_OPTIONS = {
|
||||
URL_ROOT:'{{ url_root }}',
|
||||
VERSION:'{{ release|e }}',
|
||||
COLLAPSE_INDEX:false,
|
||||
FILE_SUFFIX:'{{ '' if no_search_suffix else file_suffix }}',
|
||||
HAS_SOURCE: {{ has_source|lower }},
|
||||
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
|
||||
};
|
||||
</script>
|
||||
{%- for scriptfile in script_files %}
|
||||
<script type="text/javascript" src="{{ pathto(scriptfile, 1) }}"></script>
|
||||
{%- endfor %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
{# RTD hosts this file, so just load on non RTD builds #}
|
||||
{% if not READTHEDOCS %}
|
||||
<script type="text/javascript" src="{{ pathto('_static/js/theme.js', 1) }}"></script>
|
||||
@@ -198,7 +214,7 @@
|
||||
{% if theme_sticky_navigation %}
|
||||
<script type="text/javascript">
|
||||
jQuery(function () {
|
||||
SphinxRtdTheme.Navigation.enable({{ 'true' if theme_sticky_navigation|tobool else 'false' }});
|
||||
SphinxRtdTheme.StickyNav.enable();
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
340
doc/_themes/pretix_theme/layout_old.html
vendored
340
doc/_themes/pretix_theme/layout_old.html
vendored
@@ -1,86 +1,136 @@
|
||||
{# TEMPLATE VAR SETTINGS #}
|
||||
{#
|
||||
basic/layout.html
|
||||
~~~~~~~~~~~~~~~~~
|
||||
|
||||
Master layout template for Sphinx themes.
|
||||
|
||||
:copyright: Copyright 2007-2013 by the Sphinx team, see AUTHORS.
|
||||
:license: BSD, see LICENSE for details.
|
||||
#}
|
||||
{%- block doctype -%}
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
||||
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
{%- endblock %}
|
||||
{%- set reldelim1 = reldelim1 is not defined and ' »' 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) %}
|
||||
{# XXX necessary? #}
|
||||
{%- if url_root == '#' %}{% set url_root = '' %}{% endif %}
|
||||
{%- if not embedded and docstitle %}
|
||||
{%- set titlesuffix = " — "|safe + docstitle|e %}
|
||||
{%- else %}
|
||||
{%- set titlesuffix = "" %}
|
||||
{%- endif %}
|
||||
{%- set lang_attr = 'en' if language == None else (language | replace('_', '-')) %}
|
||||
|
||||
{# Build sphinx_version_info tuple from sphinx_version string in pure Jinja #}
|
||||
{%- set (_ver_major, _ver_minor) = (sphinx_version.split('.') | list)[:2] | map('int') -%}
|
||||
{%- set sphinx_version_info = (_ver_major, _ver_minor, -1) -%}
|
||||
{%- macro relbar() %}
|
||||
<div class="related">
|
||||
<h3>{{ _('Navigation') }}</h3>
|
||||
<ul>
|
||||
{%- for rellink in rellinks %}
|
||||
<li class="right" {% if loop.first %}style="margin-right: 10px"{% endif %}>
|
||||
<a href="{{ pathto(rellink[0]) }}" title="{{ rellink[1]|striptags|e }}"
|
||||
{{ accesskey(rellink[2]) }}>{{ rellink[3] }}</a>
|
||||
{%- if not loop.first %}{{ reldelim2 }}{% endif %}</li>
|
||||
{%- endfor %}
|
||||
{%- block rootrellink %}
|
||||
<li><a href="{{ pathto(master_doc) }}">{{ shorttitle|e }}</a>{{ reldelim1 }}</li>
|
||||
{%- endblock %}
|
||||
{%- for parent in parents %}
|
||||
<li><a href="{{ parent.link|e }}" {% if loop.last %}{{ accesskey("U") }}{% endif %}>{{ parent.title }}</a>{{ reldelim1 }}</li>
|
||||
{%- endfor %}
|
||||
{%- block relbaritems %} {% endblock %}
|
||||
</ul>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html class="writer-html5" lang="{{ lang_attr }}"{% if sphinx_version_info >= (7, 2) %} data-content_root="{{ content_root }}"{% endif %}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
{%- if READTHEDOCS and not embedded %}
|
||||
<meta name="readthedocs-addons-api-version" content="1">
|
||||
{%- endif %}
|
||||
{{- metatags }}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
{%- block htmltitle %}
|
||||
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
||||
{%- endblock -%}
|
||||
{%- macro sidebar() %}
|
||||
{%- if render_sidebar %}
|
||||
<div class="sphinxsidebar">
|
||||
<div class="sphinxsidebarwrapper">
|
||||
{%- block sidebarlogo %}
|
||||
{%- if logo %}
|
||||
<p class="logo"><a href="{{ pathto(master_doc) }}">
|
||||
<img class="logo" src="{{ pathto('_static/' + logo, 1) }}" alt="Logo"/>
|
||||
</a></p>
|
||||
{%- endif %}
|
||||
{%- endblock %}
|
||||
{%- if sidebars != None %}
|
||||
{#- new style sidebar: explicitly include/exclude templates #}
|
||||
{%- for sidebartemplate in sidebars %}
|
||||
{%- include sidebartemplate %}
|
||||
{%- endfor %}
|
||||
{%- else %}
|
||||
{#- old style sidebars: using blocks -- should be deprecated #}
|
||||
{%- block sidebartoc %}
|
||||
{%- include "localtoc.html" %}
|
||||
{%- endblock %}
|
||||
{%- block sidebarrel %}
|
||||
{%- include "relations.html" %}
|
||||
{%- endblock %}
|
||||
{%- block sidebarsourcelink %}
|
||||
{%- include "sourcelink.html" %}
|
||||
{%- endblock %}
|
||||
{%- if customsidebar %}
|
||||
{%- include customsidebar %}
|
||||
{%- endif %}
|
||||
{%- block sidebarsearch %}
|
||||
{%- include "searchbox.html" %}
|
||||
{%- endblock %}
|
||||
{%- endif %}
|
||||
</div>
|
||||
</div>
|
||||
{%- endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{#- CSS #}
|
||||
{%- for css_file in css_files %}
|
||||
{%- if css_file|attr("filename") %}
|
||||
{{ css_tag(css_file) }}
|
||||
{%- else %}
|
||||
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
|
||||
{#
|
||||
"extra_css_files" is an undocumented Read the Docs theme specific option.
|
||||
There is no need to check for ``|attr("filename")`` here because it's always a string.
|
||||
Note that this option should be removed in favor of regular ``html_css_files``:
|
||||
https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_css_files
|
||||
#}
|
||||
{%- for css_file in extra_css_files %}
|
||||
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
|
||||
{%- endfor -%}
|
||||
|
||||
{#- FAVICON #}
|
||||
{%- if favicon_url %}
|
||||
<link rel="shortcut icon" href="{{ favicon_url }}"/>
|
||||
{%- endif %}
|
||||
|
||||
{#- CANONICAL URL (deprecated) #}
|
||||
{%- if theme_canonical_url and not pageurl %}
|
||||
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
|
||||
{%- endif -%}
|
||||
|
||||
{#- CANONICAL URL #}
|
||||
{%- if pageurl %}
|
||||
<link rel="canonical" href="{{ pageurl|e }}" />
|
||||
{%- endif -%}
|
||||
|
||||
{#- JAVASCRIPTS #}
|
||||
{%- block scripts %}
|
||||
{%- if not embedded %}
|
||||
{%- macro script() %}
|
||||
<script type="text/javascript">
|
||||
var DOCUMENTATION_OPTIONS = {
|
||||
URL_ROOT: '{{ url_root }}',
|
||||
VERSION: '{{ release|e }}',
|
||||
COLLAPSE_INDEX: false,
|
||||
FILE_SUFFIX: '{{ '' if no_search_suffix else file_suffix }}',
|
||||
HAS_SOURCE: {{ has_source|lower }},
|
||||
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
|
||||
};
|
||||
</script>
|
||||
{%- for scriptfile in script_files %}
|
||||
{{ js_tag(scriptfile) }}
|
||||
<script type="text/javascript" src="{{ pathto(scriptfile, 1) }}"></script>
|
||||
{%- endfor %}
|
||||
<script src="{{ pathto('_static/js/theme.js', 1) }}"></script>
|
||||
{%- endmacro %}
|
||||
|
||||
{%- if READTHEDOCS or DEBUG %}
|
||||
<script src="{{ pathto('_static/js/versions.js', 1) }}"></script>
|
||||
{%- endif %}
|
||||
{%- macro css() %}
|
||||
<link rel="stylesheet" href="{{ pathto('_static/' + style, 1) }}" type="text/css" />
|
||||
<link rel="stylesheet" href="{{ pathto('_static/pygments.css', 1) }}" type="text/css" />
|
||||
{%- for cssfile in css_files %}
|
||||
<link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" />
|
||||
{%- endfor %}
|
||||
{%- endmacro %}
|
||||
|
||||
{#- OPENSEARCH #}
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset={{ encoding }}" />
|
||||
{{ metatags }}
|
||||
{%- block htmltitle %}
|
||||
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
||||
{%- endblock %}
|
||||
{{ css() }}
|
||||
{%- if not embedded %}
|
||||
{{ script() }}
|
||||
{%- if use_opensearch %}
|
||||
<link rel="search" type="application/opensearchdescription+xml"
|
||||
title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}"
|
||||
href="{{ pathto('_static/opensearch.xml', 1) }}"/>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endblock %}
|
||||
|
||||
{%- block linktags %}
|
||||
{%- if favicon %}
|
||||
<link rel="shortcut icon" href="{{ pathto('_static/' + favicon, 1) }}"/>
|
||||
{%- endif %}
|
||||
{%- if theme_canonical_url %}
|
||||
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- block linktags %}
|
||||
{%- if hasdoc('about') %}
|
||||
<link rel="author" title="{{ _('About these documents') }}" href="{{ pathto('about') }}" />
|
||||
{%- endif %}
|
||||
@@ -93,135 +143,67 @@
|
||||
{%- if hasdoc('copyright') %}
|
||||
<link rel="copyright" title="{{ _('Copyright') }}" href="{{ pathto('copyright') }}" />
|
||||
{%- endif %}
|
||||
<link rel="top" title="{{ docstitle|e }}" href="{{ pathto('index') }}" />
|
||||
{%- if parents %}
|
||||
<link rel="up" title="{{ parents[-1].title|striptags|e }}" href="{{ parents[-1].link|e }}" />
|
||||
{%- endif %}
|
||||
{%- if next %}
|
||||
<link rel="next" title="{{ next.title|striptags|e }}" href="{{ next.link|e }}" />
|
||||
{%- endif %}
|
||||
{%- if prev %}
|
||||
<link rel="prev" title="{{ prev.title|striptags|e }}" href="{{ prev.link|e }}" />
|
||||
{%- endif %}
|
||||
{%- endblock %}
|
||||
{%- block extrahead %} {% endblock %}
|
||||
</head>
|
||||
{%- endblock %}
|
||||
{%- block extrahead %} {% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{%- block header %}{% endblock %}
|
||||
|
||||
<body class="wy-body-for-nav">
|
||||
{%- block relbar1 %}{{ 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 %}
|
||||
{%- block content %}
|
||||
{%- block sidebar1 %} {# possible location for sidebar #} {% endblock %}
|
||||
|
||||
{# 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 class="document">
|
||||
{%- block document %}
|
||||
<div class="documentwrapper">
|
||||
{%- if render_sidebar %}
|
||||
<div class="bodywrapper">
|
||||
{%- endif %}
|
||||
<div class="body">
|
||||
{% block body %} {% endblock %}
|
||||
</div>
|
||||
{%- endblock %}
|
||||
{% include "footer.html" %}
|
||||
{%- if render_sidebar %}
|
||||
</div>
|
||||
{%- endblock %}
|
||||
{%- endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% include "versions.html" -%}
|
||||
{%- endblock %}
|
||||
|
||||
<script>
|
||||
jQuery(function () {
|
||||
SphinxRtdTheme.Navigation.enable({{ 'true' if theme_sticky_navigation|tobool else 'false' }});
|
||||
});
|
||||
</script>
|
||||
{%- block sidebar2 %}{{ sidebar() }}{% endblock %}
|
||||
<div class="clearer"></div>
|
||||
</div>
|
||||
{%- endblock %}
|
||||
|
||||
{#- 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 relbar2 %}{{ relbar() }}{% endblock %}
|
||||
|
||||
{%- 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 %}
|
||||
{% 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>
|
||||
@@ -39,7 +39,7 @@ as well as the type of underlying hardware. Example:
|
||||
"rsa_pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh…nswIDAQAB\n-----END PUBLIC KEY-----\n"
|
||||
}
|
||||
|
||||
The ``rsa_pubkey`` is optional any only required for certain features such as working with reusable
|
||||
The ``rsa_pubkey`` is optional any only required for certain fatures such as working with reusable
|
||||
media and NFC cryptography.
|
||||
|
||||
Every initialization token can only be used once. On success, you will receive a response containing
|
||||
@@ -197,11 +197,10 @@ Permissions & security profiles
|
||||
|
||||
Device authentication is currently hardcoded to grant the following permissions:
|
||||
|
||||
* Read event meta data and products etc.
|
||||
* Read and write orders
|
||||
* Read and write gift cards
|
||||
* Read and write reusable media
|
||||
* Read vouchers
|
||||
* View event meta data and products etc.
|
||||
* View orders
|
||||
* Change orders
|
||||
* Manage gift cards
|
||||
|
||||
Devices cannot change events or products and cannot access vouchers.
|
||||
|
||||
@@ -209,6 +208,20 @@ Additionally, when creating a device through the user interface or API, a user c
|
||||
the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security
|
||||
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
|
||||
---------------
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ List-level conditional fetching
|
||||
If modification checks are not possible with this granularity, you can instead check for the full list.
|
||||
In this case, the list of objects may contain a regular HTTP header ``Last-Modified`` with the date of the
|
||||
last modification to any item of that resource. You can then pass this date back in your next request in the
|
||||
``If-Modified-Since`` header. If any object has changed in the meantime, you will receive back a full list
|
||||
``If-Modified-Since`` header. If the any object has changed in the meantime, you will receive back a full list
|
||||
(if something it missing, this means the object has been deleted). If nothing happened, we'll send back a
|
||||
``304 Not Modified`` return code.
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ software_brand string Device software
|
||||
software_version string Device software version (read-only)
|
||||
created datetime Creation time
|
||||
initialized datetime Time of initialization (or ``null``)
|
||||
initialization_token string Token for initialization (field invisible without write permission)
|
||||
initialization_token string Token for initialization
|
||||
revoked boolean Whether this device no longer has access
|
||||
security_profile string The name of a supported security profile restricting API access
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
@@ -65,6 +65,8 @@ Endpoints
|
||||
|
||||
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**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -159,6 +161,8 @@ Endpoints
|
||||
|
||||
Returns information on one event, identified by its slug.
|
||||
|
||||
Permission required: "Can change event settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -230,6 +234,8 @@ Endpoints
|
||||
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.
|
||||
|
||||
Permission required: "Can create events"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -332,6 +338,8 @@ Endpoints
|
||||
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.
|
||||
|
||||
Permission required: "Can create events"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -425,6 +433,8 @@ Endpoints
|
||||
|
||||
Updates an event
|
||||
|
||||
Permission required: "Can change event settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -500,6 +510,8 @@ Endpoints
|
||||
|
||||
Delete an event. Note that events with orders cannot be deleted to ensure data integrity.
|
||||
|
||||
Permission required: "Can change event settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -549,6 +561,8 @@ organizer level.
|
||||
|
||||
Get current values of event settings.
|
||||
|
||||
Permission required: "Can change event settings" (Exception: with device auth, *some* settings can always be *read*.)
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -601,8 +615,6 @@ organizer level.
|
||||
|
||||
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::
|
||||
|
||||
Settings can be stored at different levels in pretix. If a value is not set on event level, a default setting
|
||||
|
||||
@@ -19,7 +19,6 @@ at :ref:`plugin-docs`.
|
||||
item_bundles
|
||||
item_add-ons
|
||||
item_meta_properties
|
||||
item_program_times
|
||||
questions
|
||||
question_options
|
||||
quotas
|
||||
|
||||
@@ -22,7 +22,6 @@ invoice_from_name string Sender address:
|
||||
invoice_from string Sender address: Address lines
|
||||
invoice_from_zipcode string Sender address: ZIP code
|
||||
invoice_from_city string Sender address: City
|
||||
invoice_from_state string Sender address: State (only used in some countries)
|
||||
invoice_from_country string Sender address: Country code
|
||||
invoice_from_tax_id string Sender address: Local Tax ID
|
||||
invoice_from_vat_id string Sender address: EU VAT ID
|
||||
@@ -234,7 +233,6 @@ List of all invoices
|
||||
"invoice_from": "Demo street 12",
|
||||
"invoice_from_zipcode":"",
|
||||
"invoice_from_city":"Demo town",
|
||||
"invoice_from_state":"CA",
|
||||
"invoice_from_country":"US",
|
||||
"invoice_from_tax_id":"",
|
||||
"invoice_from_vat_id":"",
|
||||
@@ -383,7 +381,6 @@ Fetching individual invoices
|
||||
"invoice_from": "Demo street 12",
|
||||
"invoice_from_zipcode":"",
|
||||
"invoice_from_city":"Demo town",
|
||||
"invoice_from_state":"CA",
|
||||
"invoice_from_country":"US",
|
||||
"invoice_from_tax_id":"",
|
||||
"invoice_from_vat_id":"",
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
Item program times
|
||||
==================
|
||||
|
||||
Resource description
|
||||
--------------------
|
||||
|
||||
Program times for products (items) that can be set in addition to event times, e.g. to display seperate schedules within an event.
|
||||
Note that ``program_times`` are not available for items inside event series.
|
||||
The program times resource contains the following public fields:
|
||||
|
||||
.. rst-class:: rest-resource-table
|
||||
|
||||
===================================== ========================== =======================================================
|
||||
Field Type Description
|
||||
===================================== ========================== =======================================================
|
||||
id integer Internal ID of the program time
|
||||
start datetime The start date time for this program time slot.
|
||||
end datetime The end date time for this program time slot.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
.. versionchanged:: TODO
|
||||
|
||||
The resource has been added.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/
|
||||
|
||||
Returns a list of all program times for a given item.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/items/11/program_times/ 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
|
||||
|
||||
{
|
||||
"count": 3,
|
||||
"next": null,
|
||||
"previous": null,
|
||||
"results": [
|
||||
{
|
||||
"id": 2,
|
||||
"start": "2025-08-14T22:00:00Z",
|
||||
"end": "2025-08-15T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"start": "2025-08-12T22:00:00Z",
|
||||
"end": "2025-08-13T22:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"start": "2025-08-15T22:00:00Z",
|
||||
"end": "2025-08-17T22:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param item: The ``id`` field of the item to fetch
|
||||
:statuscode 200: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/(id)/
|
||||
|
||||
Returns information on one program time, identified by its ID.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ 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
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"start": "2025-08-15T22:00:00Z",
|
||||
"end": "2025-10-27T23:00:00Z"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param item: The ``id`` field of the item to fetch
|
||||
:param id: The ``id`` field of the program time 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.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/
|
||||
|
||||
Creates a new program time
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"start": "2025-08-15T10:00:00Z",
|
||||
"end": "2025-08-15T22:00:00Z"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 17,
|
||||
"start": "2025-08-15T10:00:00Z",
|
||||
"end": "2025-08-15T22:00:00Z"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event/item to create a program time for
|
||||
:param event: The ``slug`` field of the event to create a program time for
|
||||
:param item: The ``id`` field of the item to create a program time for
|
||||
:statuscode 201: no error
|
||||
:statuscode 400: The program time could not be created due to invalid submitted data.
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
|
||||
|
||||
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/(id)/
|
||||
|
||||
Update a program time. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to 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.
|
||||
|
||||
You can change all fields of the resource except the ``id`` field.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
Content-Type: application/json
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"start": "2025-08-14T10:00:00Z"
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"id": 1,
|
||||
"start": "2025-08-14T10:00:00Z",
|
||||
"end": "2025-08-15T12:00:00Z"
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to modify
|
||||
:param id: The ``id`` field of the program time to modify
|
||||
:statuscode 200: no error
|
||||
:statuscode 400: The program time could not be modified due to invalid submitted data
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
|
||||
|
||||
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/program_times/(id)/
|
||||
|
||||
Delete a program time.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
**Example response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 204 No Content
|
||||
Vary: Accept
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param event: The ``slug`` field of the event to modify
|
||||
:param id: The ``id`` field of the item to modify
|
||||
:param id: The ``id`` field of the program time to delete
|
||||
:statuscode 204: no error
|
||||
:statuscode 401: Authentication failure
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
|
||||
@@ -139,10 +139,6 @@ has_variations boolean Shows whether
|
||||
variations list of objects A list with one object for each variation of this item.
|
||||
Can be empty. Only writable during creation,
|
||||
use separate endpoint to modify this later.
|
||||
program_times list of objects A list with one object for each program time of this item.
|
||||
Can be empty. Only writable during creation,
|
||||
use separate endpoint to modify this later.
|
||||
Not available for items in event series.
|
||||
├ id integer Internal ID of the variation
|
||||
├ value multi-lingual string The "name" of the variation
|
||||
├ default_price money (string) The price set directly for this variation or ``null``
|
||||
@@ -229,10 +225,6 @@ meta_data object Values set fo
|
||||
|
||||
The ``hidden_if_item_available_mode`` attributes has been added.
|
||||
|
||||
.. versionchanged:: 2025.9
|
||||
|
||||
The ``program_times`` attribute has been added.
|
||||
|
||||
Notes
|
||||
-----
|
||||
|
||||
@@ -240,11 +232,9 @@ Please note that an item either always has variations or never has. Once created
|
||||
change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least
|
||||
one variation.
|
||||
|
||||
Also note that ``variations``, ``bundles``, ``addons`` and ``program_times`` are only supported on ``POST``. To update/delete variations,
|
||||
bundles, add-ons and program times please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT``
|
||||
with nested ``variations``, ``bundles``, ``addons`` and/or ``program_times``.
|
||||
|
||||
``program_times`` is not available to items in event series.
|
||||
Also note that ``variations``, ``bundles``, and ``addons`` are only supported on ``POST``. To update/delete variations,
|
||||
bundles, and add-ons please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT``
|
||||
with nested ``variations``, ``bundles`` and/or ``addons``.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
@@ -383,8 +373,7 @@ Endpoints
|
||||
}
|
||||
],
|
||||
"addons": [],
|
||||
"bundles": [],
|
||||
"program_times": []
|
||||
"bundles": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -536,8 +525,7 @@ Endpoints
|
||||
}
|
||||
],
|
||||
"addons": [],
|
||||
"bundles": [],
|
||||
"program_times": []
|
||||
"bundles": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
@@ -665,13 +653,7 @@ Endpoints
|
||||
}
|
||||
],
|
||||
"addons": [],
|
||||
"bundles": [],
|
||||
"program_times": [
|
||||
{
|
||||
"start": "2025-08-14T22:00:00Z",
|
||||
"end": "2025-08-15T00:00:00Z"
|
||||
}
|
||||
]
|
||||
"bundles": []
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -791,13 +773,7 @@ Endpoints
|
||||
}
|
||||
],
|
||||
"addons": [],
|
||||
"bundles": [],
|
||||
"program_times": [
|
||||
{
|
||||
"start": "2025-08-14T22:00:00Z",
|
||||
"end": "2025-08-15T00:00:00Z"
|
||||
}
|
||||
]
|
||||
"bundles": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer of the event to create an item for
|
||||
@@ -813,9 +789,8 @@ Endpoints
|
||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||
want to change.
|
||||
|
||||
You can change all fields of the resource except the ``has_variations``, ``variations``, ``addon`` and the
|
||||
``program_times`` field. If you need to update/delete variations, add-ons or program times, please use the nested
|
||||
dedicated endpoints.
|
||||
You can change all fields of the resource except the ``has_variations``, ``variations`` and the ``addon`` field. If
|
||||
you need to update/delete variations or add-ons please use the nested dedicated endpoints.
|
||||
|
||||
**Example request**:
|
||||
|
||||
@@ -949,8 +924,7 @@ Endpoints
|
||||
}
|
||||
],
|
||||
"addons": [],
|
||||
"bundles": [],
|
||||
"program_times": []
|
||||
"bundles": []
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
|
||||
@@ -117,8 +117,6 @@ cancellation_date datetime Time of order c
|
||||
reliable for orders that have been cancelled,
|
||||
reactivated and cancelled again.
|
||||
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.
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
|
||||
@@ -158,10 +156,6 @@ use_gift_cards list of strings List of unique
|
||||
|
||||
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
|
||||
@@ -993,6 +987,8 @@ Creating orders
|
||||
|
||||
* does not support file upload questions
|
||||
|
||||
* does not support redeeming gift cards
|
||||
|
||||
* does not support or validate memberships
|
||||
|
||||
|
||||
@@ -1099,14 +1095,6 @@ Creating orders
|
||||
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``.
|
||||
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
|
||||
to incrementing integers starting with ``1``. Then, you can reference one of these
|
||||
@@ -1731,56 +1719,6 @@ List of all order positions
|
||||
:statuscode 401: Authentication failure
|
||||
: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
|
||||
-----------------------------
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ Endpoints
|
||||
|
||||
Updates an organizer. Currently only the ``plugins`` field may be updated.
|
||||
|
||||
Permission required: "Can change organizer settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -170,6 +172,8 @@ information about the properties.
|
||||
|
||||
Get current values of organizer settings.
|
||||
|
||||
Permission required: "Can change organizer settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
@@ -154,7 +154,7 @@ Endpoints
|
||||
.. 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
|
||||
medium behind the scenes, therefore this endpoint requires write permissions.
|
||||
medium behind the scenes.
|
||||
|
||||
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,
|
||||
|
||||
@@ -154,6 +154,8 @@ Endpoints
|
||||
|
||||
Creates a new subevent.
|
||||
|
||||
Permission required: "Can create events"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -298,6 +300,8 @@ Endpoints
|
||||
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.
|
||||
|
||||
Permission required: "Can change event settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
@@ -369,6 +373,8 @@ Endpoints
|
||||
|
||||
Delete a sub-event. Note that events with orders cannot be deleted to ensure data integrity.
|
||||
|
||||
Permission required: "Can change event settings"
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
@@ -24,58 +24,21 @@ all_events boolean Whether this te
|
||||
limit_events list List of event slugs this team has access to
|
||||
require_2fa boolean Whether members of this team are required to use
|
||||
two-factor authentication
|
||||
all_event_permissions bool Whether members of this team are granted all event-level
|
||||
permissions, including future additions
|
||||
limit_event_permissions list of strings The event-level permissions team members are granted
|
||||
all_organizer_permissions bool Whether members of this team are granted all organizer-level
|
||||
permissions, including future additions
|
||||
all_organizer_permissions list of strings The organizer-level permissions team members are granted
|
||||
can_create_events boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``.
|
||||
can_change_teams boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``.
|
||||
can_change_organizer_settings boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``.
|
||||
can_manage_customers boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``.
|
||||
can_manage_reusable_media boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``.
|
||||
can_manage_gift_cards boolean **DEPRECATED**. Legacy interface, use ``limit_organizer_permissions``.
|
||||
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``.
|
||||
can_create_events boolean
|
||||
can_change_teams boolean
|
||||
can_change_organizer_settings boolean
|
||||
can_manage_customers boolean
|
||||
can_manage_reusable_media boolean
|
||||
can_manage_gift_cards boolean
|
||||
can_change_event_settings boolean
|
||||
can_change_items boolean
|
||||
can_view_orders boolean
|
||||
can_change_orders boolean
|
||||
can_view_vouchers boolean
|
||||
can_change_vouchers boolean
|
||||
can_checkin_orders boolean
|
||||
===================================== ========================== =======================================================
|
||||
|
||||
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
|
||||
--------------------
|
||||
|
||||
@@ -158,10 +121,6 @@ Team endpoints
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"all_event_permissions": true,
|
||||
"limit_event_permissions": [],
|
||||
"all_organizer_permissions": true,
|
||||
"limit_organizer_permissions": [],
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -200,10 +159,6 @@ Team endpoints
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"all_event_permissions": true,
|
||||
"limit_event_permissions": [],
|
||||
"all_organizer_permissions": true,
|
||||
"limit_organizer_permissions": [],
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -232,10 +187,7 @@ Team endpoints
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"all_event_permissions": true,
|
||||
"limit_event_permissions": [],
|
||||
"all_organizer_permissions": true,
|
||||
"limit_organizer_permissions": [],
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
|
||||
@@ -253,10 +205,6 @@ Team endpoints
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"all_event_permissions": true,
|
||||
"limit_event_permissions": [],
|
||||
"all_organizer_permissions": true,
|
||||
"limit_organizer_permissions": [],
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
@@ -284,8 +232,7 @@ Team endpoints
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"all_organizer_permissions": false,
|
||||
"limit_organizer_permissions": ["organizer.events:create"]
|
||||
"can_create_events": true
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -302,10 +249,6 @@ Team endpoints
|
||||
"all_events": true,
|
||||
"limit_events": [],
|
||||
"require_2fa": true,
|
||||
"all_event_permissions": true,
|
||||
"limit_event_permissions": [],
|
||||
"all_organizer_permissions": false,
|
||||
"limit_organizer_permissions": ["organizer.events:create"],
|
||||
"can_create_events": true,
|
||||
...
|
||||
}
|
||||
|
||||
@@ -60,9 +60,6 @@ The following values for ``action_types`` are valid with pretix core:
|
||||
* ``pretix.event.added``
|
||||
* ``pretix.event.changed``
|
||||
* ``pretix.event.deleted``
|
||||
* ``pretix.giftcards.created``
|
||||
* ``pretix.giftcards.modified``
|
||||
* ``pretix.giftcards.transaction.*``
|
||||
* ``pretix.voucher.added``
|
||||
* ``pretix.voucher.changed``
|
||||
* ``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)
|
||||
and multiplying it with the tax rate (19%) yields a tax amount of 79.83 (instead of 79.85) and a gross sum of 499.98
|
||||
(instead of 500.00). This becomes a problem when juristictions, data formats, or external systems expect this calculation
|
||||
(instead of 499.98). This becomes a problem when juristictions, data formats, or external systems expect this calculation
|
||||
to work on the level of the entire order. A prominent example is the EN 16931 standard for e-invoicing that
|
||||
does not allow the computation as created by pretix.
|
||||
|
||||
|
||||
@@ -55,12 +55,12 @@ your views:
|
||||
)
|
||||
|
||||
class AdminView(EventPermissionRequiredMixin, View):
|
||||
permission = 'event.orders:read'
|
||||
permission = 'can_view_orders'
|
||||
|
||||
...
|
||||
|
||||
|
||||
@event_permission_required('event.orders:read')
|
||||
@event_permission_required('can_view_orders')
|
||||
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')
|
||||
def navbar_info(sender, request, **kwargs):
|
||||
url = resolve(request.path_info)
|
||||
if not request.user.has_event_permission(request.organizer, request.event, 'event.vouchers:read'):
|
||||
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_vouchers'):
|
||||
return []
|
||||
return [{
|
||||
'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):
|
||||
model = Event
|
||||
permission = 'event.settings.general:write'
|
||||
permission = 'can_change_settings'
|
||||
form_class = MySettingsForm
|
||||
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
|
||||
.event`` and ``request.organizer`` are available as usual.
|
||||
|
||||
To require a special permission like ``event.orders:read``, you do not need to inherit from a special ViewSet base
|
||||
To require a special permission like ``can_view_orders``, you do not need to inherit from a special ViewSet base
|
||||
class, you can just set the ``permission`` attribute on your viewset:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyViewSet(ModelViewSet):
|
||||
permission = 'event.orders:read'
|
||||
permission = 'can_view_orders'
|
||||
...
|
||||
|
||||
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
|
||||
|
||||
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken) else request.user)
|
||||
if perm_holder.has_event_permission(request.event.organizer, request.event, 'event.orders:read'):
|
||||
if perm_holder.has_event_permission(request.event.organizer, request.event, 'can_view_orders'):
|
||||
...
|
||||
|
||||
|
||||
|
||||
@@ -80,24 +80,8 @@ The exporter class
|
||||
|
||||
.. autoattribute:: category
|
||||
|
||||
.. autoattribute:: feature
|
||||
|
||||
.. autoattribute:: export_form_fields
|
||||
|
||||
.. autoattribute:: repeatable_read
|
||||
|
||||
.. automethod:: render
|
||||
|
||||
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,8 +14,7 @@ Core
|
||||
: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,
|
||||
register_ticket_secret_generators, gift_card_transaction_display,
|
||||
register_text_placeholders, register_mail_placeholders, device_info_updated,
|
||||
register_event_permission_groups, register_organizer_permission_groups
|
||||
register_text_placeholders, register_mail_placeholders, device_info_updated
|
||||
|
||||
Order events
|
||||
""""""""""""
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
.. _`aipolicy`:
|
||||
|
||||
AI-assisted contribution policy
|
||||
===============================
|
||||
|
||||
pretix is maintained by humans.
|
||||
Every discussion, issue, and pull request is read and reviewed by humans (and sometimes machines, too).
|
||||
We ask you to respect the time and effort put in by these humans by not sending low-effort, unqualified work, since it puts the burden of validation on the maintainer.
|
||||
|
||||
Therefore, the pretix project has strict rules for AI usage:
|
||||
|
||||
- **All AI usage in any form must be disclosed.** You must state the tool you used (e.g. Claude Code, Cursor, Amp) along with the extent that the work was AI-assisted.
|
||||
|
||||
- **The human-in-the-loop must fully understand all code.** If you can't explain what your changes do and how they interact with the greater system without the aid of AI tools, do not contribute to this project.
|
||||
|
||||
- **Issues and discussions can use AI assistance but must have a full human-in-the-loop.** This means that any content generated with AI must have been reviewed and edited by a human before submission. AI is very good at being overly verbose and including noise that distracts from the main point. Humans must do their research and trim this down.
|
||||
|
||||
- **No AI-generated media is allowed (art, images, videos, audio, etc.).** Text and code are the only acceptable AI-generated content, per the other rules in this policy.
|
||||
|
||||
- **Bad AI drivers will be excluded from the project.** People who produce bad contributions that are clearly AI (slop) will be blocked from our organization without warning.
|
||||
|
||||
This policy was inspired by the `ghostty project`_.
|
||||
|
||||
.. _ghostty project: https://github.com/ghostty-org/ghostty/blob/main/AI_POLICY.md
|
||||
@@ -1,39 +1,23 @@
|
||||
Contribution workflow
|
||||
=====================
|
||||
General remarks
|
||||
===============
|
||||
|
||||
You are interested in contributing to pretix? That is awesome!
|
||||
|
||||
If you’re new to contributing to open source software, don’t be afraid. We’ll happily review your code and give you
|
||||
constructive and friendly feedback on your changes. Every contribution should go through the following steps.
|
||||
constructive and friendly feedback on your changes.
|
||||
|
||||
Discussion & Design
|
||||
-------------------
|
||||
|
||||
pretix is a large and mature project with more of a decade of history and hopefully many more decades to come.
|
||||
Keeping pretix in good shape over long timeframes is first and foremost a fight against complexity.
|
||||
With every additional feature, complexity grows, and both features and complexity are hard to remove.
|
||||
|
||||
Even if you are doing the initial work of the contribution, accepting the contribution is not free for us.
|
||||
Not only will we need to maintain the feature, but every feature adds cost to the maintenance of every other feature it interacts with, and every feature adds effort for users to understand how pretix works.
|
||||
Therefore, we must carefully select what features we add, based on how well they fit the system in general and of how much use they will be to our larger user base.
|
||||
|
||||
We strongly ask you to **create a discussion on GitHub for every new feature idea** outlining the use case and the proposed implementation design.
|
||||
Pull requests without prior discussion will likely just be closed.
|
||||
|
||||
For bug fixes and very minor changes, you can skip this step and open a PR right away.
|
||||
|
||||
Development
|
||||
-----------
|
||||
|
||||
To develop your contribution, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
|
||||
First of all, you'll need pretix running locally on your machine. Head over to :ref:`devsetup` to learn how to do this.
|
||||
If you run into any problems on your way, please do not hesitate to ask us anytime!
|
||||
|
||||
While developing, please have a look at our :ref:`aipolicy` and our guidelines on :ref:`codestyle`.
|
||||
Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be
|
||||
assured that we will not tolerate any form of harassment.
|
||||
|
||||
Sending a patch
|
||||
---------------
|
||||
|
||||
Once you have a first draft of your changes, please `create a pull request`_ on our `GitHub repository`_.
|
||||
If you improved pretix in any way, we'd be very happy if you contribute it
|
||||
back to the main code base! The easiest way to do so is to `create a pull request`_
|
||||
on our `GitHub repository`_.
|
||||
|
||||
We recommend that you create a feature branch for every issue you work on so the changes can
|
||||
be reviewed individually.
|
||||
@@ -41,17 +25,14 @@ Please use the test suite to check whether your changes break any existing featu
|
||||
the code style checks to confirm you are consistent with pretix's coding style. You'll
|
||||
find instructions on this in the :ref:`checksandtests` section of the development setup guide.
|
||||
|
||||
We automatically run the tests and the code style check on every pull request through GitHub Actions and we won’t
|
||||
We automatically run the tests and the code style check on every pull request on Travis CI and we won’t
|
||||
accept any pull requests without all tests passing. However, if you don't find out *why* they are not passing,
|
||||
just send the pull request and tell us – we'll be glad to help.
|
||||
|
||||
If you add a new feature, please include appropriate documentation into your patch. If you fix a bug,
|
||||
please include a regression test, i.e. a test that fails without your changes and passes after applying your changes.
|
||||
|
||||
Again: If you get stuck, do not hesitate to contact us through GitHub discussions.
|
||||
|
||||
Please note that we bound ourselves to a :ref:`coc` that applies to all communication around the project. You can be
|
||||
assured that we will not tolerate any form of harassment.
|
||||
Again: If you get stuck, do not hesitate to contact any of us, or Raphael personally at mail@raphaelmichel.de.
|
||||
|
||||
.. _create a pull request: https://help.github.com/articles/creating-a-pull-request/
|
||||
.. _GitHub repository: https://github.com/pretix/pretix
|
||||
|
||||
@@ -6,5 +6,4 @@ Contributing to pretix
|
||||
|
||||
general
|
||||
style
|
||||
ai
|
||||
codeofconduct
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
.. spelling:word-list:: Rebase rebasing
|
||||
|
||||
.. _`codestyle`:
|
||||
|
||||
Coding style and quality
|
||||
========================
|
||||
|
||||
@@ -30,6 +28,8 @@ Code
|
||||
Commits and Pull Requests
|
||||
-------------------------
|
||||
|
||||
|
||||
|
||||
Most commits should start as pull requests, therefore this applies to the titles of pull requests as well since
|
||||
the pull request title will become the commit message on merge. We prefer merging with GitHub's "Squash and merge"
|
||||
feature if the PR contains multiple commits that do not carry value to keep. If there is value in keeping the
|
||||
|
||||
@@ -196,7 +196,7 @@ A simple implementation could look like this:
|
||||
.. code-block:: python
|
||||
|
||||
class MyNotificationType(NotificationType):
|
||||
required_permission = "event.orders:read"
|
||||
required_permission = "can_view_orders"
|
||||
action_type = "pretix.event.order.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.
|
||||
The central concept here is the concept of *Teams*. You can read more on `configuring teams and permissions`_
|
||||
The central concept here is the concept of *Teams*. You can read more on `configuring teams and permissions <user-teams>`_
|
||||
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
|
||||
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):
|
||||
permission = 'organizer.settings.general:write'
|
||||
# Only users with the permission ``organizer.settings.general:write`` on
|
||||
permission = 'can_change_organizer_settings'
|
||||
# Only users with the permission ``can_change_organizer_settings`` on
|
||||
# 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
|
||||
|
||||
|
||||
@organizer_permission_required('organizer.settings.general:write')
|
||||
@organizer_permission_required('can_change_organizer_settings')
|
||||
def my_orga_view(request, organizer, **kwargs):
|
||||
# Only users with the permission ``organizer.settings.general:write`` on
|
||||
# Only users with the permission ``can_change_organizer_settings`` on
|
||||
# this organizer can access this
|
||||
|
||||
|
||||
@@ -56,8 +56,8 @@ Of course, the same is available on event level:
|
||||
|
||||
|
||||
class MyEventView(EventPermissionRequiredMixin, View):
|
||||
permission = 'event.settings.general:write'
|
||||
# Only users with the permission ``event.settings.general:write`` on
|
||||
permission = 'can_change_event_settings'
|
||||
# Only users with the permission ``can_change_event_settings`` on
|
||||
# this event can access this
|
||||
|
||||
|
||||
@@ -65,16 +65,13 @@ Of course, the same is available on event level:
|
||||
permission = None
|
||||
# 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('event.settings.general:write')
|
||||
@event_permission_required('can_change_event_settings')
|
||||
def my_event_view(request, organizer, **kwargs):
|
||||
# Only users with the permission ``event.settings.general:write`` on
|
||||
# Only users with the permission ``can_change_event_settings`` on
|
||||
# this event can access this
|
||||
|
||||
|
||||
@event_permission_required()
|
||||
def my_other_event_view(request, organizer, **kwargs):
|
||||
# Only users with *any* permission on this event can access this
|
||||
@@ -124,7 +121,7 @@ When creating your own ``viewset`` using Django REST framework, you just need to
|
||||
and pretix will check it automatically for you::
|
||||
|
||||
class MyModelViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
permission = 'event.orders:read'
|
||||
permission = 'can_view_orders'
|
||||
|
||||
Checking permission in code
|
||||
---------------------------
|
||||
@@ -139,12 +136,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::
|
||||
|
||||
>>> event.get_users_with_permission('event.orders:read')
|
||||
>>> event.get_users_with_permission('can_change_event_settings')
|
||||
<QuerySet: …>
|
||||
|
||||
Determine if a user has a certain permission for a specific event::
|
||||
|
||||
>>> user.has_event_permission(organizer, event, 'event.orders:read', request=request)
|
||||
>>> user.has_event_permission(organizer, event, 'can_change_event_settings', request=request)
|
||||
True
|
||||
|
||||
Determine if a user has any permission for a specific event::
|
||||
@@ -156,27 +153,27 @@ In the two previous commands, the ``request`` argument is optional, but required
|
||||
|
||||
The same method exists for organizer-level permissions::
|
||||
|
||||
>>> user.has_organizer_permission(organizer, 'event.orders:read', request=request)
|
||||
>>> user.has_organizer_permission(organizer, 'can_change_event_settings', request=request)
|
||||
True
|
||||
|
||||
Sometimes, it might be more useful to get the set of permissions at once::
|
||||
|
||||
>>> user.get_event_permission_set(organizer, event)
|
||||
{'event.settings.general:write', 'event.orders:read', 'event.orders:write'}
|
||||
{'can_change_event_settings', 'can_view_orders', 'can_change_orders'}
|
||||
|
||||
>>> user.get_organizer_permission_set(organizer, event)
|
||||
{'organizer.settings.general:write', 'organizer.events:create'}
|
||||
{'can_change_organizer_settings', 'can_create_events'}
|
||||
|
||||
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::
|
||||
|
||||
{% if "event.orders:write" in request.eventpermset %}
|
||||
{% if "can_change_orders" in request.eventpermset %}
|
||||
…
|
||||
{% endif %}
|
||||
|
||||
You can also do the reverse to get any events a user has access to::
|
||||
|
||||
>>> user.get_events_with_permission('event.settings.general:write', request=request)
|
||||
>>> user.get_events_with_permission('can_change_event_settings', request=request)
|
||||
<QuerySet: …>
|
||||
|
||||
>>> user.get_events_with_any_permission(request=request)
|
||||
@@ -198,53 +195,3 @@ 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,
|
||||
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.
|
||||
|
||||
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,8 +1,9 @@
|
||||
sphinx==9.1.*
|
||||
sphinx-rtd-theme~=3.1.0
|
||||
sphinxcontrib-httpdomain~=2.0.0
|
||||
sphinxcontrib-images~=1.0.1
|
||||
sphinxcontrib-jquery~=4.1
|
||||
sphinxcontrib-spelling~=8.0.2
|
||||
sphinxemoji~=0.3.2
|
||||
sphinx==7.4.*
|
||||
jinja2==3.1.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
sphinxcontrib-images
|
||||
sphinxcontrib-jquery
|
||||
sphinxcontrib-spelling==8.*
|
||||
sphinxemoji
|
||||
pyenchant==3.3.*
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
-e ../
|
||||
sphinx==9.1.*
|
||||
sphinx-rtd-theme~=3.1.0
|
||||
sphinxcontrib-httpdomain~=2.0.0
|
||||
sphinxcontrib-images~=1.0.1
|
||||
sphinxcontrib-jquery~=4.1
|
||||
sphinxcontrib-spelling~=8.0.2
|
||||
sphinxemoji~=0.3.2
|
||||
sphinx==7.4.*
|
||||
jinja2==3.1.*
|
||||
sphinx-rtd-theme
|
||||
sphinxcontrib-httpdomain
|
||||
sphinxcontrib-images
|
||||
sphinxcontrib-jquery
|
||||
sphinxcontrib-spelling==8.*
|
||||
sphinxemoji
|
||||
pyenchant==3.3.*
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "pretix"
|
||||
dynamic = ["version"]
|
||||
description = "Reinventing presales, one ticket at a time"
|
||||
readme = "README.rst"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.9"
|
||||
license = {file = "LICENSE"}
|
||||
keywords = ["tickets", "web", "shop", "ecommerce"]
|
||||
authors = [
|
||||
@@ -29,19 +29,18 @@ dependencies = [
|
||||
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
|
||||
"babel",
|
||||
"BeautifulSoup4==4.14.*",
|
||||
"bleach==6.3.*",
|
||||
"celery==5.6.*",
|
||||
"bleach==6.2.*",
|
||||
"celery==5.5.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=44.0.0",
|
||||
"css-inline==0.20.*",
|
||||
"css-inline==0.18.*",
|
||||
"defusedcsv>=1.1.0",
|
||||
"dnspython==2.*",
|
||||
"Django[argon2]==4.2.*,>=4.2.26",
|
||||
"django-bootstrap3==26.1",
|
||||
"django-compressor==4.6.0",
|
||||
"django-countries==8.2.*",
|
||||
"Django[argon2]==4.2.*,>=4.2.24",
|
||||
"django-bootstrap3==25.2",
|
||||
"django-compressor==4.5.1",
|
||||
"django-countries==7.6.*",
|
||||
"django-filter==25.1",
|
||||
"django-formset-js-improved==0.5.0.5",
|
||||
"django-formset-js-improved==0.5.0.4",
|
||||
"django-formtools==2.5.1",
|
||||
"django-hierarkey==2.0.*,>=2.0.1",
|
||||
"django-hijack==3.7.*",
|
||||
@@ -50,22 +49,22 @@ dependencies = [
|
||||
"django-localflavor==5.0",
|
||||
"django-markup",
|
||||
"django-oauth-toolkit==2.3.*",
|
||||
"django-otp==1.7.*",
|
||||
"django-phonenumber-field==8.4.*",
|
||||
"django-otp==1.6.*",
|
||||
"django-phonenumber-field==7.3.*",
|
||||
"django-redis==6.0.*",
|
||||
"django-scopes==2.0.*",
|
||||
"django-statici18n==2.7.*",
|
||||
"django-statici18n==2.6.*",
|
||||
"djangorestframework==3.16.*",
|
||||
"dnspython==2.8.*",
|
||||
"dnspython==2.7.*",
|
||||
"drf_ujson2==1.7.*",
|
||||
"geoip2==5.*",
|
||||
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||
"isoweek",
|
||||
"jsonschema",
|
||||
"kombu==5.6.*",
|
||||
"kombu==5.5.*",
|
||||
"libsass==0.23.*",
|
||||
"lxml",
|
||||
"markdown==3.10.2", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
"markdown==3.9", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
|
||||
"mt-940==4.30.*",
|
||||
"oauthlib==3.3.*",
|
||||
@@ -73,32 +72,33 @@ dependencies = [
|
||||
"packaging",
|
||||
"paypalrestsdk==1.13.*",
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.12.*",
|
||||
"PyJWT==2.10.*",
|
||||
"phonenumberslite==9.0.*",
|
||||
"Pillow==12.1.*",
|
||||
"Pillow==11.3.*",
|
||||
"pretix-plugin-build",
|
||||
"protobuf==7.34.*",
|
||||
"protobuf==6.33.*",
|
||||
"psycopg2-binary",
|
||||
"pycountry",
|
||||
"pycparser==3.0",
|
||||
"pycparser==2.23",
|
||||
"pycryptodome==3.23.*",
|
||||
"pypdf==6.5.*",
|
||||
"pypdf==6.1.*",
|
||||
"python-bidi==0.6.*", # Support for Arabic in reportlab
|
||||
"python-dateutil==2.9.*",
|
||||
"pytz",
|
||||
"pytz-deprecation-shim==0.1.*",
|
||||
"pyuca",
|
||||
"qrcode==8.2",
|
||||
"redis==7.1.*",
|
||||
"redis==6.4.*",
|
||||
"reportlab==4.4.*",
|
||||
"requests==2.32.*",
|
||||
"sentry-sdk==2.54.*",
|
||||
"sentry-sdk==2.43.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
"tlds>=2020041600",
|
||||
"tqdm==4.*",
|
||||
"ua-parser==1.0.*",
|
||||
"vat_moss_forked==2020.3.20.0.11.0",
|
||||
"vobject==0.9.*",
|
||||
"webauthn==2.7.*",
|
||||
"zeep==4.3.*"
|
||||
@@ -110,10 +110,10 @@ dev = [
|
||||
"aiohttp==3.13.*",
|
||||
"coverage",
|
||||
"coveralls",
|
||||
"fakeredis==2.34.*",
|
||||
"fakeredis==2.32.*",
|
||||
"flake8==7.3.*",
|
||||
"freezegun",
|
||||
"isort==8.0.*",
|
||||
"isort==6.1.*",
|
||||
"pep8-naming==0.15.*",
|
||||
"potypo",
|
||||
"pytest-asyncio>=0.24",
|
||||
@@ -123,7 +123,7 @@ dev = [
|
||||
"pytest-mock==3.15.*",
|
||||
"pytest-sugar",
|
||||
"pytest-xdist==3.8.*",
|
||||
"pytest==9.0.*",
|
||||
"pytest==8.4.*",
|
||||
"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
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
__version__ = "2026.3.0.dev0"
|
||||
__version__ = "2025.10.0.dev0"
|
||||
|
||||
@@ -36,9 +36,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
|
||||
|
||||
from pretix.api.models import OAuthAccessToken
|
||||
from pretix.base.models import Device, Event, User
|
||||
from pretix.base.models.auth import (
|
||||
EventPermissionSet, OrganizerPermissionSet, SuperuserPermissionSet,
|
||||
)
|
||||
from pretix.base.models.auth import SuperuserPermissionSet
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.helpers.security import (
|
||||
Session2FASetupRequired, SessionInvalid, SessionPasswordChangeRequired,
|
||||
@@ -87,7 +85,7 @@ class EventPermission(BasePermission):
|
||||
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
|
||||
request.eventpermset = SuperuserPermissionSet()
|
||||
else:
|
||||
request.eventpermset = EventPermissionSet(perm_holder.get_event_permission_set(request.organizer, request.event))
|
||||
request.eventpermset = perm_holder.get_event_permission_set(request.organizer, request.event)
|
||||
|
||||
if isinstance(required_permission, (list, tuple)):
|
||||
if not any(p in request.eventpermset for p in required_permission):
|
||||
@@ -102,7 +100,7 @@ class EventPermission(BasePermission):
|
||||
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):
|
||||
request.orgapermset = SuperuserPermissionSet()
|
||||
else:
|
||||
request.orgapermset = OrganizerPermissionSet(perm_holder.get_organizer_permission_set(request.organizer))
|
||||
request.orgapermset = perm_holder.get_organizer_permission_set(request.organizer)
|
||||
|
||||
if isinstance(required_permission, (list, tuple)):
|
||||
if not any(p in request.eventpermset for p in required_permission):
|
||||
@@ -126,12 +124,12 @@ class EventCRUDPermission(EventPermission):
|
||||
def has_permission(self, request, view):
|
||||
if not super(EventCRUDPermission, self).has_permission(request, view):
|
||||
return False
|
||||
elif view.action == 'create' and 'organizer.events:create' not in request.orgapermset:
|
||||
elif view.action == 'create' and 'can_create_events' not in request.orgapermset:
|
||||
return False
|
||||
elif view.action == 'destroy' and 'event.settings.general:write' not in request.eventpermset:
|
||||
elif view.action == 'destroy' and 'can_change_event_settings' not in request.eventpermset:
|
||||
return False
|
||||
elif view.action in ['update', 'partial_update'] \
|
||||
and 'event.settings.general:write' not in request.eventpermset:
|
||||
and 'can_change_event_settings' not in request.eventpermset:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
# Generated by Django 4.2.24 on 2025-11-14 16:21
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixapi", "0013_alter_webhookcallretry_retry_not_before"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="webhook",
|
||||
name="target_url",
|
||||
field=models.URLField(max_length=1024),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="webhookcall",
|
||||
name="target_url",
|
||||
field=models.URLField(max_length=1024),
|
||||
),
|
||||
]
|
||||
@@ -114,7 +114,7 @@ class OAuthRefreshToken(AbstractRefreshToken):
|
||||
class WebHook(models.Model):
|
||||
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
|
||||
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
|
||||
target_url = models.URLField(verbose_name=_("Target URL"), max_length=1024)
|
||||
target_url = models.URLField(verbose_name=_("Target URL"), max_length=255)
|
||||
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
|
||||
limit_events = models.ManyToManyField('pretixbase.Event', verbose_name=_("Limit to events"), blank=True)
|
||||
comment = models.CharField(verbose_name=_("Comment"), max_length=255, null=True, blank=True)
|
||||
@@ -140,7 +140,7 @@ class WebHookEventListener(models.Model):
|
||||
class WebHookCall(models.Model):
|
||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
|
||||
datetime = models.DateTimeField(auto_now_add=True)
|
||||
target_url = models.URLField(max_length=1024)
|
||||
target_url = models.URLField(max_length=255)
|
||||
action_type = models.CharField(max_length=255)
|
||||
is_retry = models.BooleanField(default=False)
|
||||
execution_time = models.FloatField(null=True)
|
||||
|
||||
@@ -300,7 +300,7 @@ class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
def ignored_meta_properties(self):
|
||||
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
|
||||
else self.context['request'].user)
|
||||
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'organizer.settings.general:write', request=self.context['request']):
|
||||
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'can_change_organizer_settings', request=self.context['request']):
|
||||
return []
|
||||
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)
|
||||
new_event = super().create({**validated_data, 'plugins': None})
|
||||
|
||||
event = self.context['event']
|
||||
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
|
||||
new_event.copy_data_from(event, skip_meta_data='meta_data' in validated_data)
|
||||
|
||||
if plugins is not None:
|
||||
@@ -561,7 +561,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
|
||||
def ignored_meta_properties(self):
|
||||
perm_holder = (self.context['request'].auth if isinstance(self.context['request'].auth, (Device, TeamAPIToken))
|
||||
else self.context['request'].user)
|
||||
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'organizer.settings.general:write', request=self.context['request']):
|
||||
if perm_holder.has_organizer_permission(self.context['request'].organizer, 'can_change_organizer_settings', request=self.context['request']):
|
||||
return []
|
||||
return [k for k, p in self.meta_properties.items() if p.protected]
|
||||
|
||||
@@ -707,10 +707,7 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer):
|
||||
|
||||
|
||||
class EventSettingsSerializer(SettingsSerializer):
|
||||
default_write_permission = 'event.settings.general:write'
|
||||
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',
|
||||
'checkout_email_helptext',
|
||||
'presale_has_ended_text',
|
||||
@@ -798,7 +795,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'invoice_address_asked',
|
||||
'invoice_address_required',
|
||||
'invoice_address_vatid',
|
||||
'invoice_address_vatid_required_countries',
|
||||
'invoice_address_company_required',
|
||||
'invoice_address_beneficiary',
|
||||
'invoice_address_custom_field',
|
||||
@@ -809,7 +805,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'invoice_reissue_after_modify',
|
||||
'invoice_include_free',
|
||||
'invoice_generate',
|
||||
'invoice_generate_only_business',
|
||||
'invoice_period',
|
||||
'invoice_numbers_consecutive',
|
||||
'invoice_numbers_prefix',
|
||||
@@ -825,7 +820,6 @@ class EventSettingsSerializer(SettingsSerializer):
|
||||
'invoice_address_from',
|
||||
'invoice_address_from_zipcode',
|
||||
'invoice_address_from_city',
|
||||
'invoice_address_from_state',
|
||||
'invoice_address_from_country',
|
||||
'invoice_address_from_tax_id',
|
||||
'invoice_address_from_vat_id',
|
||||
@@ -948,7 +942,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'invoice_address_asked',
|
||||
'invoice_address_required',
|
||||
'invoice_address_vatid',
|
||||
'invoice_address_vatid_required_countries',
|
||||
'invoice_address_company_required',
|
||||
'invoice_address_beneficiary',
|
||||
'invoice_address_custom_field',
|
||||
@@ -959,7 +952,6 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
||||
'invoice_address_from',
|
||||
'invoice_address_from_zipcode',
|
||||
'invoice_address_from_city',
|
||||
'invoice_address_from_state',
|
||||
'invoice_address_from_country',
|
||||
'invoice_address_from_tax_id',
|
||||
'invoice_address_from_vat_id',
|
||||
@@ -1083,16 +1075,16 @@ class SeatSerializer(I18nAwareModelSerializer):
|
||||
|
||||
def prefetch_expanded_data(self, items, request, expand_fields):
|
||||
if 'orderposition' in expand_fields:
|
||||
if 'event.orders:read' not in request.eventpermset:
|
||||
raise PermissionDenied('event.orders:read permission required for expand=orderposition')
|
||||
if 'can_view_orders' not in request.eventpermset:
|
||||
raise PermissionDenied('can_view_orders permission required for expand=orderposition')
|
||||
prefetch_by_id(items, OrderPosition.objects.prefetch_related('order'), 'orderposition_id', 'orderposition')
|
||||
if 'cartposition' in expand_fields:
|
||||
if 'event.orders:read' not in request.eventpermset:
|
||||
raise PermissionDenied('event.orders:read permission required for expand=cartposition')
|
||||
if 'can_view_orders' not in request.eventpermset:
|
||||
raise PermissionDenied('can_view_orders permission required for expand=cartposition')
|
||||
prefetch_by_id(items, CartPosition.objects, 'cartposition_id', 'cartposition')
|
||||
if 'voucher' in expand_fields:
|
||||
if 'event.vouchers:read' not in request.eventpermset:
|
||||
raise PermissionDenied('event.vouchers:read permission required for expand=voucher')
|
||||
if 'can_view_vouchers' not in request.eventpermset:
|
||||
raise PermissionDenied('can_view_vouchers permission required for expand=voucher')
|
||||
prefetch_by_id(items, Voucher.objects, 'voucher_id', 'voucher')
|
||||
|
||||
def __init__(self, instance, *args, **kwargs):
|
||||
|
||||
@@ -27,9 +27,7 @@ from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.forms import form_field_to_serializer_field
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
from pretix.base.models import (
|
||||
Event, ScheduledEventExport, ScheduledOrganizerExport,
|
||||
)
|
||||
from pretix.base.models import ScheduledEventExport, ScheduledOrganizerExport
|
||||
from pretix.base.timeframes import SerializerDateFrameField
|
||||
|
||||
|
||||
@@ -56,29 +54,20 @@ class ExporterSerializer(serializers.Serializer):
|
||||
|
||||
class JobRunSerializer(serializers.Serializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
ex = self.ex = kwargs.pop('exporter')
|
||||
ex = kwargs.pop('exporter')
|
||||
events = kwargs.pop('events', None)
|
||||
super().__init__(*args, **kwargs)
|
||||
if ex.is_multievent and not isinstance(ex, OrganizerLevelExportMixin):
|
||||
self.fields["all_events"] = serializers.BooleanField(
|
||||
required=False,
|
||||
)
|
||||
if events is not None and not isinstance(ex, OrganizerLevelExportMixin):
|
||||
self.fields["events"] = serializers.SlugRelatedField(
|
||||
queryset=ex.events,
|
||||
queryset=events,
|
||||
required=False,
|
||||
allow_empty=True,
|
||||
allow_empty=False,
|
||||
slug_field='slug',
|
||||
many=True
|
||||
)
|
||||
for k, v in ex.export_form_fields.items():
|
||||
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):
|
||||
if isinstance(data, QueryDict):
|
||||
data = data.copy()
|
||||
@@ -106,14 +95,6 @@ class JobRunSerializer(serializers.Serializer):
|
||||
data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}'
|
||||
|
||||
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
|
||||
|
||||
def is_valid(self, raise_exception=False):
|
||||
@@ -150,20 +131,13 @@ class ScheduledExportSerializer(serializers.ModelSerializer):
|
||||
exporter = self.context['exporters'].get(identifier)
|
||||
if exporter:
|
||||
try:
|
||||
attrs["export_form_data"] = JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"])
|
||||
JobRunSerializer(exporter=exporter).to_internal_value(attrs["export_form_data"])
|
||||
except ValidationError as e:
|
||||
raise ValidationError({"export_form_data": e.detail})
|
||||
else:
|
||||
raise ValidationError({"export_identifier": ["Unknown exporter."]})
|
||||
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):
|
||||
d = value.replace(' ', '')
|
||||
if len(d.split(',')) > 25:
|
||||
|
||||
@@ -65,9 +65,8 @@ def form_field_to_serializer_field(field):
|
||||
if isinstance(field, m_from):
|
||||
return m_to(
|
||||
required=field.required,
|
||||
allow_null=not field.required and not isinstance(field, forms.BooleanField),
|
||||
allow_null=not field.required,
|
||||
validators=field.validators,
|
||||
initial=field.initial,
|
||||
**{kwarg: getattr(field, kwarg, None) for kwarg in m_kwargs}
|
||||
)
|
||||
|
||||
|
||||
@@ -47,9 +47,8 @@ from pretix.api.serializers.event import MetaDataField
|
||||
from pretix.api.serializers.fields import UploadedFileField
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.base.models import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemProgramTime,
|
||||
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
||||
SalesChannel,
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
|
||||
ItemVariationMetaValue, Question, QuestionOption, Quota, SalesChannel,
|
||||
)
|
||||
|
||||
|
||||
@@ -188,12 +187,6 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
|
||||
'position', 'price_included', 'multi_allowed')
|
||||
|
||||
|
||||
class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemProgramTime
|
||||
fields = ('start', 'end')
|
||||
|
||||
|
||||
class ItemBundleSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemBundle
|
||||
@@ -219,37 +212,6 @@ class ItemBundleSerializer(serializers.ModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class ItemProgramTimeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemProgramTime
|
||||
fields = ('id', 'start', 'end')
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
|
||||
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
|
||||
full_data.update(data)
|
||||
|
||||
start = full_data.get('start')
|
||||
if not start:
|
||||
raise ValidationError(_("The program start must not be empty."))
|
||||
|
||||
end = full_data.get('end')
|
||||
if not end:
|
||||
raise ValidationError(_("The program end must not be empty."))
|
||||
|
||||
if start > end:
|
||||
raise ValidationError(_("The program end must not be before the program start."))
|
||||
|
||||
event = self.context['event']
|
||||
if event.has_subevents:
|
||||
raise ValidationError({
|
||||
_("You cannot use program times on an event series.")
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
class ItemAddOnSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ItemAddOn
|
||||
@@ -288,7 +250,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
addons = InlineItemAddOnSerializer(many=True, required=False)
|
||||
bundles = InlineItemBundleSerializer(many=True, required=False)
|
||||
variations = InlineItemVariationSerializer(many=True, required=False)
|
||||
program_times = InlineItemProgramTimeSerializer(many=True, required=False)
|
||||
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
||||
meta_data = MetaDataField(required=False, source='*')
|
||||
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
||||
@@ -310,7 +271,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
|
||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||
'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations',
|
||||
'addons', 'bundles', 'program_times', 'original_price', 'require_approval', 'generate_tickets',
|
||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
||||
'show_quota_left', 'hidden_if_available', 'hidden_if_item_available', 'hidden_if_item_available_mode', 'allow_waitinglist',
|
||||
'issue_giftcard', 'meta_data',
|
||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
|
||||
@@ -333,9 +294,9 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data or 'program_times' in data):
|
||||
raise ValidationError(_('Updating add-ons, bundles, program times or variations via PATCH/PUT is not '
|
||||
'supported. Please use the dedicated nested endpoint.'))
|
||||
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data):
|
||||
raise ValidationError(_('Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use the '
|
||||
'dedicated nested endpoint.'))
|
||||
|
||||
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
|
||||
Item.clean_available(data.get('available_from'), data.get('available_until'))
|
||||
@@ -386,13 +347,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
ItemAddOn.clean_max_min_count(addon_data.get('max_count', 0), addon_data.get('min_count', 0))
|
||||
return value
|
||||
|
||||
def validate_program_times(self, value):
|
||||
if not self.instance:
|
||||
for program_time_data in value:
|
||||
ItemProgramTime.clean_start_end(self, start=program_time_data.get('start', None),
|
||||
end=program_time_data.get('end', None))
|
||||
return value
|
||||
|
||||
@cached_property
|
||||
def item_meta_properties(self):
|
||||
return {
|
||||
@@ -410,7 +364,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
variations_data = validated_data.pop('variations') if 'variations' in validated_data else {}
|
||||
addons_data = validated_data.pop('addons') if 'addons' in validated_data else {}
|
||||
bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {}
|
||||
program_times_data = validated_data.pop('program_times') if 'program_times' in validated_data else {}
|
||||
meta_data = validated_data.pop('meta_data', None)
|
||||
picture = validated_data.pop('picture', None)
|
||||
require_membership_types = validated_data.pop('require_membership_types', [])
|
||||
@@ -445,8 +398,6 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
||||
ItemAddOn.objects.create(base_item=item, **addon_data)
|
||||
for bundle_data in bundles_data:
|
||||
ItemBundle.objects.create(base_item=item, **bundle_data)
|
||||
for program_time_data in program_times_data:
|
||||
ItemProgramTime.objects.create(item=item, **program_time_data)
|
||||
|
||||
# Meta data
|
||||
if meta_data is not None:
|
||||
|
||||
@@ -24,7 +24,7 @@ from decimal import Decimal
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from pretix.api.serializers.order import OrderPositionSerializer
|
||||
@@ -66,9 +66,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
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)
|
||||
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)
|
||||
@@ -80,8 +77,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
|
||||
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)
|
||||
else:
|
||||
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
|
||||
@@ -91,9 +86,6 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
|
||||
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)
|
||||
else:
|
||||
self.fields['customer'] = serializers.SlugRelatedField(
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
# 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 json
|
||||
import logging
|
||||
import os
|
||||
from collections import Counter, defaultdict
|
||||
@@ -53,7 +52,7 @@ from pretix.base.decimal import round_decimal
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.invoicing.transmission import get_transmission_types
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress,
|
||||
CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
|
||||
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
|
||||
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
|
||||
Voucher,
|
||||
@@ -62,7 +61,6 @@ from pretix.base.models.orders import (
|
||||
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||
PrintLog, RevokedTicketSecret, Transaction,
|
||||
)
|
||||
from pretix.base.payment import GiftCardPayment, PaymentException
|
||||
from pretix.base.pdf import get_images, get_variables
|
||||
from pretix.base.services.cart import error_messages
|
||||
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
|
||||
@@ -193,7 +191,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
||||
{"transmission_info": {r: "This field is required for the selected type of invoice transmission."}}
|
||||
)
|
||||
break # do not call else branch of for loop
|
||||
elif t.is_exclusive(self.context["request"].event, data.get("country"), data.get("is_business")):
|
||||
elif t.exclusive:
|
||||
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
|
||||
raise ValidationError({
|
||||
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
|
||||
@@ -615,7 +613,7 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
# /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
|
||||
# layer to not set pdf_data=true in the first place.
|
||||
request and hasattr(request, 'eventpermset') and 'event.orders:read' not in request.eventpermset
|
||||
request and hasattr(request, 'eventpermset') and 'can_view_orders' not in request.eventpermset
|
||||
)
|
||||
if ('pdf_data' in self.context and not self.context['pdf_data']) or pdf_data_forbidden:
|
||||
self.fields.pop('pdf_data', None)
|
||||
@@ -638,14 +636,6 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||
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):
|
||||
def to_representation(self, instance: OrderPosition):
|
||||
return instance.require_checkin_attention
|
||||
@@ -714,16 +704,6 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
||||
if 'answers.question' in self.context['expand']:
|
||||
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
|
||||
|
||||
if 'addons' in self.context['expand']:
|
||||
# Experimental feature, undocumented on purpose for now in case we need to remove it again
|
||||
# for performance reasons
|
||||
subl = CheckinListOrderPositionSerializer(read_only=True, many=True, context={
|
||||
**self.context,
|
||||
'expand': [v for v in self.context['expand'] if v != 'addons'],
|
||||
'pdf_data': False,
|
||||
})
|
||||
self.fields['addons'] = subl
|
||||
|
||||
|
||||
class OrderPaymentTypeField(serializers.Field):
|
||||
# TODO: Remove after pretix 2.2
|
||||
@@ -1201,7 +1181,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
|
||||
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):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -1217,7 +1196,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
|
||||
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
|
||||
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode', 'use_gift_cards')
|
||||
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode')
|
||||
|
||||
def validate_payment_provider(self, pp):
|
||||
if pp is None:
|
||||
@@ -1226,18 +1205,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError('The given payment provider is not known.')
|
||||
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):
|
||||
if expires < now():
|
||||
raise ValidationError('Expiration date must be in the future.')
|
||||
@@ -1312,14 +1279,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
payment_date = validated_data.pop('payment_date', now())
|
||||
force = validated_data.pop('force', 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"):
|
||||
validated_data["sales_channel"] = self.context['event'].organizer.sales_channels.get(identifier="web")
|
||||
@@ -1642,7 +1601,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
order.sales_channel,
|
||||
[
|
||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
|
||||
cp.addon_to, cp.is_bundled, pos._voucher_discount)
|
||||
bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
|
||||
for cp in order_positions
|
||||
]
|
||||
)
|
||||
@@ -1774,7 +1733,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
rounding_mode = self.context["event"].settings.tax_rounding
|
||||
changed = apply_rounding(
|
||||
rounding_mode,
|
||||
ia,
|
||||
self.context["event"].currency,
|
||||
[*pos_map.values(), *fees]
|
||||
)
|
||||
@@ -1804,45 +1762,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
if order.total != Decimal('0.00') and order.event.currency == "XXX":
|
||||
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'):
|
||||
order.status = Order.STATUS_PAID
|
||||
order.save()
|
||||
@@ -1912,7 +1831,7 @@ class InvoiceSerializer(I18nAwareModelSerializer):
|
||||
class Meta:
|
||||
model = Invoice
|
||||
fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
|
||||
'invoice_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
|
||||
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
|
||||
'invoice_to', 'invoice_to_is_business', 'invoice_to_company', 'invoice_to_name', 'invoice_to_street',
|
||||
'invoice_to_zipcode', 'invoice_to_city', 'invoice_to_state', 'invoice_to_country', 'invoice_to_vat_id',
|
||||
'invoice_to_beneficiary', 'invoice_to_transmission_info', 'custom_field', 'date', 'refers', 'locale',
|
||||
|
||||
@@ -33,7 +33,7 @@ from pretix.api.serializers.order import (
|
||||
OrderFeeCreateSerializer, OrderPositionCreateSerializer,
|
||||
)
|
||||
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
|
||||
from pretix.base.services.orders import OrderChangeManager, OrderError
|
||||
from pretix.base.services.orders import OrderError
|
||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -82,11 +82,11 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
ocm: OrderChangeManager = self.context['ocm']
|
||||
ocm = self.context['ocm']
|
||||
check_quotas = self.context.get('check_quotas', True)
|
||||
|
||||
try:
|
||||
new_position = ocm.add_position(
|
||||
ocm.add_position(
|
||||
item=validated_data['item'],
|
||||
variation=validated_data.get('variation'),
|
||||
price=validated_data.get('price'),
|
||||
@@ -98,7 +98,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
|
||||
)
|
||||
if self.context.get('commit', True):
|
||||
ocm.commit(check_quotas=check_quotas)
|
||||
return new_position.position
|
||||
return validated_data['order'].positions.order_by('-positionid').first()
|
||||
else:
|
||||
return OrderPosition() # fake to appease DRF
|
||||
except OrderError as e:
|
||||
@@ -131,7 +131,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
ocm: OrderChangeManager = self.context['ocm']
|
||||
ocm = self.context['ocm']
|
||||
|
||||
try:
|
||||
f = OrderFee(
|
||||
@@ -146,7 +146,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
|
||||
ocm.add_fee(f)
|
||||
if self.context.get('commit', True):
|
||||
ocm.commit()
|
||||
return f
|
||||
return validated_data['order'].fees.order_by('-pk').first()
|
||||
else:
|
||||
return OrderFee() # fake to appease DRF
|
||||
except OrderError as e:
|
||||
@@ -310,7 +310,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
|
||||
return data
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
ocm: OrderChangeManager = self.context['ocm']
|
||||
ocm = self.context['ocm']
|
||||
check_quotas = self.context.get('check_quotas', True)
|
||||
current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None
|
||||
item = validated_data.get('item', instance.item)
|
||||
@@ -399,7 +399,7 @@ class OrderFeeChangeSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
ocm: OrderChangeManager = self.context['ocm']
|
||||
ocm = self.context['ocm']
|
||||
value = validated_data.get('value', instance.value)
|
||||
|
||||
try:
|
||||
|
||||
@@ -45,19 +45,12 @@ from pretix.base.models import (
|
||||
SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
|
||||
)
|
||||
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 (
|
||||
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
|
||||
PLUGIN_LEVEL_ORGANIZER,
|
||||
)
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
from pretix.base.settings import validate_organizer_settings
|
||||
from pretix.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.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
@@ -313,128 +306,23 @@ class EventSlugField(serializers.SlugRelatedField):
|
||||
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):
|
||||
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:
|
||||
model = Team
|
||||
fields = (
|
||||
'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'all_event_permissions', 'limit_event_permissions',
|
||||
'all_organizer_permissions', 'limit_organizer_permissions', 'can_change_event_settings',
|
||||
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_checkin_orders', 'can_view_vouchers',
|
||||
'can_change_vouchers', 'can_create_events', 'can_change_organizer_settings', 'can_change_teams',
|
||||
'can_manage_gift_cards', 'can_manage_customers', 'can_manage_reusable_media'
|
||||
'id', 'name', 'require_2fa', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
|
||||
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
|
||||
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
|
||||
'can_change_vouchers', 'can_checkin_orders', '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):
|
||||
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.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'):
|
||||
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
|
||||
|
||||
|
||||
@@ -451,7 +339,7 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
created = serializers.DateTimeField(read_only=True)
|
||||
revoked = serializers.BooleanField(read_only=True)
|
||||
initialized = serializers.DateTimeField(read_only=True)
|
||||
initialization_token = serializers.CharField(read_only=True)
|
||||
initialization_token = serializers.DateTimeField(read_only=True)
|
||||
security_profile = serializers.ChoiceField(choices=[], required=False, default="full")
|
||||
|
||||
class Meta:
|
||||
@@ -465,8 +353,6 @@ class DeviceSerializer(serializers.ModelSerializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
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):
|
||||
@@ -477,22 +363,24 @@ class TeamInviteSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
|
||||
def _send_invite(self, instance):
|
||||
mail(
|
||||
instance.email,
|
||||
_('Account invitation'),
|
||||
'pretixcontrol/email/invitation.txt',
|
||||
{
|
||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||
'user': self,
|
||||
'organizer': self.context['organizer'].name,
|
||||
'team': instance.team.name,
|
||||
'url': build_global_uri('control:auth.invite', kwargs={
|
||||
'token': instance.token
|
||||
})
|
||||
},
|
||||
event=None,
|
||||
locale=get_language_without_region() # TODO: expose?
|
||||
)
|
||||
try:
|
||||
mail(
|
||||
instance.email,
|
||||
_('pretix account invitation'),
|
||||
'pretixcontrol/email/invitation.txt',
|
||||
{
|
||||
'user': self,
|
||||
'organizer': self.context['organizer'].name,
|
||||
'team': instance.team.name,
|
||||
'url': build_global_uri('control:auth.invite', kwargs={
|
||||
'token': instance.token
|
||||
})
|
||||
},
|
||||
event=None,
|
||||
locale=get_language_without_region() # TODO: expose?
|
||||
)
|
||||
except SendMailException:
|
||||
pass # Already logged
|
||||
|
||||
def create(self, validated_data):
|
||||
if 'email' in validated_data:
|
||||
@@ -551,14 +439,10 @@ class TeamMemberSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class OrganizerSettingsSerializer(SettingsSerializer):
|
||||
default_write_permission = 'organizer.settings.general:write'
|
||||
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_native',
|
||||
'customer_accounts_link_by_email',
|
||||
'customer_accounts_require_login_for_order_access',
|
||||
'invoice_regenerate_allowed',
|
||||
'contact_mail',
|
||||
'imprint_url',
|
||||
|
||||
@@ -37,8 +37,6 @@ logger = logging.getLogger(__name__)
|
||||
class SettingsSerializer(serializers.Serializer):
|
||||
default_fields = []
|
||||
readonly_fields = []
|
||||
default_write_permission = 'organizer.settings.general:write'
|
||||
write_permission_required = {}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.changed_data = []
|
||||
@@ -60,17 +58,9 @@ class SettingsSerializer(serializers.Serializer):
|
||||
f._label = str(form_kwargs.get('label', fname))
|
||||
f._help_text = str(form_kwargs.get('help_text'))
|
||||
f.parent = self
|
||||
|
||||
self.write_permission_required[fname] = DEFAULTS[fname].get('write_permission', self.default_write_permission)
|
||||
|
||||
self.fields[fname] = f
|
||||
|
||||
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}
|
||||
|
||||
def update(self, instance: HierarkeyProxy, validated_data):
|
||||
|
||||
@@ -67,7 +67,6 @@ orga_router.register(r'invoices', order.InvoiceViewSet)
|
||||
orga_router.register(r'scheduled_exports', exporters.ScheduledOrganizerExportViewSet)
|
||||
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
|
||||
orga_router.register(r'transactions', order.OrganizerTransactionViewSet)
|
||||
orga_router.register(r'orderpositions', order.OrganizerOrderPositionViewSet, basename='orderpositions')
|
||||
|
||||
team_router = routers.DefaultRouter()
|
||||
team_router.register(r'members', organizer.TeamMemberViewSet)
|
||||
@@ -84,7 +83,7 @@ event_router.register(r'discounts', discount.DiscountViewSet)
|
||||
event_router.register(r'quotas', item.QuotaViewSet)
|
||||
event_router.register(r'vouchers', voucher.VoucherViewSet)
|
||||
event_router.register(r'orders', order.EventOrderViewSet)
|
||||
event_router.register(r'orderpositions', order.EventOrderPositionViewSet)
|
||||
event_router.register(r'orderpositions', order.OrderPositionViewSet)
|
||||
event_router.register(r'transactions', order.TransactionViewSet)
|
||||
event_router.register(r'invoices', order.InvoiceViewSet)
|
||||
event_router.register(r'revokedsecrets', order.RevokedSecretViewSet, basename='revokedsecrets')
|
||||
@@ -113,7 +112,6 @@ item_router = routers.DefaultRouter()
|
||||
item_router.register(r'variations', item.ItemVariationViewSet)
|
||||
item_router.register(r'addons', item.ItemAddOnViewSet)
|
||||
item_router.register(r'bundles', item.ItemBundleViewSet)
|
||||
item_router.register(r'program_times', item.ItemProgramTimeViewSet)
|
||||
|
||||
order_router = routers.DefaultRouter()
|
||||
order_router.register(r'payments', order.PaymentViewSet)
|
||||
|
||||
@@ -52,8 +52,8 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
|
||||
ordering = ('datetime',)
|
||||
ordering_fields = ('datetime', 'cart_id')
|
||||
lookup_field = 'id'
|
||||
permission = 'event.orders:read'
|
||||
write_permission = 'event.orders:write'
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
return CartPosition.objects.filter(
|
||||
|
||||
@@ -67,7 +67,6 @@ from pretix.base.models import (
|
||||
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models.orders import PrintLog
|
||||
from pretix.base.permissions import AnyPermissionOf
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
|
||||
)
|
||||
@@ -119,11 +118,11 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
|
||||
def _get_permission_name(self, request):
|
||||
if request.path.endswith('/failed_checkins/'):
|
||||
return 'event.orders:checkin', 'event.orders:write'
|
||||
return 'can_checkin_orders', 'can_change_orders'
|
||||
elif request.method in SAFE_METHODS:
|
||||
return 'event.orders:read', 'event.orders:checkin',
|
||||
return 'can_view_orders', 'can_checkin_orders',
|
||||
else:
|
||||
return 'event.settings.general:write'
|
||||
return 'can_change_event_settings'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.event.checkin_lists.prefetch_related(
|
||||
@@ -189,15 +188,11 @@ class CheckinListViewSet(viewsets.ModelViewSet):
|
||||
clist = self.get_object()
|
||||
if serializer.validated_data.get('nonce'):
|
||||
if kwargs.get('position'):
|
||||
prev = kwargs['position'].all_checkins.filter(
|
||||
nonce=serializer.validated_data['nonce'],
|
||||
successful=False
|
||||
).first()
|
||||
prev = kwargs['position'].all_checkins.filter(nonce=serializer.validated_data['nonce']).first()
|
||||
else:
|
||||
prev = clist.checkins.filter(
|
||||
nonce=serializer.validated_data['nonce'],
|
||||
raw_barcode=serializer.validated_data['raw_barcode'],
|
||||
successful=False
|
||||
).first()
|
||||
if prev:
|
||||
# Ignore because nonce is already handled
|
||||
@@ -386,21 +381,15 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
||||
|
||||
qs = qs.filter(reduce(operator.or_, lists_qs))
|
||||
|
||||
prefetch_related = [
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
|
||||
),
|
||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
]
|
||||
select_related = [
|
||||
'item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat'
|
||||
]
|
||||
|
||||
if pdf_data:
|
||||
qs = qs.prefetch_related(
|
||||
# Don't add to list, we don't want to propagate to addons
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
|
||||
),
|
||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||
Prefetch(
|
||||
'event',
|
||||
@@ -415,39 +404,32 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
||||
)
|
||||
)
|
||||
))
|
||||
).select_related(
|
||||
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
|
||||
)
|
||||
else:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch(
|
||||
lookup='checkins',
|
||||
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
|
||||
),
|
||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
|
||||
|
||||
if expand and 'subevent' in expand:
|
||||
prefetch_related += [
|
||||
qs = qs.prefetch_related(
|
||||
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
|
||||
'subevent__seat_category_mappings', 'subevent__meta_values'
|
||||
]
|
||||
)
|
||||
|
||||
if expand and 'item' in expand:
|
||||
prefetch_related += [
|
||||
'item', 'item__addons', 'item__bundles', 'item__meta_values',
|
||||
'item__variations',
|
||||
]
|
||||
select_related.append('item__tax_rule')
|
||||
qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values',
|
||||
'item__variations').select_related('item__tax_rule')
|
||||
|
||||
if expand and 'variation' in expand:
|
||||
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)
|
||||
qs = qs.prefetch_related('variation', 'variation__meta_values')
|
||||
|
||||
return qs
|
||||
|
||||
@@ -475,7 +457,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'event': op.order.event,
|
||||
'pdf_data': pdf_data and (
|
||||
user if user and user.is_authenticated else auth
|
||||
).has_event_permission(request.organizer, event, 'event.orders:read', request),
|
||||
).has_event_permission(request.organizer, event, 'can_view_orders', request),
|
||||
}
|
||||
|
||||
common_checkin_args = dict(
|
||||
@@ -840,8 +822,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
}
|
||||
|
||||
filterset_class = CheckinOrderPositionFilter
|
||||
permission = AnyPermissionOf('event.orders:read', 'event.orders:checkin')
|
||||
write_permission = AnyPermissionOf('event.orders:write', 'event.orders:checkin')
|
||||
permission = ('can_view_orders', 'can_checkin_orders')
|
||||
write_permission = ('can_change_orders', 'can_checkin_orders')
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
@@ -872,7 +854,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
expand=self.request.query_params.getlist('expand'),
|
||||
)
|
||||
|
||||
if 'pk' not in self.request.resolver_match.kwargs and 'event.orders:read' not in self.request.eventpermset \
|
||||
if 'pk' not in self.request.resolver_match.kwargs and 'can_view_orders' not in self.request.eventpermset \
|
||||
and len(self.request.query_params.get('search', '')) < 3:
|
||||
qs = qs.none()
|
||||
|
||||
@@ -921,9 +903,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class CheckinRPCRedeemView(views.APIView):
|
||||
def post(self, request, *args, **kwargs):
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
events = self.request.auth.get_events_with_permission(('event.orders:write', 'event.orders:checkin'))
|
||||
events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders'))
|
||||
elif self.request.user.is_authenticated:
|
||||
events = self.request.user.get_events_with_permission(('event.orders:write', 'event.orders:checkin'), self.request).filter(
|
||||
events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
else:
|
||||
@@ -984,16 +966,15 @@ class CheckinRPCSearchView(ListAPIView):
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['expand'] = self.request.query_params.getlist('expand')
|
||||
ctx['organizer'] = self.request.organizer
|
||||
ctx['pdf_data'] = False
|
||||
return ctx
|
||||
|
||||
@cached_property
|
||||
def lists(self):
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
events = self.request.auth.get_events_with_permission(('event.orders:read', 'event.orders:checkin'))
|
||||
events = self.request.auth.get_events_with_permission(('can_view_orders', 'can_checkin_orders'))
|
||||
elif self.request.user.is_authenticated:
|
||||
events = self.request.user.get_events_with_permission(('event.orders:read', 'event.orders:checkin'), self.request).filter(
|
||||
events = self.request.user.get_events_with_permission(('can_view_orders', 'can_checkin_orders'), self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
else:
|
||||
@@ -1010,9 +991,9 @@ class CheckinRPCSearchView(ListAPIView):
|
||||
@cached_property
|
||||
def has_full_access_permission(self):
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
events = self.request.auth.get_events_with_permission('event.orders:read')
|
||||
events = self.request.auth.get_events_with_permission('can_view_orders')
|
||||
elif self.request.user.is_authenticated:
|
||||
events = self.request.user.get_events_with_permission('event.orders:read', self.request).filter(
|
||||
events = self.request.user.get_events_with_permission('can_view_orders', self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
else:
|
||||
@@ -1039,9 +1020,9 @@ class CheckinRPCSearchView(ListAPIView):
|
||||
class CheckinRPCAnnulView(views.APIView):
|
||||
def post(self, request, *args, **kwargs):
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
events = self.request.auth.get_events_with_permission(('event.orders:write', 'event.orders:checkin'))
|
||||
events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders'))
|
||||
elif self.request.user.is_authenticated:
|
||||
events = self.request.user.get_events_with_permission(('event.orders:write', 'event.orders:checkin'), self.request).filter(
|
||||
events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter(
|
||||
organizer=self.request.organizer
|
||||
)
|
||||
else:
|
||||
@@ -1119,7 +1100,7 @@ class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
filterset_class = CheckinFilter
|
||||
ordering = ('created', 'id')
|
||||
ordering_fields = ('created', 'datetime', 'id',)
|
||||
permission = 'event.orders:read'
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Checkin.all.filter().select_related(
|
||||
|
||||
@@ -57,7 +57,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.discounts.prefetch_related(
|
||||
|
||||
@@ -281,11 +281,6 @@ class EventViewSet(viewsets.ModelViewSet):
|
||||
new_event = serializer.save(organizer=self.request.organizer)
|
||||
|
||||
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)
|
||||
|
||||
if plugins is not None:
|
||||
@@ -346,24 +341,15 @@ class CloneEventViewSet(viewsets.ModelViewSet):
|
||||
lookup_field = 'slug'
|
||||
lookup_url_kwarg = 'event'
|
||||
http_method_names = ['post']
|
||||
write_permission = 'event.settings.general:write'
|
||||
write_permission = 'can_create_events'
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = Event.objects.get(slug=self.kwargs['event'], organizer=self.request.organizer)
|
||||
ctx['event'] = self.kwargs['event']
|
||||
ctx['organizer'] = self.request.organizer
|
||||
return ctx
|
||||
|
||||
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.instance.log_action(
|
||||
@@ -440,7 +426,7 @@ with scopes_disabled():
|
||||
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = SubEventSerializer
|
||||
queryset = SubEvent.objects.none()
|
||||
write_permission = 'event.subevents:write'
|
||||
write_permission = 'can_change_event_settings'
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
ordering = ('date_from',)
|
||||
ordering_fields = ('id', 'date_from', 'last_modified')
|
||||
@@ -560,7 +546,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = TaxRuleSerializer
|
||||
queryset = TaxRule.objects.none()
|
||||
write_permission = 'event.settings.tax:write'
|
||||
write_permission = 'can_change_event_settings'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.tax_rules.all()
|
||||
@@ -603,7 +589,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
class ItemMetaPropertiesViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ItemMetaPropertiesSerializer
|
||||
queryset = ItemMetaProperty.objects.none()
|
||||
write_permission = 'event.settings.general:write'
|
||||
write_permission = 'can_change_event_settings'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.event.item_meta_properties.all()
|
||||
@@ -650,18 +636,19 @@ class ItemMetaPropertiesViewSet(viewsets.ModelViewSet):
|
||||
|
||||
class EventSettingsView(views.APIView):
|
||||
permission = None
|
||||
write_permission = 'event.settings.general:write'
|
||||
write_permission = 'can_change_event_settings'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
if isinstance(request.auth, Device):
|
||||
s = DeviceEventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
||||
'request': request, 'permissions': request.eventpermset
|
||||
'request': request
|
||||
})
|
||||
elif 'can_change_event_settings' in request.eventpermset:
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
||||
'request': request
|
||||
})
|
||||
else:
|
||||
s = EventSettingsSerializer(instance=request.event.settings, event=request.event, context={
|
||||
'request': request, 'permissions': request.eventpermset,
|
||||
})
|
||||
|
||||
raise PermissionDenied()
|
||||
if 'explain' in request.GET:
|
||||
return Response({
|
||||
fname: {
|
||||
@@ -675,7 +662,7 @@ class EventSettingsView(views.APIView):
|
||||
|
||||
def patch(self, request, *wargs, **kwargs):
|
||||
s = EventSettingsSerializer(instance=request.event.settings, data=request.data, partial=True,
|
||||
event=request.event, context={'request': request, 'permissions': request.eventpermset})
|
||||
event=request.event, context={'request': request})
|
||||
s.is_valid(raise_exception=True)
|
||||
with transaction.atomic():
|
||||
s.save()
|
||||
@@ -687,7 +674,7 @@ class EventSettingsView(views.APIView):
|
||||
)
|
||||
s = EventSettingsSerializer(
|
||||
instance=request.event.settings, event=request.event, context={
|
||||
'request': request, 'permissions': request.eventpermset
|
||||
'request': request
|
||||
})
|
||||
return Response(s.data)
|
||||
|
||||
@@ -714,7 +701,7 @@ class SeatFilter(FilterSet):
|
||||
class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
serializer_class = SeatSerializer
|
||||
queryset = Seat.objects.none()
|
||||
write_permission = 'event.settings.general:write'
|
||||
write_permission = 'can_change_event_settings'
|
||||
filter_backends = (DjangoFilterBackend, )
|
||||
filterset_class = SeatFilter
|
||||
|
||||
|
||||
@@ -40,12 +40,12 @@ from pretix.api.serializers.exporters import (
|
||||
)
|
||||
from pretix.base.exporter import OrganizerLevelExportMixin
|
||||
from pretix.base.models import (
|
||||
CachedFile, Device, ScheduledEventExport, ScheduledOrganizerExport,
|
||||
CachedFile, Device, Event, ScheduledEventExport, ScheduledOrganizerExport,
|
||||
TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models.organizer import TeamQuerySet
|
||||
from pretix.base.services.export import (
|
||||
export, init_event_exporters, init_organizer_exporters, multiexport,
|
||||
from pretix.base.services.export import export, multiexport
|
||||
from pretix.base.signals import (
|
||||
register_data_exporters, register_multievent_data_exporters,
|
||||
)
|
||||
from pretix.helpers.http import ChunkBasedFileResponse
|
||||
|
||||
@@ -74,11 +74,6 @@ class ExportersMixin:
|
||||
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
||||
def download(self, *args, **kwargs):
|
||||
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
||||
if not cf.allowed_for_session(self.request, "exporters-api"):
|
||||
return Response(
|
||||
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
|
||||
status=status.HTTP_410_GONE
|
||||
)
|
||||
if cf.file:
|
||||
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
|
||||
@@ -111,11 +106,10 @@ class ExportersMixin:
|
||||
@action(detail=True, methods=['POST'])
|
||||
def run(self, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
serializer = JobRunSerializer(exporter=instance, data=self.request.data)
|
||||
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
cf = CachedFile(web_download=True)
|
||||
cf.bind_to_session(self.request, "exporters-api")
|
||||
cf = CachedFile(web_download=False)
|
||||
cf.date = now()
|
||||
cf.expires = now() + timedelta(hours=24)
|
||||
cf.save()
|
||||
@@ -136,34 +130,27 @@ class ExportersMixin:
|
||||
|
||||
|
||||
class EventExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
permission = None
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def get_serializer_kwargs(self):
|
||||
return {}
|
||||
|
||||
@cached_property
|
||||
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 = []
|
||||
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)):
|
||||
ex._serializer = JobRunSerializer(exporter=ex)
|
||||
exporters.append(ex)
|
||||
return exporters
|
||||
|
||||
def do_export(self, cf, instance, 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,
|
||||
})
|
||||
return export.apply_async(args=(self.request.event.id, str(cf.id), instance.identifier, data))
|
||||
|
||||
|
||||
class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
@@ -171,23 +158,47 @@ class OrganizerExportersViewSet(ExportersMixin, viewsets.ViewSet):
|
||||
|
||||
@cached_property
|
||||
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 = []
|
||||
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)):
|
||||
ex._serializer = JobRunSerializer(exporter=ex)
|
||||
ex._serializer = JobRunSerializer(exporter=ex, events=events)
|
||||
exporters.append(ex)
|
||||
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):
|
||||
return multiexport.apply_async(kwargs={
|
||||
'organizer': self.request.organizer.id,
|
||||
'user': self.request.user.id if self.request.user and self.request.user.is_authenticated else None,
|
||||
'user': self.request.user.id if 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),
|
||||
@@ -205,11 +216,11 @@ class ScheduledExportersViewSet(viewsets.ModelViewSet):
|
||||
class ScheduledEventExportViewSet(ScheduledExportersViewSet):
|
||||
serializer_class = ScheduledEventExportSerializer
|
||||
queryset = ScheduledEventExport.objects.none()
|
||||
permission = None
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
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, 'event.settings.general:write',
|
||||
if not perm_holder.has_event_permission(self.request.organizer, self.request.event, 'can_change_event_settings',
|
||||
request=self.request):
|
||||
if self.request.user.is_authenticated:
|
||||
qs = self.request.event.scheduled_exports.filter(owner=self.request.user)
|
||||
@@ -241,28 +252,11 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet):
|
||||
|
||||
@cached_property
|
||||
def exporters(self):
|
||||
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,
|
||||
))
|
||||
responses = register_data_exporters.send(self.request.event)
|
||||
exporters = [response(self.request.event, self.request.organizer) for r, response in responses if response]
|
||||
return {e.identifier: e for e in exporters}
|
||||
|
||||
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.instance.compute_next_run()
|
||||
serializer.instance.error_counter = 0
|
||||
@@ -291,7 +285,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
|
||||
|
||||
def get_queryset(self):
|
||||
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, 'organizer.settings.general:write',
|
||||
if not perm_holder.has_organizer_permission(self.request.organizer, 'can_change_organizer_settings',
|
||||
request=self.request):
|
||||
if self.request.user.is_authenticated:
|
||||
qs = self.request.organizer.scheduled_exports.filter(owner=self.request.user)
|
||||
@@ -321,55 +315,26 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet):
|
||||
ctx['exporters'] = self.exporters
|
||||
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
|
||||
def exporters(self):
|
||||
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,
|
||||
))
|
||||
responses = register_multievent_data_exporters.send(self.request.organizer)
|
||||
exporters = [
|
||||
response(Event.objects.none() if issubclass(response, OrganizerLevelExportMixin) else self.events,
|
||||
self.request.organizer)
|
||||
for r, response in responses if response
|
||||
]
|
||||
return {e.identifier: e for e in exporters}
|
||||
|
||||
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.instance.compute_next_run()
|
||||
serializer.instance.error_counter = 0
|
||||
|
||||
@@ -40,19 +40,19 @@ from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from django_scopes import scopes_disabled
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.pagination import TotalOrderingFilter
|
||||
from pretix.api.serializers.item import (
|
||||
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
||||
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
|
||||
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
|
||||
ItemSerializer, ItemVariationSerializer, QuestionOptionSerializer,
|
||||
QuestionSerializer, QuotaSerializer,
|
||||
)
|
||||
from pretix.api.views import ConditionalListView
|
||||
from pretix.base.models import (
|
||||
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
|
||||
ItemVariation, Question, QuestionOption, Quota,
|
||||
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation,
|
||||
Question, QuestionOption, Quota,
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.helpers.dicts import merge_dicts
|
||||
@@ -99,14 +99,14 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
ordering = ('position', 'id')
|
||||
filterset_class = ItemFilter
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.items.select_related('tax_rule').prefetch_related(
|
||||
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
|
||||
'variations__meta_values', 'variations__meta_values__property',
|
||||
'require_membership_types', 'variations__require_membership_types',
|
||||
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times'
|
||||
'limit_sales_channels', 'variations__limit_sales_channels',
|
||||
).all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
@@ -163,7 +163,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('id',)
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
@cached_property
|
||||
def item(self):
|
||||
@@ -234,7 +234,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
|
||||
ordering_fields = ('id',)
|
||||
ordering = ('id',)
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
@cached_property
|
||||
def item(self):
|
||||
@@ -279,59 +279,6 @@ class ItemBundleViewSet(viewsets.ModelViewSet):
|
||||
)
|
||||
|
||||
|
||||
class ItemProgramTimeViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ItemProgramTimeSerializer
|
||||
queryset = ItemProgramTime.objects.none()
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter,)
|
||||
ordering_fields = ('id',)
|
||||
ordering = ('id',)
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
|
||||
@cached_property
|
||||
def item(self):
|
||||
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.event.has_subevents:
|
||||
raise ValidationError('You cannot use program times on an event series.')
|
||||
return self.item.program_times.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['event'] = self.request.event
|
||||
ctx['item'] = self.item
|
||||
return ctx
|
||||
|
||||
def perform_create(self, serializer):
|
||||
item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
|
||||
serializer.save(item=item)
|
||||
item.log_action(
|
||||
'pretix.event.item.program_times.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(event=self.request.event)
|
||||
serializer.instance.item.log_action(
|
||||
'pretix.event.item.program_times.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
|
||||
)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
super().perform_destroy(instance)
|
||||
instance.item.log_action(
|
||||
'pretix.event.item.program_times.removed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={'start': instance.start, 'end': instance.end}
|
||||
)
|
||||
|
||||
|
||||
class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ItemAddOnSerializer
|
||||
queryset = ItemAddOn.objects.none()
|
||||
@@ -339,7 +286,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('id',)
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
@cached_property
|
||||
def item(self):
|
||||
@@ -398,7 +345,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.categories.all()
|
||||
@@ -453,7 +400,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position', 'id')
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.questions.prefetch_related('options').all()
|
||||
@@ -497,7 +444,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
|
||||
ordering_fields = ('id', 'position')
|
||||
ordering = ('position',)
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event)
|
||||
@@ -564,10 +511,10 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
||||
ordering_fields = ('id', 'size')
|
||||
ordering = ('id',)
|
||||
permission = None
|
||||
write_permission = 'event.items:write'
|
||||
write_permission = 'can_change_items'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.quotas.select_related('subevent').prefetch_related('items', 'variations').all()
|
||||
return self.request.event.quotas.all()
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
queryset = self.filter_queryset(self.get_queryset()).distinct()
|
||||
|
||||
@@ -62,8 +62,8 @@ with scopes_disabled():
|
||||
class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = ReusableMediaSerializer
|
||||
queryset = ReusableMedium.objects.none()
|
||||
permission = 'organizer.reusablemedia:read'
|
||||
write_permission = 'organizer.reusablemedia:write'
|
||||
permission = 'can_manage_reusable_media'
|
||||
write_permission = 'can_manage_reusable_media'
|
||||
filter_backends = (DjangoFilterBackend, OrderingFilter)
|
||||
ordering = ('-updated', '-id')
|
||||
ordering_fields = ('created', 'updated', 'identifier', 'type', 'id')
|
||||
@@ -95,8 +95,6 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
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
|
||||
|
||||
@transaction.atomic()
|
||||
|
||||
@@ -57,10 +57,9 @@ from pretix.api.serializers.order import (
|
||||
BlockedTicketSecretSerializer, InvoiceSerializer, OrderCreateSerializer,
|
||||
OrderPaymentCreateSerializer, OrderPaymentSerializer,
|
||||
OrderPositionSerializer, OrderRefundCreateSerializer,
|
||||
OrderRefundSerializer, OrderSerializer, OrganizerOrderPositionSerializer,
|
||||
OrganizerTransactionSerializer, PriceCalcSerializer, PrintLogSerializer,
|
||||
RevokedTicketSecretSerializer, SimulatedOrderSerializer,
|
||||
TransactionSerializer,
|
||||
OrderRefundSerializer, OrderSerializer, OrganizerTransactionSerializer,
|
||||
PriceCalcSerializer, PrintLogSerializer, RevokedTicketSecretSerializer,
|
||||
SimulatedOrderSerializer, TransactionSerializer,
|
||||
)
|
||||
from pretix.api.serializers.orderchange import (
|
||||
BlockNameSerializer, OrderChangeOperationSerializer,
|
||||
@@ -91,6 +90,7 @@ from pretix.base.services.invoices import (
|
||||
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
||||
regenerate_invoice, transmit_invoice,
|
||||
)
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.services.orders import (
|
||||
OrderChangeManager, OrderError, _order_placed_email,
|
||||
_order_placed_email_attendee, approve_order, cancel_order, deny_order,
|
||||
@@ -317,7 +317,7 @@ class OrderViewSetMixin:
|
||||
|
||||
class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
|
||||
def get_base_queryset(self):
|
||||
perm = "event.orders:read" if self.request.method in SAFE_METHODS else "event.orders:write"
|
||||
perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
return Order.objects.filter(
|
||||
event__organizer=self.request.organizer,
|
||||
@@ -338,8 +338,8 @@ class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
|
||||
|
||||
|
||||
class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
permission = 'event.orders:read'
|
||||
write_permission = 'event.orders:write'
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
@@ -439,6 +439,8 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except PaymentException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except SendMailException:
|
||||
pass
|
||||
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
return Response(
|
||||
@@ -632,7 +634,10 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
||||
order = self.get_object()
|
||||
if not order.email:
|
||||
return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
order.resend_link(user=self.request.user, auth=self.request.auth)
|
||||
try:
|
||||
order.resend_link(user=self.request.user, auth=self.request.auth)
|
||||
except SendMailException:
|
||||
return Response({'detail': _('There was an error sending the mail. Please try again later.')}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
|
||||
return Response(
|
||||
status=status.HTTP_204_NO_CONTENT
|
||||
@@ -1066,12 +1071,15 @@ with scopes_disabled():
|
||||
}
|
||||
|
||||
|
||||
class OrderPositionViewSetMixin:
|
||||
class OrderPositionViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = OrderPositionSerializer
|
||||
queryset = OrderPosition.all.none()
|
||||
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
|
||||
ordering = ('order__datetime', 'positionid')
|
||||
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
|
||||
filterset_class = OrderPositionFilter
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
ordering_custom = {
|
||||
'attendee_name': {
|
||||
'_order': F('display_name').asc(nulls_first=True),
|
||||
@@ -1085,7 +1093,8 @@ class OrderPositionViewSetMixin:
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx['pdf_data'] = False
|
||||
ctx['event'] = self.request.event
|
||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
|
||||
ctx['check_quotas'] = self.request.query_params.get('check_quotas', 'true').lower() == 'true'
|
||||
return ctx
|
||||
|
||||
@@ -1094,8 +1103,9 @@ class OrderPositionViewSetMixin:
|
||||
qs = OrderPosition.all
|
||||
else:
|
||||
qs = OrderPosition.objects
|
||||
qs = qs.filter(order__event__organizer=self.request.organizer)
|
||||
if self.request.query_params.get('pdf_data', 'false').lower() == 'true' and getattr(self.request, 'event', None):
|
||||
|
||||
qs = qs.filter(order__event=self.request.event)
|
||||
if self.request.query_params.get('pdf_data', 'false').lower() == 'true':
|
||||
prefetch_related_objects([self.request.organizer], 'meta_properties')
|
||||
prefetch_related_objects(
|
||||
[self.request.event],
|
||||
@@ -1150,9 +1160,9 @@ class OrderPositionViewSetMixin:
|
||||
qs = qs.prefetch_related(
|
||||
Prefetch('checkins', queryset=Checkin.objects.select_related("device")),
|
||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
||||
'answers', 'answers__options', 'answers__question', 'order__event', 'order__event__organizer'
|
||||
'answers', 'answers__options', 'answers__question',
|
||||
).select_related(
|
||||
'item', 'order', 'seat'
|
||||
'item', 'order', 'order__event', 'order__event__organizer', 'seat'
|
||||
)
|
||||
return qs
|
||||
|
||||
@@ -1164,49 +1174,6 @@ class OrderPositionViewSetMixin:
|
||||
return prov
|
||||
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')
|
||||
def price_calc(self, request, *args, **kwargs):
|
||||
"""
|
||||
@@ -1613,8 +1580,8 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
|
||||
class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderPaymentSerializer
|
||||
queryset = OrderPayment.objects.none()
|
||||
permission = 'event.orders:read'
|
||||
write_permission = 'event.orders:write'
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
lookup_field = 'local_id'
|
||||
|
||||
def get_serializer_context(self):
|
||||
@@ -1649,6 +1616,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
except Quota.QuotaExceededException:
|
||||
pass
|
||||
except SendMailException:
|
||||
pass
|
||||
|
||||
serializer = OrderPaymentSerializer(r, context=serializer.context)
|
||||
|
||||
@@ -1686,6 +1655,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except PaymentException as e:
|
||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except SendMailException:
|
||||
pass
|
||||
return self.retrieve(request, [], **kwargs)
|
||||
|
||||
@action(detail=True, methods=['POST'])
|
||||
@@ -1786,8 +1757,8 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = OrderRefundSerializer
|
||||
queryset = OrderRefund.objects.none()
|
||||
permission = 'event.orders:read'
|
||||
write_permission = 'event.orders:write'
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
lookup_field = 'local_id'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -1944,18 +1915,13 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ordering = ('nr',)
|
||||
ordering_fields = ('nr', 'date')
|
||||
filterset_class = InvoiceFilter
|
||||
permission = 'can_view_orders'
|
||||
lookup_url_kwarg = 'number'
|
||||
lookup_field = 'nr'
|
||||
|
||||
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
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
perm = "event.orders:read" if self.request.method in SAFE_METHODS else "event.orders:write"
|
||||
perm = "can_view_orders" if self.request.method in SAFE_METHODS else "can_change_orders"
|
||||
if getattr(self.request, 'event', None):
|
||||
qs = self.request.event.invoices
|
||||
elif isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
@@ -2065,7 +2031,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
else:
|
||||
order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id)
|
||||
c = generate_cancellation(inv)
|
||||
if invoice_qualified(order):
|
||||
if inv.order.status != Order.STATUS_CANCELED:
|
||||
inv = generate_invoice(order)
|
||||
else:
|
||||
inv = c
|
||||
@@ -2096,8 +2062,8 @@ class RevokedSecretViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ordering = ('-created',)
|
||||
ordering_fields = ('created', 'secret')
|
||||
filterset_class = RevokedSecretFilter
|
||||
permission = 'event.orders:read'
|
||||
write_permission = 'event.orders:write'
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
return RevokedTicketSecret.objects.filter(event=self.request.event)
|
||||
@@ -2118,8 +2084,8 @@ class BlockedSecretViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
|
||||
ordering = ('-updated', '-pk')
|
||||
filterset_class = BlockedSecretFilter
|
||||
permission = 'event.orders:read'
|
||||
write_permission = 'event.orders:write'
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
return BlockedTicketSecret.objects.filter(event=self.request.event)
|
||||
@@ -2154,7 +2120,7 @@ class TransactionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
ordering = ('datetime', 'pk')
|
||||
ordering_fields = ('datetime', 'created', 'id',)
|
||||
filterset_class = TransactionFilter
|
||||
permission = 'event.orders:read'
|
||||
permission = 'can_view_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
return Transaction.objects.filter(order__event=self.request.event).select_related("order")
|
||||
@@ -2171,11 +2137,11 @@ class OrganizerTransactionViewSet(TransactionViewSet):
|
||||
|
||||
if isinstance(self.request.auth, (TeamAPIToken, Device)):
|
||||
qs = qs.filter(
|
||||
order__event__in=self.request.auth.get_events_with_permission("event.orders:read"),
|
||||
order__event__in=self.request.auth.get_events_with_permission("can_view_orders"),
|
||||
)
|
||||
elif self.request.user.is_authenticated:
|
||||
qs = qs.filter(
|
||||
order__event__in=self.request.user.get_events_with_permission("event.orders:read", request=self.request)
|
||||
order__event__in=self.request.user.get_events_with_permission("can_view_orders", request=self.request)
|
||||
)
|
||||
else:
|
||||
raise PermissionDenied("Unknown authentication scheme")
|
||||
|
||||
@@ -70,7 +70,7 @@ class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
filter_backends = (TotalOrderingFilter,)
|
||||
ordering = ('slug',)
|
||||
ordering_fields = ('name', 'slug')
|
||||
write_permission = "organizer.settings.general:write"
|
||||
write_permission = "can_change_organizer_settings"
|
||||
|
||||
def get_queryset(self):
|
||||
if self.request.user.is_authenticated:
|
||||
@@ -154,8 +154,8 @@ class OrganizerViewSet(mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
class SeatingPlanViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = SeatingPlanSerializer
|
||||
queryset = SeatingPlan.objects.none()
|
||||
permission = None
|
||||
write_permission = 'organizer.seatingplans:write'
|
||||
permission = 'can_change_organizer_settings'
|
||||
write_permission = 'can_change_organizer_settings'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.seating_plans.order_by('name')
|
||||
@@ -221,8 +221,8 @@ with scopes_disabled():
|
||||
class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = GiftCardSerializer
|
||||
queryset = GiftCard.objects.none()
|
||||
permission = 'organizer.giftcards:read'
|
||||
write_permission = 'organizer.giftcards:write'
|
||||
permission = 'can_manage_gift_cards'
|
||||
write_permission = 'can_manage_gift_cards'
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filterset_class = GiftCardFilter
|
||||
|
||||
@@ -249,24 +249,12 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
def perform_create(self, serializer):
|
||||
value = serializer.validated_data.pop('value')
|
||||
inst = serializer.save(issuer=self.request.organizer)
|
||||
inst.log_action(
|
||||
action='pretix.giftcards.created',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
)
|
||||
inst.transactions.create(value=value, acceptor=self.request.organizer)
|
||||
inst.log_action(
|
||||
action='pretix.giftcards.transaction.manual',
|
||||
'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
|
||||
}
|
||||
)
|
||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
||||
)
|
||||
|
||||
@transaction.atomic()
|
||||
@@ -281,7 +269,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
|
||||
testmode=serializer.instance.testmode)
|
||||
inst.log_action(
|
||||
action='pretix.giftcards.modified',
|
||||
'pretix.giftcards.modified',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
@@ -294,14 +282,10 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
diff = value - old_value
|
||||
inst.transactions.create(value=diff, acceptor=self.request.organizer)
|
||||
inst.log_action(
|
||||
action='pretix.giftcards.transaction.manual',
|
||||
'pretix.giftcards.transaction.manual',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'value': diff,
|
||||
'acceptor_id': self.request.organizer.id,
|
||||
'acceptor_slug': self.request.organizer.slug
|
||||
}
|
||||
data={'value': diff}
|
||||
)
|
||||
|
||||
return inst
|
||||
@@ -325,15 +309,10 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
}, status=status.HTTP_409_CONFLICT)
|
||||
gc.transactions.create(value=value, text=text, info=info, acceptor=self.request.organizer)
|
||||
gc.log_action(
|
||||
action='pretix.giftcards.transaction.manual',
|
||||
'pretix.giftcards.transaction.manual',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'value': value,
|
||||
'text': text,
|
||||
'acceptor_id': self.request.organizer.id,
|
||||
'acceptor_slug': self.request.organizer.slug
|
||||
}
|
||||
data={'value': value, 'text': text}
|
||||
)
|
||||
return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -344,8 +323,8 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
||||
class GiftCardTransactionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = GiftCardTransactionSerializer
|
||||
queryset = GiftCardTransaction.objects.none()
|
||||
permission = 'organizer.giftcards:read'
|
||||
write_permission = 'organizer.giftcards:write'
|
||||
permission = 'can_manage_gift_cards'
|
||||
write_permission = 'can_manage_gift_cards'
|
||||
|
||||
@cached_property
|
||||
def giftcard(self):
|
||||
@@ -362,8 +341,8 @@ class GiftCardTransactionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
class TeamViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = TeamSerializer
|
||||
queryset = Team.objects.none()
|
||||
permission = 'organizer.teams:write'
|
||||
write_permission = 'organizer.teams:write'
|
||||
permission = 'can_change_teams'
|
||||
write_permission = 'can_change_teams'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.organizer.teams.order_by('pk')
|
||||
@@ -402,8 +381,8 @@ class TeamViewSet(viewsets.ModelViewSet):
|
||||
class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = TeamMemberSerializer
|
||||
queryset = User.objects.none()
|
||||
permission = 'organizer.teams:write'
|
||||
write_permission = 'organizer.teams:write'
|
||||
permission = 'can_change_teams'
|
||||
write_permission = 'can_change_teams'
|
||||
|
||||
@cached_property
|
||||
def team(self):
|
||||
@@ -431,8 +410,8 @@ class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = TeamInviteSerializer
|
||||
queryset = TeamInvite.objects.none()
|
||||
permission = 'organizer.teams:write'
|
||||
write_permission = 'organizer.teams:write'
|
||||
permission = 'can_change_teams'
|
||||
write_permission = 'can_change_teams'
|
||||
|
||||
@cached_property
|
||||
def team(self):
|
||||
@@ -468,8 +447,8 @@ class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyMo
|
||||
class TeamAPITokenViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
|
||||
serializer_class = TeamAPITokenSerializer
|
||||
queryset = TeamAPIToken.objects.none()
|
||||
permission = 'organizer.teams:write'
|
||||
write_permission = 'organizer.teams:write'
|
||||
permission = 'can_change_teams'
|
||||
write_permission = 'can_change_teams'
|
||||
|
||||
@cached_property
|
||||
def team(self):
|
||||
@@ -532,8 +511,8 @@ class DeviceViewSet(mixins.CreateModelMixin,
|
||||
GenericViewSet):
|
||||
serializer_class = DeviceSerializer
|
||||
queryset = Device.objects.none()
|
||||
permission = 'organizer.devices:read'
|
||||
write_permission = 'organizer.devices:write'
|
||||
permission = 'can_change_organizer_settings'
|
||||
write_permission = 'can_change_organizer_settings'
|
||||
lookup_field = 'device_id'
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -542,9 +521,6 @@ class DeviceViewSet(mixins.CreateModelMixin,
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
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
|
||||
|
||||
@transaction.atomic()
|
||||
@@ -571,11 +547,11 @@ class DeviceViewSet(mixins.CreateModelMixin,
|
||||
|
||||
class OrganizerSettingsView(views.APIView):
|
||||
permission = None
|
||||
write_permission = 'organizer.settings.general:write'
|
||||
write_permission = 'can_change_organizer_settings'
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
||||
'request': request, 'permissions': request.orgapermset
|
||||
'request': request
|
||||
})
|
||||
if 'explain' in request.GET:
|
||||
return Response({
|
||||
@@ -592,7 +568,7 @@ class OrganizerSettingsView(views.APIView):
|
||||
s = OrganizerSettingsSerializer(
|
||||
instance=request.organizer.settings, data=request.data, partial=True,
|
||||
organizer=request.organizer, context={
|
||||
'request': request, 'permissions': request.orgapermset
|
||||
'request': request
|
||||
}
|
||||
)
|
||||
s.is_valid(raise_exception=True)
|
||||
@@ -604,7 +580,7 @@ class OrganizerSettingsView(views.APIView):
|
||||
}
|
||||
)
|
||||
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
|
||||
'request': request, 'permissions': request.orgapermset
|
||||
'request': request
|
||||
})
|
||||
return Response(s.data)
|
||||
|
||||
@@ -621,8 +597,7 @@ with scopes_disabled():
|
||||
class CustomerViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = CustomerSerializer
|
||||
queryset = Customer.objects.none()
|
||||
permission = 'organizer.customers:read'
|
||||
write_permission = 'organizer.customers:write'
|
||||
permission = 'can_manage_customers'
|
||||
lookup_field = 'identifier'
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filterset_class = CustomerFilter
|
||||
@@ -682,7 +657,7 @@ class CustomerViewSet(viewsets.ModelViewSet):
|
||||
class MembershipTypeViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = MembershipTypeSerializer
|
||||
queryset = MembershipType.objects.none()
|
||||
permission = 'organizer.settings.general:write'
|
||||
permission = 'can_change_organizer_settings'
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.organizer.membership_types.all()
|
||||
@@ -739,15 +714,14 @@ with scopes_disabled():
|
||||
class MembershipViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = MembershipSerializer
|
||||
queryset = Membership.objects.none()
|
||||
permission = 'organizer.customers:read'
|
||||
write_permission = 'organizer.customers:write'
|
||||
permission = 'can_manage_customers'
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filterset_class = MembershipFilter
|
||||
|
||||
def get_queryset(self):
|
||||
return Membership.objects.filter(
|
||||
customer__organizer=self.request.organizer
|
||||
).select_related('customer')
|
||||
)
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
@@ -790,8 +764,8 @@ with scopes_disabled():
|
||||
class SalesChannelViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = SalesChannelSerializer
|
||||
queryset = SalesChannel.objects.none()
|
||||
permission = 'organizer.settings.general:write'
|
||||
write_permission = 'organizer.settings.general:write'
|
||||
permission = 'can_change_organizer_settings'
|
||||
write_permission = 'can_change_organizer_settings'
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filterset_class = SalesChannelFilter
|
||||
lookup_field = 'identifier'
|
||||
@@ -826,7 +800,7 @@ class SalesChannelViewSet(viewsets.ModelViewSet):
|
||||
identifier=serializer.instance.identifier,
|
||||
)
|
||||
inst.log_action(
|
||||
'pretix.sales_channel.changed',
|
||||
'pretix.saleschannel.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
|
||||
@@ -204,7 +204,7 @@ class ShreddersMixin:
|
||||
|
||||
|
||||
class EventShreddersViewSet(ShreddersMixin, viewsets.ViewSet):
|
||||
permission = 'event.orders:write'
|
||||
permission = 'can_change_orders'
|
||||
|
||||
def get_serializer_kwargs(self):
|
||||
return {}
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import F, Q
|
||||
from django.utils.timezone import now
|
||||
@@ -62,16 +61,11 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
||||
ordering = ('id',)
|
||||
ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value')
|
||||
filterset_class = VoucherFilter
|
||||
permission = 'event.vouchers:read'
|
||||
write_permission = 'event.vouchers:write'
|
||||
permission = 'can_view_vouchers'
|
||||
write_permission = 'can_change_vouchers'
|
||||
|
||||
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries
|
||||
def get_queryset(self):
|
||||
return Voucher.annotate_budget_used(
|
||||
self.request.event.vouchers
|
||||
).select_related(
|
||||
'item', 'quota', 'seat', 'variation'
|
||||
)
|
||||
return self.request.event.vouchers.select_related('seat').all()
|
||||
|
||||
@transaction.atomic()
|
||||
def create(self, request, *args, **kwargs):
|
||||
|
||||
@@ -51,8 +51,8 @@ class WaitingListViewSet(viewsets.ModelViewSet):
|
||||
ordering = ('created', 'pk',)
|
||||
ordering_fields = ('id', 'created', 'email', 'item')
|
||||
filterset_class = WaitingListFilter
|
||||
permission = 'event.orders:read'
|
||||
write_permission = 'event.orders:write'
|
||||
permission = 'can_view_orders'
|
||||
write_permission = 'can_change_orders'
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.waitinglistentries.all()
|
||||
|
||||
@@ -35,8 +35,8 @@ class WebhookFilter(FilterSet):
|
||||
class WebHookViewSet(viewsets.ModelViewSet):
|
||||
serializer_class = WebHookSerializer
|
||||
queryset = WebHook.objects.none()
|
||||
permission = 'organizer.settings.general:write'
|
||||
write_permission = 'organizer.settings.general:write'
|
||||
permission = 'can_change_organizer_settings'
|
||||
write_permission = 'can_change_organizer_settings'
|
||||
filter_backends = (DjangoFilterBackend,)
|
||||
filterset_class = WebhookFilter
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
||||
from pretix.base.signals import periodic_task
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.celery import get_task_priority
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_ALL_EVENTS = None
|
||||
@@ -174,38 +173,6 @@ class ParametrizedEventWebhookEvent(ParametrizedWebhookEvent):
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedGiftcardWebhookEvent(ParametrizedWebhookEvent):
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
giftcard = logentry.content_object
|
||||
if not giftcard:
|
||||
return None
|
||||
|
||||
return {
|
||||
'notification_id': logentry.pk,
|
||||
'issuer_id': logentry.organizer_id,
|
||||
'issuer_slug': logentry.organizer.slug,
|
||||
'giftcard': giftcard.pk,
|
||||
'action': logentry.action_type,
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedGiftcardTransactionWebhookEvent(ParametrizedWebhookEvent):
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
giftcard = logentry.content_object
|
||||
if not giftcard:
|
||||
return None
|
||||
|
||||
return {
|
||||
'notification_id': logentry.pk,
|
||||
'issuer_id': logentry.organizer_id,
|
||||
'issuer_slug': logentry.organizer.slug,
|
||||
'acceptor_id': logentry.parsed_data.get('acceptor_id'),
|
||||
'acceptor_slug': logentry.parsed_data.get('acceptor_slug'),
|
||||
'giftcard': giftcard.pk,
|
||||
'action': logentry.action_type,
|
||||
}
|
||||
|
||||
|
||||
class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent):
|
||||
|
||||
def build_payload(self, logentry: LogEntry):
|
||||
@@ -465,18 +432,6 @@ def register_default_webhook_events(sender, **kwargs):
|
||||
'pretix.customer.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'),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -519,10 +474,7 @@ def notify_webhooks(logentry_ids: list):
|
||||
)
|
||||
|
||||
for wh in webhooks:
|
||||
send_webhook.apply_async(
|
||||
args=(logentry.id, notification_type.action_type, wh.pk),
|
||||
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||
)
|
||||
send_webhook.apply_async(args=(logentry.id, notification_type.action_type, wh.pk))
|
||||
|
||||
|
||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=60, acks_late=True, autoretry_for=(DatabaseError,),)
|
||||
|
||||
@@ -224,7 +224,7 @@ class HistoryPasswordValidator:
|
||||
).delete()
|
||||
|
||||
|
||||
def has_event_access_permission(request, permission='event.settings.general:write'):
|
||||
def has_event_access_permission(request, permission='can_change_event_settings'):
|
||||
return (
|
||||
request.user.is_authenticated and
|
||||
request.user.has_event_permission(request.organizer, request.event, permission, request=request)
|
||||
|
||||
@@ -112,6 +112,23 @@ def oidc_validate_and_complete_config(config):
|
||||
scope="openid",
|
||||
))
|
||||
|
||||
for scope in config["scope"].split(" "):
|
||||
if scope not in provider_config.get("scopes_supported", []):
|
||||
raise ValidationError(_('You are requesting scope "{scope}" but provider only supports these: {scopes}.').format(
|
||||
scope=scope,
|
||||
scopes=", ".join(provider_config.get("scopes_supported", []))
|
||||
))
|
||||
|
||||
if "claims_supported" in provider_config:
|
||||
claims_supported = provider_config.get("claims_supported", [])
|
||||
for k, v in config.items():
|
||||
if k.endswith('_field') and v:
|
||||
if v not in claims_supported: # https://openid.net/specs/openid-connect-core-1_0.html#UserInfo
|
||||
raise ValidationError(_('You are requesting field "{field}" but provider only supports these: {fields}.').format(
|
||||
field=v,
|
||||
fields=", ".join(provider_config.get("claims_supported", []))
|
||||
))
|
||||
|
||||
if "token_endpoint_auth_methods_supported" in provider_config:
|
||||
token_endpoint_auth_methods_supported = provider_config.get("token_endpoint_auth_methods_supported",
|
||||
["client_secret_basic"])
|
||||
|
||||
@@ -90,7 +90,6 @@ StaticMapping = namedtuple('StaticMapping', ('id', 'pretix_model', 'external_obj
|
||||
|
||||
class OutboundSyncProvider:
|
||||
max_attempts = 5
|
||||
list_field_joiner = "," # set to None to keep native lists in properties
|
||||
|
||||
def __init__(self, event):
|
||||
self.event = event
|
||||
@@ -216,10 +215,7 @@ class OutboundSyncProvider:
|
||||
|
||||
try:
|
||||
mapped_objects = self.sync_order(sq.order)
|
||||
actions_taken = [res and res.sync_info.get("action", "") for res_list in mapped_objects.values() for res in res_list]
|
||||
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:
|
||||
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()):
|
||||
sq.order.log_action("pretix.event.order.data_sync.success", {
|
||||
"provider": self.identifier,
|
||||
"objects": {
|
||||
@@ -240,7 +236,7 @@ class OutboundSyncProvider:
|
||||
sq.set_sync_error("exceeded", e.messages, e.full_message)
|
||||
else:
|
||||
logger.info(
|
||||
f"Could not sync order {sq.order.code} to {sq.sync_provider} "
|
||||
f"Could not sync order {sq.order.code} to {type(self).__name__} "
|
||||
f"(transient error, attempt #{sq.failed_attempts}, next {sq.not_before})",
|
||||
exc_info=True,
|
||||
)
|
||||
@@ -285,8 +281,7 @@ class OutboundSyncProvider:
|
||||
'Please update value mapping for field "{field_name}" - option "{val}" not assigned'
|
||||
).format(field_name=key, val=val)])
|
||||
|
||||
if self.list_field_joiner:
|
||||
val = self.list_field_joiner.join(val)
|
||||
val = ",".join(val)
|
||||
return val
|
||||
|
||||
def get_properties(self, inputs: dict, property_mappings: List[dict]):
|
||||
|
||||
@@ -71,20 +71,15 @@ def assign_properties(
|
||||
return out
|
||||
|
||||
|
||||
def _add_to_list(out, field_name, current_value, new_item_input, list_sep):
|
||||
def _add_to_list(out, field_name, current_value, new_item, list_sep):
|
||||
new_item = str(new_item)
|
||||
if list_sep is not None:
|
||||
new_items = str(new_item_input).split(list_sep)
|
||||
new_item = new_item.replace(list_sep, "")
|
||||
current_value = current_value.split(list_sep) if current_value else []
|
||||
else:
|
||||
new_items = [str(new_item_input)]
|
||||
if not isinstance(current_value, (list, tuple)):
|
||||
current_value = [str(current_value)]
|
||||
|
||||
new_list = list(current_value)
|
||||
for new_item in new_items:
|
||||
if new_item not in current_value:
|
||||
new_list.append(new_item)
|
||||
if new_list != current_value:
|
||||
elif not isinstance(current_value, (list, tuple)):
|
||||
current_value = [str(current_value)]
|
||||
if new_item not in current_value:
|
||||
new_list = current_value + [new_item]
|
||||
if list_sep is not None:
|
||||
new_list = list_sep.join(new_list)
|
||||
out[field_name] = new_list
|
||||
|
||||
@@ -24,7 +24,6 @@ from itertools import groupby
|
||||
from smtplib import SMTPResponseException
|
||||
from typing import TypeVar
|
||||
|
||||
import bleach
|
||||
import css_inline
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
@@ -35,11 +34,8 @@ from django.utils.translation import get_language, gettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.signals import register_html_mail_renderers
|
||||
from pretix.base.templatetags.rich_text import (
|
||||
DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback,
|
||||
markdown_compile_email, truelink_callback,
|
||||
)
|
||||
from pretix.helpers.format import FormattedString, SafeFormatter, format_map
|
||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
from pretix.helpers.format import SafeFormatter, format_map
|
||||
|
||||
from pretix.base.services.placeholders import ( # noqa
|
||||
get_available_placeholders, PlaceholderContext
|
||||
@@ -137,26 +133,13 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
def template_name(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
def compile_markdown(self, plaintext, context=None):
|
||||
return markdown_compile_email(plaintext, context=context)
|
||||
def compile_markdown(self, plaintext):
|
||||
return markdown_compile_email(plaintext)
|
||||
|
||||
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)
|
||||
if context:
|
||||
linker = bleach.Linker(
|
||||
url_re=URL_RE,
|
||||
email_re=EMAIL_RE,
|
||||
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||
parse_email=True
|
||||
)
|
||||
if apply_format_map:
|
||||
body_md = format_map(
|
||||
body_md,
|
||||
context=context,
|
||||
mode=SafeFormatter.MODE_RICH_TO_HTML,
|
||||
linkifier=linker
|
||||
)
|
||||
body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
|
||||
htmlctx = {
|
||||
'site': settings.PRETIX_INSTANCE_NAME,
|
||||
'site_url': settings.SITE_URL,
|
||||
|
||||
@@ -73,9 +73,6 @@ class BaseExporter:
|
||||
self.events = Event.objects.filter(pk=event.pk)
|
||||
self.timezone = event.timezone
|
||||
|
||||
if hasattr(self, 'organizer_required_permission'):
|
||||
raise TypeError("Deprecated attribute organizer_required_permission no longer supported.")
|
||||
|
||||
def __str__(self):
|
||||
return self.identifier
|
||||
|
||||
@@ -179,30 +176,15 @@ class BaseExporter:
|
||||
"""
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_required_event_permission(cls) -> 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:
|
||||
@classmethod
|
||||
def get_required_event_permission(cls):
|
||||
raise TypeError("required_event_permission may not be called on OrganizerLevelExportMixin")
|
||||
|
||||
@classmethod
|
||||
def get_required_organizer_permission(cls) -> str:
|
||||
@property
|
||||
def organizer_required_permission(self) -> str:
|
||||
"""
|
||||
The permission level required to use this exporter. Must be set for organizer-level exports. Set to `None` to
|
||||
allow everyone with any access to the organizer.
|
||||
|
||||
``get_required_event_permission`` will be ignored on this class.
|
||||
The permission level required to use this exporter. Only useful for organizer-level exports,
|
||||
not for event-level exports.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
return 'can_view_orders'
|
||||
|
||||
|
||||
class ListExporter(BaseExporter):
|
||||
|
||||
@@ -47,13 +47,10 @@ from ..signals import register_multievent_data_exporters
|
||||
class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'customerlist'
|
||||
verbose_name = gettext_lazy('Customer accounts')
|
||||
organizer_required_permission = 'can_manage_customers'
|
||||
category = pgettext_lazy('export_category', '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
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
|
||||
@@ -209,7 +209,6 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
_('Invoice sender:') + ' ' + _('Address'),
|
||||
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||
_('Invoice sender:') + ' ' + _('City'),
|
||||
_('Invoice sender:') + ' ' + pgettext('address', 'State'),
|
||||
_('Invoice sender:') + ' ' + _('Country'),
|
||||
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||
@@ -292,7 +291,6 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
i.invoice_from,
|
||||
i.invoice_from_zipcode,
|
||||
i.invoice_from_city,
|
||||
i.invoice_from_state,
|
||||
i.invoice_from_country,
|
||||
i.invoice_from_tax_id,
|
||||
i.invoice_from_vat_id,
|
||||
|
||||
@@ -149,7 +149,7 @@ class ItemDataExporter(ListExporter):
|
||||
row += [
|
||||
_("Yes") if i.active and v.active else "",
|
||||
", ".join([str(sn.label) for sn in sales_channels]),
|
||||
v.default_price if v.default_price is not None else i.default_price,
|
||||
v.default_price or i.default_price,
|
||||
_("Yes") if i.free_price else "",
|
||||
str(i.tax_rule) if i.tax_rule else "",
|
||||
_("Yes") if i.admission else "",
|
||||
|
||||
@@ -39,8 +39,8 @@ from zoneinfo import ZoneInfo
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.db.models import (
|
||||
Case, CharField, Count, DateTimeField, Exists, F, IntegerField, Max, Min,
|
||||
OuterRef, Q, Subquery, Sum, When,
|
||||
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
|
||||
Q, Subquery, Sum, When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.dispatch import receiver
|
||||
@@ -144,18 +144,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
d = OrderedDict(d)
|
||||
if not self.is_multievent and not self.event.has_subevents:
|
||||
del d['event_date_range']
|
||||
if not self.is_multievent:
|
||||
d["items"] = forms.ModelMultipleChoiceField(
|
||||
label=_("Products"),
|
||||
queryset=self.event.items.all(),
|
||||
widget=forms.CheckboxSelectMultiple(
|
||||
attrs={"class": "scrolling-multiple-choice"}
|
||||
),
|
||||
help_text=_("If none are selected, all products are included. Orders are included if they contain "
|
||||
"at least one position of this product. The order totals etc. still include all products "
|
||||
"contained in the order."),
|
||||
required=False,
|
||||
)
|
||||
return d
|
||||
|
||||
def _get_all_payment_methods(self, qs):
|
||||
@@ -261,17 +249,9 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
pcnt=Subquery(s, output_field=IntegerField())
|
||||
).select_related('invoice_address', 'customer')
|
||||
|
||||
if form_data.get('items'):
|
||||
qs = qs.filter(
|
||||
Exists(OrderPosition.all.filter(
|
||||
order=OuterRef('pk'),
|
||||
item__in=form_data["items"]
|
||||
))
|
||||
)
|
||||
|
||||
qs = self._date_filter(qs, form_data, rel='')
|
||||
|
||||
if form_data.get('paid_only'):
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(status=Order.STATUS_PAID)
|
||||
return qs
|
||||
|
||||
@@ -315,9 +295,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
for id, vn in payment_methods:
|
||||
headers.append(_('Paid by {method}').format(method=vn))
|
||||
|
||||
if self.event_object_cache:
|
||||
# get meta_data labels from first cached event if any
|
||||
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
# get meta_data labels from first cached event
|
||||
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
yield headers
|
||||
|
||||
full_fee_sum_cache = {
|
||||
@@ -385,7 +364,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.state_for_address,
|
||||
order.invoice_address.state,
|
||||
order.invoice_address.custom_field,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
@@ -458,17 +437,9 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
).annotate(
|
||||
payment_providers=Subquery(p_providers, output_field=CharField()),
|
||||
).select_related('order', 'order__invoice_address', 'order__customer', 'tax_rule')
|
||||
if form_data.get('paid_only'):
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
||||
|
||||
if form_data.get('items'):
|
||||
qs = qs.filter(
|
||||
Exists(OrderPosition.all.filter(
|
||||
order=OuterRef('order'),
|
||||
item__in=form_data["items"]
|
||||
))
|
||||
)
|
||||
|
||||
qs = self._date_filter(qs, form_data, rel='order__')
|
||||
return qs
|
||||
|
||||
@@ -504,9 +475,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('External customer ID'))
|
||||
headers.append(_('Payment providers'))
|
||||
|
||||
if self.event_object_cache:
|
||||
# get meta_data labels from first cached event if any
|
||||
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
# get meta_data labels from first cached event
|
||||
headers += next(iter(self.event_object_cache.values())).meta_data.keys()
|
||||
yield headers
|
||||
|
||||
yield self.ProgressSetTotal(total=qs.count())
|
||||
@@ -545,7 +515,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.state_for_address,
|
||||
order.invoice_address.state,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
@@ -562,14 +532,9 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
qs = OrderPosition.all.filter(
|
||||
order__event__in=self.events,
|
||||
)
|
||||
if form_data.get('paid_only'):
|
||||
if form_data['paid_only']:
|
||||
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
||||
|
||||
if form_data.get('items'):
|
||||
qs = qs.filter(
|
||||
item__in=form_data["items"]
|
||||
)
|
||||
|
||||
qs = self._date_filter(qs, form_data, rel='order__')
|
||||
return qs
|
||||
|
||||
@@ -645,15 +610,13 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
headers.append(_('Attendee name') + ': ' + str(label))
|
||||
headers += [
|
||||
_('Attendee email'),
|
||||
_('Attendee company'),
|
||||
_('Company'),
|
||||
_('Address'),
|
||||
_('ZIP code'),
|
||||
_('City'),
|
||||
_('Country'),
|
||||
pgettext('address', 'State'),
|
||||
_('Voucher'),
|
||||
_('Voucher budget usage'),
|
||||
_('Voucher tag'),
|
||||
_('Pseudonymization ID'),
|
||||
_('Ticket secret'),
|
||||
_('Seat ID'),
|
||||
@@ -687,7 +650,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
options[q.pk].append(o)
|
||||
headers.append(str(q.question))
|
||||
headers += [
|
||||
_('Invoice address company'),
|
||||
_('Company'),
|
||||
_('Invoice address name'),
|
||||
]
|
||||
if name_scheme and len(name_scheme['fields']) > 1:
|
||||
@@ -709,9 +672,9 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
_('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:
|
||||
# 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
|
||||
yield headers
|
||||
|
||||
@@ -769,10 +732,8 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
op.zipcode or '',
|
||||
op.city or '',
|
||||
op.country if op.country else '',
|
||||
op.state_for_address or '',
|
||||
op.state or '',
|
||||
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.secret,
|
||||
]
|
||||
@@ -836,7 +797,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
order.invoice_address.city,
|
||||
order.invoice_address.country if order.invoice_address.country else
|
||||
order.invoice_address.country_old,
|
||||
order.invoice_address.state_for_address,
|
||||
order.invoice_address.state,
|
||||
order.invoice_address.vat_id,
|
||||
]
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
@@ -1239,14 +1200,11 @@ class QuotaListExporter(ListExporter):
|
||||
class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'giftcardtransactionlist'
|
||||
verbose_name = gettext_lazy('Gift card transactions')
|
||||
organizer_required_permission = 'can_manage_gift_cards'
|
||||
category = pgettext_lazy('export_category', 'Gift cards')
|
||||
description = gettext_lazy('Download a spreadsheet of all gift card transactions.')
|
||||
repeatable_read = False
|
||||
|
||||
@classmethod
|
||||
def get_required_organizer_permission(cls) -> str:
|
||||
return 'organizer.giftcards:read'
|
||||
|
||||
@property
|
||||
def additional_form_fields(self):
|
||||
d = [
|
||||
@@ -1349,13 +1307,10 @@ class GiftcardRedemptionListExporter(ListExporter):
|
||||
class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
identifier = 'giftcardlist'
|
||||
verbose_name = gettext_lazy('Gift cards')
|
||||
organizer_required_permission = 'can_manage_gift_cards'
|
||||
category = pgettext_lazy('export_category', 'Gift cards')
|
||||
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
|
||||
def additional_form_fields(self):
|
||||
return OrderedDict(
|
||||
|
||||
@@ -36,10 +36,6 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
description = _('Download a spread sheet with the data of all reusable medias on your account.')
|
||||
repeatable_read = False
|
||||
|
||||
@classmethod
|
||||
def get_required_organizer_permission(cls) -> str:
|
||||
return "organizer.reusablemedia:read"
|
||||
|
||||
def iterate_list(self, form_data):
|
||||
media = ReusableMedium.objects.filter(
|
||||
organizer=self.organizer,
|
||||
|
||||
@@ -214,38 +214,21 @@ class PasswordRecoverForm(forms.Form):
|
||||
error_messages = {
|
||||
'pw_mismatch': _("Please enter the same password twice"),
|
||||
}
|
||||
email = forms.EmailField(
|
||||
max_length=255,
|
||||
disabled=True,
|
||||
label=_("Your email address"),
|
||||
widget=forms.EmailInput(
|
||||
attrs={'autocomplete': 'username'},
|
||||
),
|
||||
)
|
||||
password = forms.CharField(
|
||||
label=_('Password'),
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'autocomplete': 'new-password',
|
||||
}),
|
||||
widget=forms.PasswordInput,
|
||||
max_length=4096,
|
||||
required=True
|
||||
)
|
||||
password_repeat = forms.CharField(
|
||||
label=_('Repeat password'),
|
||||
widget=forms.PasswordInput(attrs={
|
||||
'autocomplete': 'new-password',
|
||||
}),
|
||||
widget=forms.PasswordInput,
|
||||
max_length=4096,
|
||||
)
|
||||
|
||||
def __init__(self, user_id=None, *args, **kwargs):
|
||||
initial = kwargs.pop('initial', {})
|
||||
try:
|
||||
self.user = User.objects.get(id=user_id)
|
||||
initial['email'] = self.user.email
|
||||
except User.DoesNotExist:
|
||||
self.user = None
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
self.user_id = user_id
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
password1 = self.cleaned_data.get('password', '')
|
||||
@@ -260,7 +243,11 @@ class PasswordRecoverForm(forms.Form):
|
||||
|
||||
def clean_password(self):
|
||||
password1 = self.cleaned_data.get('password', '')
|
||||
if validate_password(password1, user=self.user) is not None:
|
||||
try:
|
||||
user = User.objects.get(id=self.user_id)
|
||||
except User.DoesNotExist:
|
||||
user = None
|
||||
if validate_password(password1, user=user) is not None:
|
||||
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
|
||||
return password1
|
||||
|
||||
@@ -320,10 +307,3 @@ class ReauthForm(forms.Form):
|
||||
self.error_messages['inactive'],
|
||||
code='inactive',
|
||||
)
|
||||
|
||||
|
||||
class ConfirmationCodeForm(forms.Form):
|
||||
code = forms.IntegerField(
|
||||
label=_('Confirmation code'),
|
||||
widget=forms.NumberInput(attrs={'class': 'confirmation-code-input', 'inputmode': 'numeric', 'type': 'text'}),
|
||||
)
|
||||
|
||||
@@ -66,10 +66,8 @@ from geoip2.errors import AddressNotFoundError
|
||||
from phonenumber_field.formfields import PhoneNumberField
|
||||
from phonenumber_field.phonenumber import PhoneNumber
|
||||
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
||||
from phonenumbers import (
|
||||
COUNTRY_CODE_TO_REGION_CODE, REGION_CODE_FOR_NON_GEO_ENTITY,
|
||||
NumberParseException, national_significant_number,
|
||||
)
|
||||
from phonenumbers import NumberParseException, national_significant_number
|
||||
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
|
||||
from PIL import ImageOps
|
||||
|
||||
from pretix.base.forms.widgets import (
|
||||
@@ -85,7 +83,7 @@ from pretix.base.invoicing.transmission import (
|
||||
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
|
||||
from pretix.base.models.tax import ask_for_vat_id
|
||||
from pretix.base.services.tax import (
|
||||
VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id,
|
||||
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
|
||||
)
|
||||
from pretix.base.settings import (
|
||||
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
|
||||
@@ -307,9 +305,7 @@ class WrappedPhonePrefixSelect(Select):
|
||||
choices = [("", "---------")]
|
||||
|
||||
if initial:
|
||||
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
|
||||
continue
|
||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
if initial in values:
|
||||
self.initial = "+%d" % prefix
|
||||
break
|
||||
@@ -441,9 +437,7 @@ def guess_phone_prefix_from_request(request, event):
|
||||
|
||||
|
||||
def get_phone_prefix(country):
|
||||
if country == REGION_CODE_FOR_NON_GEO_ENTITY:
|
||||
return None
|
||||
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
||||
if country in values:
|
||||
return prefix
|
||||
return None
|
||||
@@ -890,18 +884,18 @@ class BaseQuestionsForm(forms.Form):
|
||||
if not help_text:
|
||||
if q.valid_date_min and q.valid_date_max:
|
||||
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"),
|
||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||
)
|
||||
elif q.valid_date_min:
|
||||
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"),
|
||||
)
|
||||
elif q.valid_date_max:
|
||||
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"),
|
||||
)
|
||||
if initial and initial.answer:
|
||||
@@ -939,18 +933,18 @@ class BaseQuestionsForm(forms.Form):
|
||||
if not help_text:
|
||||
if q.valid_datetime_min and q.valid_datetime_max:
|
||||
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"),
|
||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||
)
|
||||
elif q.valid_datetime_min:
|
||||
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"),
|
||||
)
|
||||
elif q.valid_datetime_max:
|
||||
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"),
|
||||
)
|
||||
|
||||
@@ -1171,11 +1165,13 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.fields['vat_id'].help_text = '<br/>'.join([
|
||||
str(_('Optional, but depending on the country you reside in we might need to charge you '
|
||||
'additional taxes if you do not enter it.')),
|
||||
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
|
||||
])
|
||||
else:
|
||||
self.fields['vat_id'].help_text = '<br/>'.join([
|
||||
str(_('Optional, but it might be required for you to claim tax benefits on your invoice '
|
||||
'depending on your and the seller’s country of residence.')),
|
||||
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
|
||||
])
|
||||
|
||||
transmission_type_choices = [
|
||||
@@ -1362,24 +1358,13 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
"transmission method.")}
|
||||
)
|
||||
|
||||
vat_id_applicable = (
|
||||
'vat_id' in self.fields and
|
||||
data.get('is_business') and
|
||||
ask_for_vat_id(data.get('country'))
|
||||
)
|
||||
vat_id_required = vat_id_applicable and str(data.get('country')) in self.event.settings.invoice_address_vatid_required_countries
|
||||
if vat_id_required and not data.get('vat_id'):
|
||||
raise ValidationError({
|
||||
"vat_id": _("This field is required.")
|
||||
})
|
||||
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
pass # Skip re-validation if it is validated
|
||||
elif self.validate_vat_id and vat_id_applicable:
|
||||
pass
|
||||
elif self.validate_vat_id and data.get('is_business') and ask_for_vat_id(data.get('country')) and data.get('vat_id'):
|
||||
try:
|
||||
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
|
||||
self.instance.vat_id_validated = True
|
||||
self.instance.vat_id = data['vat_id'] = normalized_id
|
||||
self.instance.vat_id = normalized_id
|
||||
except VATIDFinalError as e:
|
||||
if self.all_optional:
|
||||
self.instance.vat_id_validated = False
|
||||
@@ -1387,9 +1372,6 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
else:
|
||||
raise ValidationError({"vat_id": e.message})
|
||||
except VATIDTemporaryError as e:
|
||||
# We couldn't check it online, but we can still normalize it
|
||||
normalized_id = normalize_vat_id(data.get('vat_id'), str(data.get('country')))
|
||||
self.instance.vat_id = data['vat_id'] = normalized_id
|
||||
self.instance.vat_id_validated = False
|
||||
if self.request and self.vat_warning:
|
||||
messages.warning(self.request, e.message)
|
||||
@@ -1415,10 +1397,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
if not data.get(r):
|
||||
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_info = transmission_type.form_data_to_transmission_info(data)
|
||||
elif transmission_type.is_exclusive(self.event, data.get("country"), data.get("is_business")):
|
||||
elif transmission_type.exclusive:
|
||||
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
|
||||
raise ValidationError({
|
||||
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
|
||||
|
||||
@@ -39,16 +39,37 @@ from django.contrib.auth.password_validation import (
|
||||
password_validators_help_texts, validate_password,
|
||||
)
|
||||
from django.db.models import Q
|
||||
from django.urls.base import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from pytz import common_timezones
|
||||
|
||||
from pretix.base.models import User
|
||||
from pretix.control.forms import SingleLanguageWidget
|
||||
from pretix.helpers.format import format_map
|
||||
|
||||
|
||||
class UserSettingsForm(forms.ModelForm):
|
||||
error_messages = {
|
||||
'duplicate_identifier': _("There already is an account associated with this email address. "
|
||||
"Please choose a different one."),
|
||||
'pw_current': _("Please enter your current password if you want to change your email address "
|
||||
"or password."),
|
||||
'pw_current_wrong': _("The current password you entered was not correct."),
|
||||
'pw_mismatch': _("Please enter the same password twice"),
|
||||
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||
'pw_equal': _("Please choose a password different to your current one.")
|
||||
}
|
||||
|
||||
old_pw = forms.CharField(max_length=255,
|
||||
required=False,
|
||||
label=_("Your current password"),
|
||||
widget=forms.PasswordInput())
|
||||
new_pw = forms.CharField(max_length=255,
|
||||
required=False,
|
||||
label=_("New password"),
|
||||
widget=forms.PasswordInput())
|
||||
new_pw_repeat = forms.CharField(max_length=255,
|
||||
required=False,
|
||||
label=_("Repeat new password"),
|
||||
widget=forms.PasswordInput())
|
||||
timezone = forms.ChoiceField(
|
||||
choices=((a, a) for a in common_timezones),
|
||||
label=_("Default timezone"),
|
||||
@@ -72,63 +93,16 @@ class UserSettingsForm(forms.ModelForm):
|
||||
self.user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['email'].required = True
|
||||
self.fields['email'].disabled = True
|
||||
self.fields['email'].help_text = format_map('<a href="{link}"><span class="fa fa-edit"></span> {text}</a>', {
|
||||
'text': _("Change email address"),
|
||||
'link': reverse('control:user.settings.email.change')
|
||||
})
|
||||
|
||||
|
||||
class User2FADeviceAddForm(forms.Form):
|
||||
name = forms.CharField(label=_('Device name'), max_length=64)
|
||||
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
|
||||
('totp', _('Smartphone with the Authenticator application')),
|
||||
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
|
||||
))
|
||||
|
||||
|
||||
class UserPasswordChangeForm(forms.Form):
|
||||
error_messages = {
|
||||
'pw_current_wrong': _("The current password you entered was not correct."),
|
||||
'pw_mismatch': _("Please enter the same password twice"),
|
||||
'rate_limit': _("For security reasons, please wait 5 minutes before you try again."),
|
||||
'pw_equal': _("Please choose a password different to your current one.")
|
||||
}
|
||||
email = forms.EmailField(max_length=255,
|
||||
disabled=True,
|
||||
label=_("Your email address"),
|
||||
widget=forms.EmailInput(
|
||||
attrs={'autocomplete': 'username'},
|
||||
))
|
||||
old_pw = forms.CharField(max_length=255,
|
||||
required=True,
|
||||
label=_("Your current password"),
|
||||
widget=forms.PasswordInput(
|
||||
attrs={'autocomplete': 'current-password'},
|
||||
))
|
||||
new_pw = forms.CharField(max_length=255,
|
||||
required=True,
|
||||
label=_("New password"),
|
||||
widget=forms.PasswordInput(
|
||||
attrs={'autocomplete': 'new-password'},
|
||||
))
|
||||
new_pw_repeat = forms.CharField(max_length=255,
|
||||
required=True,
|
||||
label=_("Repeat new password"),
|
||||
widget=forms.PasswordInput(
|
||||
attrs={'autocomplete': 'new-password'},
|
||||
))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop('user')
|
||||
initial = kwargs.pop('initial', {})
|
||||
initial['email'] = self.user.email
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
if self.user.auth_backend != 'native':
|
||||
del self.fields['old_pw']
|
||||
del self.fields['new_pw']
|
||||
del self.fields['new_pw_repeat']
|
||||
self.fields['email'].disabled = True
|
||||
|
||||
def clean_old_pw(self):
|
||||
old_pw = self.cleaned_data.get('old_pw')
|
||||
|
||||
if settings.HAS_REDIS:
|
||||
if old_pw and settings.HAS_REDIS:
|
||||
from django_redis import get_redis_connection
|
||||
rc = get_redis_connection("redis")
|
||||
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
|
||||
@@ -139,7 +113,7 @@ class UserPasswordChangeForm(forms.Form):
|
||||
code='rate_limit',
|
||||
)
|
||||
|
||||
if not check_password(old_pw, self.user.password):
|
||||
if old_pw and not check_password(old_pw, self.user.password):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_current_wrong'],
|
||||
code='pw_current_wrong',
|
||||
@@ -147,47 +121,59 @@ class UserPasswordChangeForm(forms.Form):
|
||||
|
||||
return old_pw
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data['email']
|
||||
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.instance.pk)).exists():
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_identifier'],
|
||||
code='duplicate_identifier',
|
||||
)
|
||||
return email
|
||||
|
||||
def clean_new_pw(self):
|
||||
password1 = self.cleaned_data.get('new_pw', '')
|
||||
if validate_password(password1, user=self.user) is not None:
|
||||
if password1 and validate_password(password1, user=self.user) is not None:
|
||||
raise forms.ValidationError(
|
||||
_(password_validators_help_texts()),
|
||||
code='pw_invalid'
|
||||
)
|
||||
if self.user.check_password(password1):
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_equal'],
|
||||
code='pw_equal',
|
||||
)
|
||||
return password1
|
||||
|
||||
def clean_new_pw_repeat(self):
|
||||
password1 = self.cleaned_data.get('new_pw')
|
||||
password2 = self.cleaned_data.get('new_pw_repeat')
|
||||
if password1 != password2:
|
||||
if password1 and password1 != password2:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_mismatch'],
|
||||
code='pw_mismatch'
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
password1 = self.cleaned_data.get('new_pw')
|
||||
email = self.cleaned_data.get('email')
|
||||
old_pw = self.cleaned_data.get('old_pw')
|
||||
|
||||
class UserEmailChangeForm(forms.Form):
|
||||
error_messages = {
|
||||
'duplicate_identifier': _("There already is an account associated with this email address. "
|
||||
"Please choose a different one."),
|
||||
}
|
||||
old_email = forms.EmailField(label=_('Old email address'), disabled=True)
|
||||
new_email = forms.EmailField(label=_('New email address'))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop('user')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def clean_new_email(self):
|
||||
email = self.cleaned_data['new_email']
|
||||
if User.objects.filter(Q(email__iexact=email) & ~Q(pk=self.user.pk)).exists():
|
||||
if (password1 or email != self.user.email) and not old_pw:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['duplicate_identifier'],
|
||||
code='duplicate_identifier',
|
||||
self.error_messages['pw_current'],
|
||||
code='pw_current'
|
||||
)
|
||||
return email
|
||||
|
||||
if password1 and password1 == old_pw:
|
||||
raise forms.ValidationError(
|
||||
self.error_messages['pw_equal'],
|
||||
code='pw_equal'
|
||||
)
|
||||
|
||||
if password1:
|
||||
self.instance.set_password(password1)
|
||||
|
||||
return self.cleaned_data
|
||||
|
||||
|
||||
class User2FADeviceAddForm(forms.Form):
|
||||
name = forms.CharField(label=_('Device name'), max_length=64)
|
||||
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
|
||||
('totp', _('Smartphone with the Authenticator application')),
|
||||
('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
|
||||
))
|
||||
|
||||
@@ -42,8 +42,6 @@ from django.utils.html import escape
|
||||
from django.utils.timezone import get_current_timezone, now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.helpers.format import PlainHtmlAlternativeString
|
||||
|
||||
|
||||
def replace_arabic_numbers(inp):
|
||||
if not isinstance(inp, str):
|
||||
@@ -63,18 +61,11 @@ def replace_arabic_numbers(inp):
|
||||
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):
|
||||
placeholders = [(k, v.render_sample(event) if event else v) for k, v in placeholders.items()]
|
||||
placeholders.sort(key=lambda x: x[0])
|
||||
phs = [
|
||||
format_placeholder_help_text(k, v)
|
||||
'<button type="button" class="content-placeholder" title="%s">{%s}</button>' % (escape(_("Sample: %s") % v) if v else "", escape(k))
|
||||
for k, v in placeholders
|
||||
]
|
||||
return _('Available placeholders: {list}').format(
|
||||
|
||||
@@ -34,13 +34,14 @@
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from asgiref.local import Local
|
||||
from babel import localedata
|
||||
from django.conf import settings
|
||||
from django.utils import translation
|
||||
from django.utils.formats import date_format, number_format
|
||||
from django.utils.translation import gettext
|
||||
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
|
||||
from i18nfield.fields import ( # noqa
|
||||
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
|
||||
)
|
||||
@@ -50,9 +51,6 @@ from i18nfield.strings import LazyI18nString # noqa
|
||||
from i18nfield.utils import I18nJSONEncoder # noqa
|
||||
|
||||
|
||||
_active_region = Local()
|
||||
|
||||
|
||||
class LazyDate:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
@@ -88,8 +86,6 @@ class LazyCurrencyNumber:
|
||||
return self.__str__()
|
||||
|
||||
def __str__(self):
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
|
||||
return money_filter(self.value, self.currency)
|
||||
|
||||
|
||||
@@ -109,41 +105,14 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
|
||||
|
||||
|
||||
def get_babel_locale():
|
||||
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal
|
||||
# Also, this returns best-effort region information for number formatting etc
|
||||
current_language = translation.get_language()
|
||||
current_region = getattr(_active_region, "value", None)
|
||||
|
||||
# Babel only accepts locales that exist on the system. We try combinations in the following order:
|
||||
# language-languageversion-region
|
||||
# language-region
|
||||
# language-languageversion
|
||||
# language
|
||||
# fallback to system default
|
||||
# fallback to english
|
||||
|
||||
try_locales = []
|
||||
if current_language:
|
||||
if "-" in current_language:
|
||||
lng_parts = current_language.split("-")
|
||||
if current_region:
|
||||
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}_{current_region.upper()}")
|
||||
try_locales.append(f"{lng_parts[0]}_{current_region.upper()}")
|
||||
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].upper()}")
|
||||
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}")
|
||||
try_locales.append(f"{lng_parts[0]}")
|
||||
else:
|
||||
if current_region:
|
||||
try_locales.append(f"{current_language}_{current_region.upper()}")
|
||||
try_locales.append(f"{current_language}")
|
||||
|
||||
try_locales.append(settings.LANGUAGE_CODE)
|
||||
|
||||
for locale in try_locales:
|
||||
if localedata.exists(locale):
|
||||
return localedata.normalize_locale(locale)
|
||||
|
||||
return "en"
|
||||
babel_locale = 'en'
|
||||
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
|
||||
if translation.get_language():
|
||||
if localedata.exists(translation.get_language()):
|
||||
babel_locale = translation.get_language()
|
||||
elif localedata.exists(translation.get_language()[:2]):
|
||||
babel_locale = translation.get_language()[:2]
|
||||
return babel_locale
|
||||
|
||||
|
||||
def get_language_without_region(lng=None):
|
||||
@@ -163,10 +132,6 @@ def get_language_without_region(lng=None):
|
||||
return lng
|
||||
|
||||
|
||||
def set_region(region):
|
||||
_active_region.value = region
|
||||
|
||||
|
||||
@contextmanager
|
||||
def language(lng, region=None):
|
||||
"""
|
||||
@@ -178,18 +143,15 @@ def language(lng, region=None):
|
||||
formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region``
|
||||
attribute will be ignored.
|
||||
"""
|
||||
lng_before = translation.get_language()
|
||||
region_before = getattr(_active_region, "value", None)
|
||||
_lng = translation.get_language()
|
||||
lng = lng or settings.LANGUAGE_CODE
|
||||
if '-' not in lng and region:
|
||||
lng += '-' + region.lower()
|
||||
translation.activate(lng)
|
||||
_active_region.value = region
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
translation.activate(lng_before)
|
||||
_active_region.value = region_before
|
||||
translation.activate(_lng)
|
||||
|
||||
|
||||
class LazyLocaleException(Exception):
|
||||
|
||||
@@ -33,7 +33,8 @@ from pretix.base.invoicing.transmission import (
|
||||
transmission_types,
|
||||
)
|
||||
from pretix.base.models import Invoice, InvoiceAddress
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
||||
from pretix.helpers.format import format_map
|
||||
|
||||
|
||||
@transmission_types.new()
|
||||
@@ -132,26 +133,41 @@ class EmailTransmissionProvider(TransmissionProvider):
|
||||
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)
|
||||
|
||||
# Do not set to completed because that is done by the email sending task
|
||||
outgoing_mail = mail(
|
||||
[recipient],
|
||||
subject,
|
||||
template,
|
||||
context=context,
|
||||
event=invoice.order.event,
|
||||
locale=invoice.order.locale,
|
||||
order=invoice.order,
|
||||
invoices=[invoice],
|
||||
attach_tickets=False,
|
||||
auto_email=True,
|
||||
attach_ical=False,
|
||||
plain_text_only=True,
|
||||
no_order_links=True,
|
||||
)
|
||||
if outgoing_mail:
|
||||
try:
|
||||
# Do not set to completed because that is done by the email sending task
|
||||
subject = format_map(subject, context)
|
||||
email_content = render_mail(template, context)
|
||||
mail(
|
||||
[recipient],
|
||||
subject,
|
||||
template,
|
||||
context=context,
|
||||
event=invoice.order.event,
|
||||
locale=invoice.order.locale,
|
||||
order=invoice.order,
|
||||
invoices=[invoice],
|
||||
attach_tickets=False,
|
||||
auto_email=True,
|
||||
attach_ical=False,
|
||||
plain_text_only=True,
|
||||
no_order_links=True,
|
||||
)
|
||||
except SendMailException:
|
||||
raise
|
||||
else:
|
||||
invoice.order.log_action(
|
||||
'pretix.event.order.email.invoice',
|
||||
user=None,
|
||||
auth=None,
|
||||
data=outgoing_mail.log_data()
|
||||
data={
|
||||
'subject': subject,
|
||||
'message': email_content,
|
||||
'position': None,
|
||||
'recipient': recipient,
|
||||
'invoices': [invoice.pk],
|
||||
'attach_tickets': False,
|
||||
'attach_ical': False,
|
||||
'attach_other_files': [],
|
||||
'attach_cached_files': [],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -36,11 +36,9 @@ class ItalianSdITransmissionType(TransmissionType):
|
||||
identifier = "it_sdi"
|
||||
verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)")
|
||||
public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)")
|
||||
exclusive = True
|
||||
enforce_transmission = True
|
||||
|
||||
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
|
||||
return str(country) == "IT"
|
||||
|
||||
def is_available(self, event, country: Country, is_business: bool):
|
||||
return str(country) == "IT" and super().is_available(event, country, is_business)
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import datetime
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import textwrap
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
@@ -32,6 +31,7 @@ from itertools import groupby
|
||||
from typing import Tuple
|
||||
|
||||
import bleach
|
||||
import vat_moss.exchange_rates
|
||||
from bidi import get_display
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.db.models import Sum
|
||||
@@ -46,6 +46,7 @@ from reportlab.lib.styles import ParagraphStyle, StyleSheet1
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.pdfgen.canvas import Canvas
|
||||
from reportlab.platypus import (
|
||||
BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate,
|
||||
@@ -58,8 +59,7 @@ from pretix.base.services.currencies import SOURCE_NAMES
|
||||
from pretix.base.signals import register_invoice_renderers
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.helpers.reportlab import (
|
||||
FontFallbackParagraph, ThumbnailingImageReader, register_ttf_font_if_new,
|
||||
reshaper,
|
||||
FontFallbackParagraph, ThumbnailingImageReader, reshaper,
|
||||
)
|
||||
from pretix.presale.style import get_fonts
|
||||
|
||||
@@ -148,10 +148,6 @@ class NumberedCanvas(Canvas):
|
||||
self.restoreState()
|
||||
|
||||
|
||||
class InvoiceNotReadyException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BaseInvoiceRenderer:
|
||||
"""
|
||||
This is the base class for all invoice renderers.
|
||||
@@ -238,25 +234,25 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
||||
"""
|
||||
Register fonts with reportlab. By default, this registers the OpenSans font family
|
||||
"""
|
||||
register_ttf_font_if_new('OpenSans', finders.find('fonts/OpenSans-Regular.ttf'))
|
||||
register_ttf_font_if_new('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf'))
|
||||
register_ttf_font_if_new('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf'))
|
||||
register_ttf_font_if_new('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf'))
|
||||
pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf')))
|
||||
pdfmetrics.registerFont(TTFont('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf')))
|
||||
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
|
||||
italic='OpenSansIt', boldItalic='OpenSansBI')
|
||||
|
||||
for family, styles in get_fonts(event=self.event, pdf_support_required=True).items():
|
||||
register_ttf_font_if_new(family, finders.find(styles['regular']['truetype']))
|
||||
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
||||
if family == self.event.settings.invoice_renderer_font:
|
||||
self.font_regular = family
|
||||
if 'bold' in styles:
|
||||
self.font_bold = family + ' B'
|
||||
if 'italic' in styles:
|
||||
register_ttf_font_if_new(family + ' I', finders.find(styles['italic']['truetype']))
|
||||
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
|
||||
if 'bold' in styles:
|
||||
register_ttf_font_if_new(family + ' B', finders.find(styles['bold']['truetype']))
|
||||
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
|
||||
if 'bolditalic' in styles:
|
||||
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
|
||||
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
|
||||
|
||||
def _normalize(self, text):
|
||||
# reportlab does not support unicode combination characters
|
||||
@@ -756,59 +752,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
return dt.astimezone(tz).date()
|
||||
|
||||
total = Decimal('0.00')
|
||||
if has_taxes:
|
||||
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
|
||||
else:
|
||||
colwidths = [a * doc.width for a in (.65, .20, .15)]
|
||||
|
||||
for (description, tax_rate, tax_name, net_value, gross_value, subevent, period_start, period_end), lines in addon_aware_groupby(
|
||||
all_lines,
|
||||
key=_group_key,
|
||||
is_addon=lambda l: l.description.startswith(" +"),
|
||||
):
|
||||
# split description into multiple Paragraphs so each fits in a table cell on a single page
|
||||
# otherwise PDF-build fails
|
||||
|
||||
description_p_list = []
|
||||
# normalize linebreaks to newlines instead of HTML so we can safely substring
|
||||
description = description.replace('<br>', '<br />').replace('<br />\n', '\n').replace('<br />', '\n')
|
||||
|
||||
# start first line with different settings than the rest of the description
|
||||
curr_description = description.split("\n", maxsplit=1)[0]
|
||||
cellpadding = 6 # default cellpadding is only set on right side of column
|
||||
max_width = colwidths[0] - cellpadding
|
||||
max_height = self.stylesheet['Normal'].leading * 5
|
||||
p_style = self.stylesheet['Normal']
|
||||
for __ in range(1000):
|
||||
p = FontFallbackParagraph(
|
||||
self._clean_text(curr_description, tags=['br']),
|
||||
p_style
|
||||
)
|
||||
h = p.wrap(max_width, doc.height)[1]
|
||||
if h <= max_height:
|
||||
description_p_list.append(p)
|
||||
if curr_description == description:
|
||||
break
|
||||
description = description[len(curr_description):].lstrip()
|
||||
curr_description = description.split("\n", maxsplit=1)[0]
|
||||
# use different settings for all except first line
|
||||
max_width = sum(colwidths[0:3 if has_taxes else 2]) - cellpadding
|
||||
max_height = self.stylesheet['Fineprint'].leading * 8
|
||||
p_style = self.stylesheet['Fineprint']
|
||||
continue
|
||||
|
||||
if not description_p_list:
|
||||
# first "manual" line is larger than 5 "real" lines => only allow one line and set rest in Fineprint
|
||||
max_height = self.stylesheet['Normal'].leading
|
||||
|
||||
if h > max_height * 1.1:
|
||||
# quickly bring the text-length down to a managable length to then stepwise reduce
|
||||
wrap_to = math.ceil(len(curr_description) * max_height * 1.1 / h)
|
||||
else:
|
||||
# trim to 95% length, but at most 10 chars to not have strangely short lines in the middle of a paragraph
|
||||
wrap_to = max(len(curr_description) - 10, math.ceil(len(curr_description) * 0.95))
|
||||
curr_description = textwrap.wrap(curr_description, wrap_to, replace_whitespace=False, drop_whitespace=False)[0]
|
||||
|
||||
# Try to be clever and figure out when organizers would want to show the period. This heuristic is
|
||||
# not perfect and the only "fully correct" way would be to include the period on every line always,
|
||||
# however this will cause confusion (a) due to useless repetition of the same date all over the invoice
|
||||
@@ -862,10 +810,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
# Group together at the end of the invoice
|
||||
request_show_service_date = period_line
|
||||
elif period_line:
|
||||
description_p_list.append(FontFallbackParagraph(
|
||||
period_line,
|
||||
self.stylesheet['Fineprint']
|
||||
))
|
||||
description += "\n" + period_line
|
||||
|
||||
lines = list(lines)
|
||||
if has_taxes:
|
||||
@@ -874,13 +819,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
net_price=money_filter(net_value, self.invoice.event.currency),
|
||||
gross_price=money_filter(gross_value, self.invoice.event.currency),
|
||||
)
|
||||
description_p_list.append(FontFallbackParagraph(
|
||||
single_price_line,
|
||||
self.stylesheet['Fineprint']
|
||||
))
|
||||
description = description + "\n" + single_price_line
|
||||
|
||||
tdata.append((
|
||||
description_p_list.pop(0),
|
||||
FontFallbackParagraph(
|
||||
self._clean_text(description, tags=['br']),
|
||||
self.stylesheet['Normal']
|
||||
),
|
||||
str(len(lines)),
|
||||
localize(tax_rate) + " %",
|
||||
FontFallbackParagraph(
|
||||
@@ -892,52 +837,23 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
self.stylesheet['NormalRight']
|
||||
),
|
||||
))
|
||||
for p in description_p_list:
|
||||
tdata.append((p, "", "", "", ""))
|
||||
tstyledata.append((
|
||||
'SPAN',
|
||||
(0, len(tdata) - 1),
|
||||
(2, len(tdata) - 1),
|
||||
))
|
||||
else:
|
||||
if len(lines) > 1:
|
||||
single_price_line = pgettext('invoice', 'Single price: {price}').format(
|
||||
price=money_filter(gross_value, self.invoice.event.currency),
|
||||
)
|
||||
description_p_list.append(FontFallbackParagraph(
|
||||
single_price_line,
|
||||
self.stylesheet['Fineprint']
|
||||
))
|
||||
description = description + "\n" + single_price_line
|
||||
tdata.append((
|
||||
description_p_list.pop(0),
|
||||
FontFallbackParagraph(
|
||||
self._clean_text(description, tags=['br']),
|
||||
self.stylesheet['Normal']
|
||||
),
|
||||
str(len(lines)),
|
||||
FontFallbackParagraph(
|
||||
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
||||
self.stylesheet['NormalRight']
|
||||
),
|
||||
))
|
||||
for p in description_p_list:
|
||||
tdata.append((p, "", ""))
|
||||
tstyledata.append((
|
||||
'SPAN',
|
||||
(0, len(tdata) - 1),
|
||||
(1, len(tdata) - 1),
|
||||
))
|
||||
|
||||
tstyledata += [
|
||||
(
|
||||
'BOTTOMPADDING',
|
||||
(0, len(tdata) - len(description_p_list)),
|
||||
(-1, len(tdata) - 2),
|
||||
0
|
||||
),
|
||||
(
|
||||
'TOPPADDING',
|
||||
(0, len(tdata) - len(description_p_list)),
|
||||
(-1, len(tdata) - 1),
|
||||
0
|
||||
),
|
||||
]
|
||||
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
|
||||
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
|
||||
total += gross_value * len(lines)
|
||||
@@ -947,11 +863,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
|
||||
money_filter(total, self.invoice.event.currency)
|
||||
])
|
||||
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
|
||||
else:
|
||||
tdata.append([
|
||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
|
||||
money_filter(total, self.invoice.event.currency)
|
||||
])
|
||||
colwidths = [a * doc.width for a in (.65, .20, .15)]
|
||||
|
||||
if not self.invoice.is_cancellation:
|
||||
if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING:
|
||||
@@ -1062,7 +980,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
||||
|
||||
def fmt(val):
|
||||
try:
|
||||
return money_filter(val, self.invoice.foreign_currency_display)
|
||||
return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display)
|
||||
except ValueError:
|
||||
return localize(val) + ' ' + self.invoice.foreign_currency_display
|
||||
|
||||
|
||||
@@ -19,11 +19,8 @@
|
||||
# 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 base64
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
import dns.resolver
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _, pgettext
|
||||
@@ -64,7 +61,7 @@ class PeppolIdValidator:
|
||||
"0020": "[0-9]{9}",
|
||||
"0201": "[0-9a-zA-Z]{6}",
|
||||
"0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}",
|
||||
"0208": "[01][0-9]{9}",
|
||||
"0208": "0[0-9]{9}",
|
||||
"0209": ".*",
|
||||
"0210": "[A-Z0-9]+",
|
||||
"0211": "IT[0-9]{11}",
|
||||
@@ -73,9 +70,6 @@ class PeppolIdValidator:
|
||||
"0205": "[A-Z0-9]+",
|
||||
"0221": "T[0-9]{13}",
|
||||
"0230": ".*",
|
||||
"0244": "[0-9]{13}",
|
||||
"0245": "[0-9]{10}",
|
||||
"0246": "DE[0-9]{9}(-[0-9]{5})?(\\.[0-9A-Z]{1,8})?",
|
||||
"9901": ".*",
|
||||
"9902": "[1-9][0-9]{7}",
|
||||
"9904": "DK[0-9]{8}",
|
||||
@@ -123,14 +117,12 @@ class PeppolIdValidator:
|
||||
"9951": ".*",
|
||||
"9952": ".*",
|
||||
"9953": ".*",
|
||||
"9954": ".*",
|
||||
"9956": "0[0-9]{9}",
|
||||
"9957": ".*",
|
||||
"9959": ".*",
|
||||
}
|
||||
|
||||
def __init__(self, validate_online=False):
|
||||
self.validate_online = validate_online
|
||||
|
||||
def __call__(self, value):
|
||||
if ":" not in value:
|
||||
raise ValidationError(_("A Peppol participant ID always starts with a prefix, followed by a colon (:)."))
|
||||
@@ -144,28 +136,6 @@ class PeppolIdValidator:
|
||||
raise ValidationError(_("The Peppol participant ID does not match the validation rules for the prefix "
|
||||
"%(number)s. Please reach out to us if you are sure this ID is correct."),
|
||||
params={"number": prefix})
|
||||
|
||||
if self.validate_online:
|
||||
base_hostnames = ['edelivery.tech.ec.europa.eu', 'acc.edelivery.tech.ec.europa.eu']
|
||||
smp_id = base64.b32encode(hashlib.sha256(value.lower().encode()).digest()).decode().rstrip("=")
|
||||
for base_hostname in base_hostnames:
|
||||
smp_domain = f'{smp_id}.iso6523-actorid-upis.{base_hostname}'
|
||||
resolver = dns.resolver.Resolver()
|
||||
try:
|
||||
answers = resolver.resolve(smp_domain, 'NAPTR', lifetime=1.0)
|
||||
if answers:
|
||||
return value
|
||||
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
|
||||
# ID not registered, do not set found=True
|
||||
pass
|
||||
except Exception: # noqa
|
||||
# Error likely on our end or infrastructure is down, allow user to proceed
|
||||
return value
|
||||
|
||||
raise ValidationError(
|
||||
_("The Peppol participant ID is not registered on the Peppol network."),
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
@@ -179,21 +149,13 @@ class PeppolTransmissionType(TransmissionType):
|
||||
def is_available(self, event, country: Country, is_business: bool):
|
||||
return is_business and super().is_available(event, country, is_business)
|
||||
|
||||
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
|
||||
if is_business and str(country) == "BE" and event and event.settings.invoice_address_from_country == "BE":
|
||||
# Peppol is required to be used for intra-Belgian B2B invoices
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def invoice_address_form_fields(self) -> dict:
|
||||
return {
|
||||
"transmission_peppol_participant_id": forms.CharField(
|
||||
label=_("Peppol participant ID"),
|
||||
validators=[
|
||||
PeppolIdValidator(
|
||||
validate_online=True,
|
||||
),
|
||||
PeppolIdValidator(),
|
||||
]
|
||||
),
|
||||
}
|
||||
@@ -204,12 +166,6 @@ class PeppolTransmissionType(TransmissionType):
|
||||
}
|
||||
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:
|
||||
return pgettext("peppol_invoice", "Visual copy")
|
||||
|
||||
|
||||
@@ -21,10 +21,9 @@
|
||||
#
|
||||
from typing import Optional
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_countries.fields import Country
|
||||
|
||||
from pretix.base.models import Invoice
|
||||
from pretix.base.models import Invoice, InvoiceAddress
|
||||
from pretix.base.signals import EventPluginRegistry, Registry
|
||||
|
||||
|
||||
@@ -59,6 +58,15 @@ class TransmissionType:
|
||||
"""
|
||||
return 100
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
"""
|
||||
If a transmission type is exclusive, no other type can be chosen if this type is
|
||||
available. Use e.g. if a certain transmission type is legally required in a certain
|
||||
jurisdiction.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def enforce_transmission(self) -> bool:
|
||||
"""
|
||||
@@ -74,22 +82,13 @@ class TransmissionType:
|
||||
for provider, _ in providers
|
||||
)
|
||||
|
||||
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
|
||||
"""
|
||||
If a transmission type is exclusive, no other type can be chosen if this type is
|
||||
available. Use e.g. if a certain transmission type is legally required in a certain
|
||||
jurisdiction. Event can be None in organizer-level contexts. Exclusiveness has no effect if
|
||||
the type is not available.
|
||||
"""
|
||||
return False
|
||||
|
||||
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
|
||||
return set()
|
||||
|
||||
def invoice_address_form_fields_visible(self, country: Country, is_business: bool) -> set:
|
||||
return set(self.invoice_address_form_fields.keys())
|
||||
|
||||
def validate_invoice_address_data(self, address_data: dict):
|
||||
def validate_address(self, ia: InvoiceAddress):
|
||||
pass
|
||||
|
||||
@property
|
||||
@@ -107,22 +106,6 @@ class TransmissionType:
|
||||
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
|
||||
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]:
|
||||
"""
|
||||
Return a watermark that should be rendered across the PDF file.
|
||||
|
||||
@@ -19,15 +19,20 @@
|
||||
# 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 json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from functools import cached_property
|
||||
from typing import Optional
|
||||
|
||||
import jsonschema
|
||||
from django.urls import reverse
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix.base.signals import PluginAwareRegistry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def make_link(a_map, wrapper, is_active=True, event=None, plugin_name=None):
|
||||
if a_map:
|
||||
@@ -105,12 +110,38 @@ They are annotated with their ``action_type`` and the defining ``plugin``.
|
||||
log_entry_types = LogEntryTypeRegistry()
|
||||
|
||||
|
||||
def prepare_schema(schema):
|
||||
def handle_properties(t):
|
||||
return {"shred_properties": [k for k, v in t["properties"].items() if v["shred"]]}
|
||||
|
||||
def walk_tree(schema):
|
||||
if type(schema) is dict:
|
||||
new_keys = {}
|
||||
for k, v in schema.items():
|
||||
if k == "properties":
|
||||
new_keys = handle_properties(schema)
|
||||
walk_tree(v)
|
||||
if schema.get("type") == "object" and "additionalProperties" not in new_keys:
|
||||
new_keys["additionalProperties"] = False
|
||||
schema.update(new_keys)
|
||||
elif type(schema) is list:
|
||||
for v in schema:
|
||||
walk_tree(v)
|
||||
|
||||
walk_tree(schema)
|
||||
return schema
|
||||
|
||||
|
||||
class LogEntryType:
|
||||
"""
|
||||
Base class for a type of LogEntry, identified by its action_type.
|
||||
"""
|
||||
|
||||
data_schema = None # {"type": "object", "properties": []}
|
||||
|
||||
def __init__(self, action_type=None, plain=None):
|
||||
if self.data_schema:
|
||||
print(self.__class__.__name__, "has schema", self._prepared_schema)
|
||||
if action_type:
|
||||
self.action_type = action_type
|
||||
if plain:
|
||||
@@ -147,12 +178,37 @@ class LogEntryType:
|
||||
|
||||
object_link_wrapper = '{val}'
|
||||
|
||||
def validate_data(self, parsed_data):
|
||||
if not self._prepared_schema:
|
||||
return
|
||||
try:
|
||||
jsonschema.validate(parsed_data, self._prepared_schema)
|
||||
except jsonschema.exceptions.ValidationError as ex:
|
||||
logger.warning("%s schema validation failed: %s %s", type(self).__name__, ex.json_path, ex.message)
|
||||
raise
|
||||
|
||||
@cached_property
|
||||
def _prepared_schema(self):
|
||||
if self.data_schema:
|
||||
return prepare_schema(self.data_schema)
|
||||
|
||||
def shred_pii(self, logentry):
|
||||
"""
|
||||
To be used for shredding personally identified information contained in the data field of a LogEntry of this
|
||||
type.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
if self._prepared_schema:
|
||||
def shred_fun(validator, value, instance, schema):
|
||||
for key in value:
|
||||
instance[key] = "##########"
|
||||
|
||||
v = jsonschema.validators.extend(jsonschema.validators.Draft202012Validator,
|
||||
validators={"shred_properties": shred_fun})
|
||||
data = logentry.parsed_data
|
||||
jsonschema.validate(data, self._prepared_schema, v)
|
||||
logentry.data = json.dumps(data)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class NoOpShredderMixin:
|
||||
|
||||
@@ -294,28 +294,14 @@ def metric_values():
|
||||
channel = app.broker_connection().channel()
|
||||
if hasattr(channel, 'client') and channel.client is not None:
|
||||
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:
|
||||
queue_lengths = []
|
||||
queue_delays = []
|
||||
for prio in priority_steps:
|
||||
if prio:
|
||||
qname = f"{q.name}{sep}{prio}"
|
||||
else:
|
||||
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)
|
||||
llen = client.llen(q.name)
|
||||
lfirst = client.lindex(q.name, -1)
|
||||
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = llen
|
||||
if lfirst:
|
||||
ldata = json.loads(lfirst)
|
||||
dt = time.time() - ldata.get('created', 0)
|
||||
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = dt
|
||||
else:
|
||||
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,
|
||||
)
|
||||
|
||||
from pretix.base.i18n import get_language_without_region, set_region
|
||||
from pretix.base.i18n import get_language_without_region
|
||||
from pretix.base.settings import global_settings_object
|
||||
from pretix.multidomain.urlreverse import (
|
||||
get_event_domain, get_organizer_domain,
|
||||
@@ -92,14 +92,10 @@ class LocaleMiddleware(MiddlewareMixin):
|
||||
)
|
||||
if '-' not in language and settings_holder.settings.region:
|
||||
language += '-' + settings_holder.settings.region
|
||||
if settings_holder.settings.region:
|
||||
set_region(settings_holder.settings.region)
|
||||
else:
|
||||
gs = global_settings_object(request)
|
||||
if '-' not in language and gs.settings.region:
|
||||
language += '-' + gs.settings.region
|
||||
if gs.settings.region:
|
||||
set_region(gs.settings.region)
|
||||
|
||||
translation.activate(language)
|
||||
request.LANGUAGE_CODE = get_language_without_region()
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# Generated by Django 4.2.19 on 2025-08-11 10:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('pretixbase', '0293_cartposition_price_includes_rounding_correction_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ItemProgramTime',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('start', models.DateTimeField()),
|
||||
('end', models.DateTimeField()),
|
||||
('item',
|
||||
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='program_times',
|
||||
to='pretixbase.item')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 4.2.23 on 2025-09-04 16:06
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0294_item_program_time"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="user",
|
||||
name="is_verified",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 4.2.24 on 2025-11-10 16:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0295_user_is_verified"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="invoice",
|
||||
name="invoice_from_state",
|
||||
field=models.CharField(max_length=190, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,120 +0,0 @@
|
||||
# Generated by Django 4.2.26 on 2026-01-22 13:44
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import pretix.base.models.mail
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0296_invoice_invoice_from_state"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="OutgoingMail",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("guid", models.UUIDField(db_index=True, default=uuid.uuid4)),
|
||||
("status", models.CharField(default="queued", max_length=200)),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
("sent", models.DateTimeField(blank=True, null=True)),
|
||||
("inflight_since", models.DateTimeField(blank=True, null=True)),
|
||||
("retry_after", models.DateTimeField(blank=True, null=True)),
|
||||
("error", models.TextField(null=True)),
|
||||
("error_detail", models.TextField(null=True)),
|
||||
("sensitive", models.BooleanField(default=False)),
|
||||
("subject", models.TextField()),
|
||||
("body_plain", models.TextField()),
|
||||
("body_html", models.TextField(null=True)),
|
||||
("sender", models.CharField(max_length=500)),
|
||||
("headers", models.JSONField(default=dict)),
|
||||
("to", models.JSONField(default=list)),
|
||||
("cc", models.JSONField(default=list)),
|
||||
("bcc", models.JSONField(default=list)),
|
||||
("recipient_count", models.IntegerField()),
|
||||
("should_attach_tickets", models.BooleanField(default=False)),
|
||||
("should_attach_ical", models.BooleanField(default=False)),
|
||||
("should_attach_other_files", models.JSONField(default=list)),
|
||||
("actual_attachments", models.JSONField(default=list)),
|
||||
(
|
||||
"customer",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
|
||||
related_name="outgoing_mails",
|
||||
to="pretixbase.customer",
|
||||
),
|
||||
),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
|
||||
related_name="outgoing_mails",
|
||||
to="pretixbase.event",
|
||||
),
|
||||
),
|
||||
(
|
||||
"order",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
|
||||
related_name="outgoing_mails",
|
||||
to="pretixbase.order",
|
||||
),
|
||||
),
|
||||
(
|
||||
"orderposition",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
|
||||
related_name="outgoing_mails",
|
||||
to="pretixbase.orderposition",
|
||||
),
|
||||
),
|
||||
(
|
||||
"organizer",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="outgoing_mails",
|
||||
to="pretixbase.organizer",
|
||||
),
|
||||
),
|
||||
(
|
||||
"should_attach_cached_files",
|
||||
models.ManyToManyField(
|
||||
related_name="outgoing_mails", to="pretixbase.cachedfile"
|
||||
),
|
||||
),
|
||||
(
|
||||
"should_attach_invoices",
|
||||
models.ManyToManyField(
|
||||
related_name="outgoing_mails", to="pretixbase.invoice"
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="outgoing_mails",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ("-created",),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,137 +0,0 @@
|
||||
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,19 +47,6 @@ class DataImportError(LazyLocaleException):
|
||||
super().__init__(msg)
|
||||
|
||||
|
||||
def rename_duplicates(values):
|
||||
used = set()
|
||||
had_duplicates = False
|
||||
for i, value in enumerate(values):
|
||||
c = 0
|
||||
while values[i] in used:
|
||||
c += 1
|
||||
values[i] = f'{value}__{c}'
|
||||
had_duplicates = True
|
||||
used.add(values[i])
|
||||
return had_duplicates
|
||||
|
||||
|
||||
def parse_csv(file, length=None, mode="strict", charset=None):
|
||||
file.seek(0)
|
||||
data = file.read(length)
|
||||
@@ -83,7 +70,6 @@ def parse_csv(file, length=None, mode="strict", charset=None):
|
||||
return None
|
||||
|
||||
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
|
||||
reader._had_duplicates = rename_duplicates(reader.fieldnames)
|
||||
return reader
|
||||
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ class AllowIgnoreQuotaColumn(BooleanColumnMixin, ImportColumn):
|
||||
|
||||
class PriceModeColumn(ImportColumn):
|
||||
identifier = 'price_mode'
|
||||
verbose_name = gettext_lazy('Price effect')
|
||||
verbose_name = gettext_lazy('Price mode')
|
||||
default_value = None
|
||||
initial = 'static:none'
|
||||
|
||||
@@ -147,7 +147,7 @@ class PriceModeColumn(ImportColumn):
|
||||
elif value in reverse:
|
||||
return reverse[value]
|
||||
else:
|
||||
raise ValidationError(_("Could not parse {value} as a price effect, use one of {options}.").format(
|
||||
raise ValidationError(_("Could not parse {value} as a price mode, use one of {options}.").format(
|
||||
value=value, options=', '.join(d.keys())
|
||||
))
|
||||
|
||||
@@ -162,7 +162,7 @@ class ValueColumn(DecimalColumnMixin, ImportColumn):
|
||||
def clean(self, value, previous_values):
|
||||
value = super().clean(value, previous_values)
|
||||
if value and previous_values.get("price_mode") == "none":
|
||||
raise ValidationError(_("It is pointless to set a value without a price effect."))
|
||||
raise ValidationError(_("It is pointless to set a value without a price mode."))
|
||||
return value
|
||||
|
||||
def assign(self, value, obj: Voucher, **kwargs):
|
||||
|
||||
@@ -36,12 +36,10 @@ from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
|
||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||
from .items import (
|
||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
|
||||
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
|
||||
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
|
||||
itempicture_upload_to,
|
||||
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
||||
SubEventItem, SubEventItemVariation, itempicture_upload_to,
|
||||
)
|
||||
from .log import LogEntry
|
||||
from .mail import OutgoingMail
|
||||
from .media import ReusableMedium
|
||||
from .memberships import Membership, MembershipType
|
||||
from .notifications import NotificationSetting
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
import binascii
|
||||
import json
|
||||
import operator
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from functools import reduce
|
||||
|
||||
@@ -45,15 +44,14 @@ from django.contrib.auth.models import (
|
||||
)
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import BadRequest, PermissionDenied
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.db.models import Q
|
||||
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.translation import gettext_lazy as _
|
||||
from django_otp.models import Device
|
||||
from django_scopes import scopes_disabled
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
|
||||
from pretix.base.i18n import language
|
||||
from pretix.helpers.urls import build_absolute_uri
|
||||
@@ -213,28 +211,6 @@ class SuperuserPermissionSet:
|
||||
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):
|
||||
"""
|
||||
This is the user model used by pretix for authentication.
|
||||
@@ -263,11 +239,9 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = []
|
||||
MAX_CONFIRMATION_CODE_ATTEMPTS = 10
|
||||
|
||||
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
|
||||
verbose_name=_('Email'), max_length=190)
|
||||
is_verified = models.BooleanField(default=False, verbose_name=_('Verified email address'))
|
||||
fullname = models.CharField(max_length=255, blank=True, null=True,
|
||||
verbose_name=_('Full name'))
|
||||
is_active = models.BooleanField(default=True,
|
||||
@@ -357,97 +331,27 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
return self.email
|
||||
|
||||
def send_security_notice(self, messages, email=None):
|
||||
from pretix.base.services.mail import mail
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
|
||||
with language(self.locale):
|
||||
msg = '- ' + '\n- '.join(str(m) for m in messages)
|
||||
try:
|
||||
with language(self.locale):
|
||||
msg = '- ' + '\n- '.join(str(m) for m in messages)
|
||||
|
||||
mail(
|
||||
email or self.email,
|
||||
_('Account information changed'),
|
||||
'pretixcontrol/email/security_notice.txt',
|
||||
{
|
||||
'user': self,
|
||||
'messages': msg,
|
||||
'url': build_absolute_uri('control:user.settings'),
|
||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||
},
|
||||
event=None,
|
||||
user=self,
|
||||
locale=self.locale
|
||||
)
|
||||
|
||||
def send_confirmation_code(self, session, reason, email=None, state=None):
|
||||
"""
|
||||
Sends a confirmation code via email to the user. The code is only valid for the action specified by `reason`.
|
||||
The email is either sent to the email address currently on file for the user, or to the one given in the optional `email` parameter.
|
||||
A `state` value can be provided which is bound to this confirmation code, and returned on successfully checking the code.
|
||||
:param session: the user's request session
|
||||
:param reason: the action which should be confirmed using this confirmation code (currently, only `email_change` is allowed)
|
||||
:param email: optional, the email address to send the confirmation code to
|
||||
:param state: optional
|
||||
"""
|
||||
from pretix.base.services.mail import mail
|
||||
|
||||
with language(self.locale):
|
||||
if reason == 'email_change':
|
||||
msg = str(_('to confirm changing your email address from {old_email}\nto {new_email}, use the following code:').format(
|
||||
old_email=self.email, new_email=email,
|
||||
))
|
||||
elif reason == 'email_verify':
|
||||
msg = str(_('to confirm that your email address {email} belongs to your pretix account, use the following code:').format(
|
||||
email=self.email,
|
||||
))
|
||||
else:
|
||||
raise Exception('Invalid confirmation code reason')
|
||||
|
||||
code = "%07d" % secrets.SystemRandom().randint(0, 9999999)
|
||||
session['user_confirmation_code:' + reason] = {
|
||||
'code': code,
|
||||
'state': state,
|
||||
'attempts': 0,
|
||||
}
|
||||
mail(
|
||||
email or self.email,
|
||||
_('pretix confirmation code'),
|
||||
'pretixcontrol/email/confirmation_code.txt',
|
||||
{
|
||||
'user': self,
|
||||
'reason': msg,
|
||||
'code': code,
|
||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||
},
|
||||
event=None,
|
||||
user=self,
|
||||
locale=self.locale
|
||||
)
|
||||
|
||||
def check_confirmation_code(self, session, reason, code):
|
||||
"""
|
||||
Checks a confirmation code entered by the user against the valid code stored in the session.
|
||||
If the code is correct, an optional state bound to the code is returned.
|
||||
If the code is incorrect, PermissionDenied is raised. If the code could not be validated, either because no
|
||||
code for the given reason is stored, or the number of input attempts is exceeded, BadRequest is raised.
|
||||
|
||||
:param session: the user's request session
|
||||
:param reason: the action which should be confirmed using this confirmation code
|
||||
:param code: the code entered by the user
|
||||
:return: optional state bound to this code using the state parameter of send_confirmation_code, None otherwise
|
||||
"""
|
||||
stored = session.get('user_confirmation_code:' + reason)
|
||||
if not stored:
|
||||
raise BadRequest
|
||||
|
||||
if stored['attempts'] > User.MAX_CONFIRMATION_CODE_ATTEMPTS:
|
||||
raise BadRequest
|
||||
|
||||
if int(stored['code']) == int(code):
|
||||
del session['user_confirmation_code:' + reason]
|
||||
return stored['state']
|
||||
else:
|
||||
stored['attempts'] += 1
|
||||
session['user_confirmation_code:' + reason] = stored
|
||||
raise PermissionDenied
|
||||
mail(
|
||||
email or self.email,
|
||||
_('Account information changed'),
|
||||
'pretixcontrol/email/security_notice.txt',
|
||||
{
|
||||
'user': self,
|
||||
'messages': msg,
|
||||
'url': build_absolute_uri('control:user.settings')
|
||||
},
|
||||
event=None,
|
||||
user=self,
|
||||
locale=self.locale
|
||||
)
|
||||
except SendMailException:
|
||||
pass # Already logged
|
||||
|
||||
def send_password_reset(self):
|
||||
from pretix.base.services.mail import mail
|
||||
@@ -455,7 +359,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
mail(
|
||||
self.email, _('Password recovery'), 'pretixcontrol/email/forgot.txt',
|
||||
{
|
||||
'instance': settings.PRETIX_INSTANCE_NAME,
|
||||
'user': self,
|
||||
'url': (build_absolute_uri('control:auth.forgot.recover')
|
||||
+ '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self)))
|
||||
@@ -495,7 +398,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
:return: set
|
||||
"""
|
||||
teams = self._get_teams_for_event(organizer, event)
|
||||
sets = [t.event_permission_set() for t in teams]
|
||||
sets = [t.permission_set() for t in teams]
|
||||
if sets:
|
||||
return set.union(*sets)
|
||||
else:
|
||||
@@ -509,7 +412,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
:return: set
|
||||
"""
|
||||
teams = self._get_teams_for_organizer(organizer)
|
||||
sets = [t.organizer_permission_set() for t in teams]
|
||||
sets = [t.permission_set() for t in teams]
|
||||
if sets:
|
||||
return set.union(*sets)
|
||||
else:
|
||||
@@ -524,7 +427,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
|
||||
:param organizer: The organizer of the event
|
||||
:param event: The event to check
|
||||
:param perm_name: The permission, e.g. ``event.orders:read``
|
||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
||||
:param request: The current request (optional)
|
||||
:param session_key: The current session key (optional)
|
||||
:return: bool
|
||||
@@ -536,8 +439,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
if teams:
|
||||
self._teamcache['e{}'.format(event.pk)] = teams
|
||||
if isinstance(perm_name, (tuple, list)):
|
||||
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_event_permission(perm_name) for team in teams]):
|
||||
return any([any(team.has_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]):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -547,7 +450,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
to the organizer ``organizer``.
|
||||
|
||||
:param organizer: The organizer to check
|
||||
:param perm_name: The permission, e.g. ``organizer.events:create``
|
||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
||||
:param request: The current request (optional). Required to detect staff sessions properly.
|
||||
:return: bool
|
||||
"""
|
||||
@@ -556,8 +459,8 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
teams = self._get_teams_for_organizer(organizer)
|
||||
if teams:
|
||||
if isinstance(perm_name, (tuple, list)):
|
||||
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_organizer_permission(perm_name) for team in teams]):
|
||||
return any([any(team.has_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]):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -588,15 +491,14 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
:return: Iterable of Events
|
||||
"""
|
||||
from .event import Event
|
||||
from .organizer import TeamQuerySet
|
||||
|
||||
if request and self.has_active_staff_session(request.session.session_key):
|
||||
return Event.objects.all()
|
||||
|
||||
if isinstance(permission, (tuple, list)):
|
||||
q = reduce(operator.or_, [TeamQuerySet.event_permission_q(p) for p in permission])
|
||||
q = reduce(operator.or_, [Q(**{p: True}) for p in permission])
|
||||
else:
|
||||
q = TeamQuerySet.event_permission_q(permission)
|
||||
q = Q(**{permission: True})
|
||||
|
||||
return Event.objects.filter(
|
||||
Q(organizer_id__in=self.teams.filter(q, all_events=True).values_list('organizer', flat=True))
|
||||
@@ -629,13 +531,14 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
:return: Iterable of Organizers
|
||||
"""
|
||||
from .event import Organizer
|
||||
from .organizer import TeamQuerySet
|
||||
|
||||
if request and self.has_active_staff_session(request.session.session_key):
|
||||
return Organizer.objects.all()
|
||||
|
||||
kwargs = {permission: True}
|
||||
|
||||
return Organizer.objects.filter(
|
||||
id__in=self.teams.filter(TeamQuerySet.organizer_permission_q(permission)).values_list('organizer', flat=True)
|
||||
id__in=self.teams.filter(**kwargs).values_list('organizer', flat=True)
|
||||
)
|
||||
|
||||
def has_active_staff_session(self, session_key=None):
|
||||
@@ -690,11 +593,6 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
||||
self.session_token = generate_session_token()
|
||||
self.save(update_fields=['session_token'])
|
||||
|
||||
@cached_property
|
||||
@scopes_disabled()
|
||||
def is_in_any_teams(self):
|
||||
return self.teams.exists()
|
||||
|
||||
|
||||
class UserKnownLoginSource(models.Model):
|
||||
user = models.ForeignKey('User', on_delete=models.CASCADE, related_name="known_login_sources")
|
||||
@@ -735,8 +633,6 @@ class U2FDevice(Device):
|
||||
|
||||
@property
|
||||
def webauthndevice(self):
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
|
||||
d = json.loads(self.json_data)
|
||||
return PublicKeyCredentialDescriptor(websafe_decode(d['keyHandle']))
|
||||
|
||||
@@ -766,8 +662,6 @@ class WebAuthnDevice(Device):
|
||||
|
||||
@property
|
||||
def webauthndevice(self):
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
|
||||
return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id))
|
||||
|
||||
@property
|
||||
|
||||
@@ -31,7 +31,6 @@ from django.urls import reverse
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.functional import cached_property
|
||||
|
||||
from pretix.helpers.celery import get_task_priority
|
||||
from pretix.helpers.json import CustomJSONEncoder
|
||||
|
||||
|
||||
@@ -59,37 +58,6 @@ class CachedFile(models.Model):
|
||||
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins
|
||||
session_key = models.TextField(null=True, blank=True) # only allow download in this session
|
||||
|
||||
def session_key_for_request(self, request, salt=None):
|
||||
from ...api.models import OAuthAccessToken, OAuthApplication
|
||||
from .devices import Device
|
||||
from .organizer import TeamAPIToken
|
||||
|
||||
if hasattr(request, "auth") and isinstance(request.auth, OAuthAccessToken):
|
||||
k = f'app:{request.auth.application.pk}'
|
||||
elif hasattr(request, "auth") and isinstance(request.auth, OAuthApplication):
|
||||
k = f'app:{request.auth.pk}'
|
||||
elif hasattr(request, "auth") and isinstance(request.auth, TeamAPIToken):
|
||||
k = f'token:{request.auth.pk}'
|
||||
elif hasattr(request, "auth") and isinstance(request.auth, Device):
|
||||
k = f'device:{request.auth.pk}'
|
||||
elif request.session.session_key:
|
||||
k = request.session.session_key
|
||||
else:
|
||||
raise ValueError("No auth method found to bind to")
|
||||
|
||||
if salt:
|
||||
k = f"{k}!{salt}"
|
||||
return k
|
||||
|
||||
def allowed_for_session(self, request, salt=None):
|
||||
return (
|
||||
not self.session_key or
|
||||
self.session_key_for_request(request, salt) == self.session_key
|
||||
)
|
||||
|
||||
def bind_to_session(self, request, salt=None):
|
||||
self.session_key = self.session_key_for_request(request, salt)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=CachedFile)
|
||||
def cached_file_delete(sender, instance, **kwargs):
|
||||
@@ -112,6 +80,7 @@ class LoggingMixin:
|
||||
from pretix.api.models import OAuthAccessToken, OAuthApplication
|
||||
from pretix.api.webhooks import notify_webhooks
|
||||
|
||||
from ..logentrytype_registry import log_entry_types
|
||||
from ..services.notifications import notify
|
||||
from .devices import Device
|
||||
from .event import Event
|
||||
@@ -130,8 +99,6 @@ class LoggingMixin:
|
||||
organizer_id = self.event.organizer_id
|
||||
elif hasattr(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:
|
||||
user = None
|
||||
@@ -158,22 +125,22 @@ class LoggingMixin:
|
||||
if (sensitivekey in k) and v:
|
||||
data[k] = "********"
|
||||
|
||||
type, meta = log_entry_types.get(action_type=action)
|
||||
if not type:
|
||||
raise TypeError("Undefined log entry type '%s'" % action)
|
||||
|
||||
logentry.data = json.dumps(data, cls=CustomJSONEncoder, sort_keys=True)
|
||||
|
||||
type.validate_data(json.loads(logentry.data))
|
||||
elif data:
|
||||
raise TypeError("You should only supply dictionaries as log data.")
|
||||
if save:
|
||||
logentry.save()
|
||||
|
||||
if logentry.notification_type:
|
||||
notify.apply_async(
|
||||
args=(logentry.pk,),
|
||||
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||
)
|
||||
notify.apply_async(args=(logentry.pk,))
|
||||
if logentry.webhook_type:
|
||||
notify_webhooks.apply_async(
|
||||
args=(logentry.pk,),
|
||||
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||
)
|
||||
notify_webhooks.apply_async(args=(logentry.pk,))
|
||||
|
||||
return logentry
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ from i18nfield.fields import I18nCharField
|
||||
from phonenumber_field.modelfields import PhoneNumberField
|
||||
|
||||
from pretix.base.banlist import banned
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models.base import LoggedModel
|
||||
from pretix.base.models.fields import MultiStringField
|
||||
from pretix.base.models.giftcards import GiftCardTransaction
|
||||
@@ -165,28 +164,6 @@ class Customer(LoggedModel):
|
||||
self.attendee_profiles.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()
|
||||
def assign_identifier(self):
|
||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789')
|
||||
@@ -316,7 +293,6 @@ class Customer(LoggedModel):
|
||||
locale=self.locale,
|
||||
customer=self,
|
||||
organizer=self.organizer,
|
||||
sensitive=True,
|
||||
)
|
||||
|
||||
def usable_gift_cards(self, used_cards=[]):
|
||||
@@ -373,7 +349,7 @@ class AttendeeProfile(models.Model):
|
||||
def state_name(self):
|
||||
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||
if sd:
|
||||
return _(sd.name)
|
||||
return sd.name
|
||||
return self.state
|
||||
|
||||
@property
|
||||
|
||||
@@ -86,7 +86,7 @@ class OrderSyncQueue(models.Model):
|
||||
|
||||
def set_sync_error(self, failure_mode, messages, full_message):
|
||||
logger.exception(
|
||||
f"Could not sync order {self.order.code} to {self.sync_provider} ({failure_mode})"
|
||||
f"Could not sync order {self.order.code} to {type(self).__name__} ({failure_mode})"
|
||||
)
|
||||
self.order.log_action(f"pretix.event.order.data_sync.failed.{failure_mode}", {
|
||||
"provider": self.sync_provider,
|
||||
|
||||
@@ -29,9 +29,6 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import ScopedManager, scopes_disabled
|
||||
|
||||
from pretix.base.models import LoggedModel
|
||||
from pretix.base.permissions import (
|
||||
AnyPermissionOf, assert_valid_event_permission,
|
||||
)
|
||||
|
||||
|
||||
@scopes_disabled()
|
||||
@@ -192,19 +189,13 @@ class Device(LoggedModel):
|
||||
kwargs['update_fields'] = {'device_id'}.union(kwargs['update_fields'])
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def _event_permission_set(self) -> set:
|
||||
def permission_set(self) -> set:
|
||||
return {
|
||||
'event.orders:read',
|
||||
'event.orders:write',
|
||||
'event.vouchers:read',
|
||||
}
|
||||
|
||||
def _organizer_permission_set(self) -> set:
|
||||
return {
|
||||
'organizer.giftcards:read',
|
||||
'organizer.giftcards:write',
|
||||
'organizer.reusablemedia:read',
|
||||
'organizer.reusablemedia:write',
|
||||
'can_view_orders',
|
||||
'can_change_orders',
|
||||
'can_view_vouchers',
|
||||
'can_manage_gift_cards',
|
||||
'can_manage_reusable_media',
|
||||
}
|
||||
|
||||
def get_event_permission_set(self, organizer, event) -> set:
|
||||
@@ -218,7 +209,7 @@ class Device(LoggedModel):
|
||||
has_event_access = (self.all_events and organizer == self.organizer) or (
|
||||
event in self.limit_events.all()
|
||||
)
|
||||
return self._event_permission_set() if has_event_access else set()
|
||||
return self.permission_set() if has_event_access else set()
|
||||
|
||||
def get_organizer_permission_set(self, organizer) -> set:
|
||||
"""
|
||||
@@ -227,7 +218,7 @@ class Device(LoggedModel):
|
||||
:param organizer: The organizer of the event
|
||||
:return: set of permissions
|
||||
"""
|
||||
return self._organizer_permission_set() if self.organizer == organizer else set()
|
||||
return self.permission_set() if self.organizer == organizer else set()
|
||||
|
||||
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
|
||||
"""
|
||||
@@ -236,7 +227,7 @@ class Device(LoggedModel):
|
||||
|
||||
:param organizer: The organizer of the event
|
||||
:param event: The event to check
|
||||
:param perm_name: The permission, e.g. ``event.orders:read``
|
||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
||||
:param request: This parameter is ignored and only defined for compatibility reasons.
|
||||
:return: bool
|
||||
"""
|
||||
@@ -244,8 +235,8 @@ class Device(LoggedModel):
|
||||
event in self.limit_events.all()
|
||||
)
|
||||
if isinstance(perm_name, (tuple, list)):
|
||||
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._event_permission_set())
|
||||
return has_event_access and any(p in self.permission_set() for p in perm_name)
|
||||
return has_event_access and (not perm_name or perm_name in self.permission_set())
|
||||
|
||||
def has_organizer_permission(self, organizer, perm_name=None, request=None):
|
||||
"""
|
||||
@@ -253,13 +244,13 @@ class Device(LoggedModel):
|
||||
to the organizer ``organizer``.
|
||||
|
||||
:param organizer: The organizer to check
|
||||
:param perm_name: The permission, e.g. ``organizer.events:create``
|
||||
:param perm_name: The permission, e.g. ``can_change_teams``
|
||||
:param request: This parameter is ignored and only defined for compatibility reasons.
|
||||
:return: bool
|
||||
"""
|
||||
if isinstance(perm_name, (tuple, list)):
|
||||
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._organizer_permission_set())
|
||||
return organizer == self.organizer and any(p in self.permission_set() for p in perm_name)
|
||||
return organizer == self.organizer and (not perm_name or perm_name in self.permission_set())
|
||||
|
||||
def get_events_with_any_permission(self):
|
||||
"""
|
||||
@@ -279,10 +270,9 @@ class Device(LoggedModel):
|
||||
:param request: Ignored, for compatibility with User model
|
||||
:return: Iterable of Events
|
||||
"""
|
||||
assert_valid_event_permission(permission)
|
||||
if (
|
||||
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._event_permission_set()):
|
||||
isinstance(permission, (list, tuple)) and any(p in self.permission_set() for p in permission)
|
||||
) or (isinstance(permission, str) and permission in self.permission_set()):
|
||||
return self.get_events_with_any_permission()
|
||||
else:
|
||||
return self.organizer.events.none()
|
||||
|
||||
@@ -37,7 +37,7 @@ from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models.base import LoggedModel
|
||||
|
||||
PositionInfo = namedtuple('PositionInfo',
|
||||
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
|
||||
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'is_addon_to',
|
||||
'voucher_discount'])
|
||||
|
||||
|
||||
@@ -279,42 +279,6 @@ class Discount(LoggedModel):
|
||||
for idx in condition_idx_group:
|
||||
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
|
||||
|
||||
def _addon_idx(self, positions, idx):
|
||||
"""
|
||||
If we have the following cart:
|
||||
|
||||
- Main product
|
||||
- 10x Addon product 5€
|
||||
- Main product
|
||||
- 10x Addon product 5€
|
||||
|
||||
And we have a discount rule that grants "every 10th product is free", people tend to expect
|
||||
|
||||
- Main product
|
||||
- 9x Addon product 5€
|
||||
- 1x Addon product free
|
||||
- Main product
|
||||
- 9x Addon product 5€
|
||||
- 1x Addon product free
|
||||
|
||||
And get confused if they get
|
||||
|
||||
- Main product
|
||||
- 8x Addon product 5€
|
||||
- 2x Addon product free
|
||||
- Main product
|
||||
- 10x Addon product 5€
|
||||
|
||||
Even if the result is the same. Therefore, we sort positions in the cart not only by price, but also by their
|
||||
relative index within their addon group. This is only a heuristic and there are *still* scenarios where the more
|
||||
unexpected version happens, e.g. if prices are different. We need to accept this as long as discounts work on
|
||||
cart level and not on addon-group level, but this simple sorting reduces the number of support issues by making
|
||||
the weird case less likely.
|
||||
"""
|
||||
if not positions[idx].addon_to:
|
||||
return 0
|
||||
return len([1 for i, p in positions.items() if i < idx and p.addon_to == positions[idx].addon_to])
|
||||
|
||||
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
|
||||
if len(condition_idx_group) < self.condition_min_count:
|
||||
return
|
||||
@@ -324,8 +288,8 @@ class Discount(LoggedModel):
|
||||
|
||||
if self.benefit_only_apply_to_cheapest_n_matches:
|
||||
# sort by line_price
|
||||
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
|
||||
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
|
||||
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
|
||||
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
|
||||
|
||||
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
||||
# want to match multiples of 3
|
||||
@@ -470,7 +434,7 @@ class Discount(LoggedModel):
|
||||
for idx, p in positions.items():
|
||||
subevent_to_idx[p.subevent_id].append(idx)
|
||||
for v in subevent_to_idx.values():
|
||||
v.sort(key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
|
||||
v.sort(key=lambda idx: positions[idx].line_price_gross)
|
||||
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
|
||||
|
||||
# Build groups of exactly condition_min_count distinct subevents
|
||||
@@ -494,7 +458,7 @@ class Discount(LoggedModel):
|
||||
|
||||
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
|
||||
# and 2 from the end" scheme to optimize price distribution among groups
|
||||
candidates = sorted(candidates, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
|
||||
candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross)
|
||||
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
|
||||
candidate = candidates[0]
|
||||
else:
|
||||
|
||||
@@ -843,38 +843,11 @@ class Event(EventMixin, LoggedModel):
|
||||
time(hour=23, minute=59, second=59)
|
||||
), 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):
|
||||
from ..signals import event_copy_data
|
||||
from . import (
|
||||
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
|
||||
ItemProgramTime, ItemVariationMetaValue, Question, Quota,
|
||||
ItemVariationMetaValue, Question, Quota,
|
||||
)
|
||||
|
||||
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
|
||||
@@ -1017,12 +990,6 @@ class Event(EventMixin, LoggedModel):
|
||||
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
|
||||
ia.save(force_insert=True)
|
||||
|
||||
if not self.has_subevents and not other.has_subevents:
|
||||
for ipt in ItemProgramTime.objects.filter(item__event=other).prefetch_related('item'):
|
||||
ipt.pk = None
|
||||
ipt.item = item_map[ipt.item.pk]
|
||||
ipt.save(force_insert=True)
|
||||
|
||||
quota_map = {}
|
||||
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
|
||||
quota_map[q.pk] = q
|
||||
@@ -1413,13 +1380,14 @@ class Event(EventMixin, LoggedModel):
|
||||
from .auth import User
|
||||
|
||||
if permission:
|
||||
qs = Team.objects.with_event_permission(permission)
|
||||
kwargs = {permission: True}
|
||||
else:
|
||||
qs = Team.objects.all()
|
||||
kwargs = {}
|
||||
|
||||
team_with_perm = qs.filter(
|
||||
team_with_perm = Team.objects.filter(
|
||||
members__pk=OuterRef('pk'),
|
||||
organizer=self.organizer,
|
||||
**kwargs
|
||||
).filter(
|
||||
Q(all_events=True) | Q(limit_events__pk=self.pk)
|
||||
)
|
||||
|
||||
@@ -142,7 +142,6 @@ class Invoice(models.Model):
|
||||
invoice_from_name = models.CharField(max_length=190, null=True)
|
||||
invoice_from_zipcode = models.CharField(max_length=190, null=True)
|
||||
invoice_from_city = models.CharField(max_length=190, null=True)
|
||||
invoice_from_state = models.CharField(max_length=190, null=True)
|
||||
invoice_from_country = FastCountryField(null=True)
|
||||
invoice_from_tax_id = models.CharField(max_length=190, null=True)
|
||||
invoice_from_vat_id = models.CharField(max_length=190, null=True)
|
||||
@@ -219,23 +218,10 @@ class Invoice(models.Model):
|
||||
taxidrow = "ABN: %s" % self.invoice_from_tax_id
|
||||
else:
|
||||
taxidrow = pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id
|
||||
|
||||
state_name = ""
|
||||
if self.invoice_from_state:
|
||||
state_name = self.invoice_from_state
|
||||
if str(self.invoice_from_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_from_country)][1] == 'long':
|
||||
try:
|
||||
state_name = pycountry.subdivisions.get(
|
||||
code='{}-{}'.format(self.invoice_from_country, self.invoice_from_state)
|
||||
).name
|
||||
except:
|
||||
pass
|
||||
|
||||
parts = [
|
||||
self.invoice_from_name,
|
||||
self.invoice_from,
|
||||
((self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or "") + " " + (state_name or "")).strip(),
|
||||
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
||||
self.invoice_from_country.name if self.invoice_from_country else "",
|
||||
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
|
||||
taxidrow,
|
||||
@@ -244,22 +230,10 @@ class Invoice(models.Model):
|
||||
|
||||
@property
|
||||
def address_invoice_from(self):
|
||||
state_name = ""
|
||||
if self.invoice_from_state:
|
||||
state_name = self.invoice_from_state
|
||||
if str(self.invoice_from_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_from_country)][1] == 'long':
|
||||
try:
|
||||
state_name = pycountry.subdivisions.get(
|
||||
code='{}-{}'.format(self.invoice_from_country, self.invoice_from_state)
|
||||
).name
|
||||
except:
|
||||
pass
|
||||
|
||||
parts = [
|
||||
self.invoice_from_name,
|
||||
self.invoice_from,
|
||||
" ".join(s for s in [self.invoice_from_zipcode, self.invoice_from_city, state_name] if s),
|
||||
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
||||
self.invoice_from_country.name if self.invoice_from_country else "",
|
||||
]
|
||||
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user