mirror of
https://github.com/pretix/pretix.git
synced 2026-08-08 10:27:49 +00:00
Compare commits
494
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd3821983f | ||
|
|
119cc50897 | ||
|
|
61f9cf13b4 | ||
|
|
f24429a7c5 | ||
|
|
29ed07ccce | ||
|
|
dd0cd7ab0b | ||
|
|
d7df906995 | ||
|
|
839f4b4657 | ||
|
|
74f7e1f61c | ||
|
|
47919afab0 | ||
|
|
819daa99f7 | ||
|
|
8512e79d68 | ||
|
|
52672ae25b | ||
|
|
ad752dc617 | ||
|
|
43c6c33bd8 | ||
|
|
88c9f8c047 | ||
|
|
2d2663f15f | ||
|
|
ae6014708b | ||
|
|
d1686df07c | ||
|
|
4d60d7bfbc | ||
|
|
c0b93fedc5 | ||
|
|
2eaa6c3069 | ||
|
|
db982c9ef4 | ||
|
|
f9f6ee94ae | ||
|
|
99c257d392 | ||
|
|
e2cb83ce28 | ||
|
|
d7b7d3cc5f | ||
|
|
721ac8a500 | ||
|
|
5796cfe03f | ||
|
|
63f1c4f793 | ||
|
|
47f409171d | ||
|
|
27fcdff17f | ||
|
|
a38a96f186 | ||
|
|
700ea77e39 | ||
|
|
06104ff483 | ||
|
|
fb5697a27b | ||
|
|
9a9ad6d6d1 | ||
|
|
a05845790e | ||
|
|
a0830dd033 | ||
|
|
dba2529f6b | ||
|
|
9c0ea8f179 | ||
|
|
1f0501a647 | ||
|
|
d2e6446238 | ||
|
|
d519fcfe0d | ||
|
|
c7226303be | ||
|
|
9406e941bc | ||
|
|
919e598f8a | ||
|
|
672692d578 | ||
|
|
9429dc7e91 | ||
|
|
5c8dbd99dd | ||
|
|
cfbb8310f0 | ||
|
|
d37d9a861c | ||
|
|
43a9cf29b2 | ||
|
|
047ad438a7 | ||
|
|
ec8d921fcf | ||
|
|
39e6ef4365 | ||
|
|
4d8b032591 | ||
|
|
e8193e408b | ||
|
|
6723d8c07c | ||
|
|
c30134f36c | ||
|
|
0617fc04ec | ||
|
|
e90b54280a | ||
|
|
39ea2889ba | ||
|
|
76230bd37b | ||
|
|
86b8c5e90f | ||
|
|
7a2027c61b | ||
|
|
55de5ef45b | ||
|
|
39e6954828 | ||
|
|
7849d98672 | ||
|
|
c325164059 | ||
|
|
8fc19c62dd | ||
|
|
20feaebbbd | ||
|
|
4587a9e630 | ||
|
|
9fe0e6eb67 | ||
|
|
bab7e54f35 | ||
|
|
352efa40e7 | ||
|
|
50da7d4261 | ||
|
|
53cc59d41d | ||
|
|
9879e99c59 | ||
|
|
dc49d5bcf7 | ||
|
|
d4460045b4 | ||
|
|
cead2898a7 | ||
|
|
6a594a6166 | ||
|
|
0e7bb43a5a | ||
|
|
3a3ae6e66c | ||
|
|
48aecb80f6 | ||
|
|
d58a6e2503 | ||
|
|
8c4e0bdb82 | ||
|
|
c40e34af57 | ||
|
|
1492ec51bf | ||
|
|
7ca2a0c910 | ||
|
|
0e41cb53a2 | ||
|
|
1d579d12c5 | ||
|
|
f3fa323351 | ||
|
|
67434bbe08 | ||
|
|
1f38d48ab7 | ||
|
|
0b99ab74a1 | ||
|
|
9508e13ea8 | ||
|
|
7efac71d62 | ||
|
|
26fdcc2872 | ||
|
|
0e5e2193ed | ||
|
|
1e2900ad2a | ||
|
|
4f521022f5 | ||
|
|
5ce28ce258 | ||
|
|
e51e765fcd | ||
|
|
bb8301fbac | ||
|
|
5023081d6a | ||
|
|
f2bf8e01e1 | ||
|
|
65645a7e93 | ||
|
|
296b17fb7b | ||
|
|
fdc6de2a3d | ||
|
|
5c2c9c94c7 | ||
|
|
277e63cce7 | ||
|
|
b0a031de93 | ||
|
|
6d770c66d6 | ||
|
|
d73155b69a | ||
|
|
839deabac3 | ||
|
|
59c702588a | ||
|
|
e1aaa422c9 | ||
|
|
27ae5ae018 | ||
|
|
56c528795c | ||
|
|
6e70562839 | ||
|
|
f7eff231ff | ||
|
|
52f78157f3 | ||
|
|
e9a2633b01 | ||
|
|
40932685fe | ||
|
|
5e66f21193 | ||
|
|
48683ce11d | ||
|
|
8fc719b483 | ||
|
|
c38859478c | ||
|
|
210115acef | ||
|
|
4db2384e93 | ||
|
|
803d0b1570 | ||
|
|
65fe7b3396 | ||
|
|
c94f7c35da | ||
|
|
c1b9e0df42 | ||
|
|
47cbd74ab5 | ||
|
|
32369445d0 | ||
|
|
bb0a6a8001 | ||
|
|
49aade373c | ||
|
|
9dcd142112 | ||
|
|
68a64f577c | ||
|
|
17eb6063d1 | ||
|
|
fd5dd989f7 | ||
|
|
7fd1d91eb8 | ||
|
|
ef500c8924 | ||
|
|
01a3546783 | ||
|
|
3e0ff1e6ed | ||
|
|
4edc7d95c6 | ||
|
|
7fb9e9a33d | ||
|
|
8058461f10 | ||
|
|
c84bd4046d | ||
|
|
5e97f668a5 | ||
|
|
5c8e785a6f | ||
|
|
8e61ac6071 | ||
|
|
c3fd3a0838 | ||
|
|
0d6e1e2271 | ||
|
|
0af011eed4 | ||
|
|
a0dae48cec | ||
|
|
a53795ea88 | ||
|
|
f1c0f24e25 | ||
|
|
980f4712a7 | ||
|
|
bc8a8d8851 | ||
|
|
10ec4d6c29 | ||
|
|
584345cb99 | ||
|
|
88545bcd05 | ||
|
|
f034f4cde4 | ||
|
|
fc6475b0bc | ||
|
|
aecc87ccdb | ||
|
|
059179aecb | ||
|
|
fd72e18a7f | ||
|
|
baac963fa8 | ||
|
|
461ab2472f | ||
|
|
29d98f4182 | ||
|
|
4f989cbe8a | ||
|
|
23559e0711 | ||
|
|
8787f79274 | ||
|
|
a7072d3b5b | ||
|
|
ff47ee7d68 | ||
|
|
2c321f401d | ||
|
|
180b92c87f | ||
|
|
c99751b319 | ||
|
|
1f4205a9d9 | ||
|
|
9e694982cf | ||
|
|
ea5dbb05c2 | ||
|
|
e2ede76468 | ||
|
|
498f5760af | ||
|
|
3b9ae7e560 | ||
|
|
c08e3c054a | ||
|
|
815e31d9a0 | ||
|
|
ed618f2f32 | ||
|
|
a900e11ce0 | ||
|
|
112d5da792 | ||
|
|
ceb2e13d27 | ||
|
|
b5ad372bb2 | ||
|
|
cdea82d206 | ||
|
|
de9045afcf | ||
|
|
6b65cb4e33 | ||
|
|
c4792800f0 | ||
|
|
ca23f7ebc2 | ||
|
|
0259899e00 | ||
|
|
efb94265b2 | ||
|
|
2aa27f56f1 | ||
|
|
4f3d90fc50 | ||
|
|
9cf66de437 | ||
|
|
9f4cbabd30 | ||
|
|
0fc2d6134f | ||
|
|
1e0e16642d | ||
|
|
a58403559e | ||
|
|
dfd53f0ea2 | ||
|
|
06250ef55e | ||
|
|
ab3104fe65 | ||
|
|
bb6e424cde | ||
|
|
c2623dba60 | ||
|
|
d8f7465b03 | ||
|
|
ac0546499b | ||
|
|
ebbb532478 | ||
|
|
94dad4d0d2 | ||
|
|
a06cd687ba | ||
|
|
fd9f3ea6ed | ||
|
|
608622e3f3 | ||
|
|
4d94294e5a | ||
|
|
4dbdadabb5 | ||
|
|
d494c61cba | ||
|
|
55a7dfbff3 | ||
|
|
b8c271cf9c | ||
|
|
5af7e1b6d6 | ||
|
|
9222ce0ecd | ||
|
|
8afb0e43e0 | ||
|
|
c65fecf45e | ||
|
|
1c684d62d4 | ||
|
|
48809dc477 | ||
|
|
71df116079 | ||
|
|
ad64f6e88b | ||
|
|
891ba9d99c | ||
|
|
5cd1476a07 | ||
|
|
cb393a0b31 | ||
|
|
af59a89ecb | ||
|
|
1eb0008da9 | ||
|
|
d6489c6dd8 | ||
|
|
abe6acc9d8 | ||
|
|
2dcbb791f0 | ||
|
|
2efc40e20b | ||
|
|
0693681473 | ||
|
|
3aabc8a163 | ||
|
|
062f8fa409 | ||
|
|
106339c928 | ||
|
|
222ea08dd0 | ||
|
|
62bc16f963 | ||
|
|
3332fc818a | ||
|
|
d87dbaf9e5 | ||
|
|
67580c4ca5 | ||
|
|
c5b32484b1 | ||
|
|
b5560509ad | ||
|
|
c78365ce43 | ||
|
|
8cc12fa1c7 | ||
|
|
59c09e27fd | ||
|
|
4d68d24eca | ||
|
|
cc5693017e | ||
|
|
6a07b7d5d1 | ||
|
|
26dc3486a0 | ||
|
|
de60183456 | ||
|
|
520bb9e378 | ||
|
|
97e344e81a | ||
|
|
a3f5f33ed5 | ||
|
|
5a123bf88f | ||
|
|
64c52a5e36 | ||
|
|
a60341afe9 | ||
|
|
308e14bab3 | ||
|
|
aa5f635932 | ||
|
|
66a9902eb4 | ||
|
|
79a58fe104 | ||
|
|
bb5a9bdbf1 | ||
|
|
449b960438 | ||
|
|
a3f247117c | ||
|
|
e279ecb423 | ||
|
|
ca6a650398 | ||
|
|
696e5602ac | ||
|
|
4c7987cef6 | ||
|
|
37c65030f8 | ||
|
|
0d1673136f | ||
|
|
32d8dce6aa | ||
|
|
8a2ecb4e97 | ||
|
|
91348e3b00 | ||
|
|
459f4f84c7 | ||
|
|
31a1385946 | ||
|
|
adfd0bfcfd | ||
|
|
ef7433dbcd | ||
|
|
ebbd18bb26 | ||
|
|
fc4ce102b6 | ||
|
|
8854ae3187 | ||
|
|
c5a91ef479 | ||
|
|
aa9c478c30 | ||
|
|
847dc0f992 | ||
|
|
daaae85865 | ||
|
|
06770bcef5 | ||
|
|
dc6eae4708 | ||
|
|
bf8bb78d2a | ||
|
|
091be266fc | ||
|
|
dde655f7d6 | ||
|
|
409e64d5f2 | ||
|
|
5d67a4fa33 | ||
|
|
4eb2c50d95 | ||
|
|
a7e85a157d | ||
|
|
4c3584c788 | ||
|
|
e466c4fb72 | ||
|
|
d0d7670ca5 | ||
|
|
a17a098b15 | ||
|
|
40516ab8e0 | ||
|
|
3ca343fabc | ||
|
|
7304b7f24b | ||
|
|
abaf968103 | ||
|
|
86e2f5a155 | ||
|
|
4c64af02c1 | ||
|
|
11df4398e1 | ||
|
|
2e89fc0a94 | ||
|
|
510c4850a5 | ||
|
|
b13368d614 | ||
|
|
b5cc8b368b | ||
|
|
87c30d0acb | ||
|
|
ffed8b29b1 | ||
|
|
53fbb64225 | ||
|
|
e10ec4074b | ||
|
|
7f2dc77aca | ||
|
|
199a3bf1e7 | ||
|
|
904aa807a3 | ||
|
|
0e41353a0e | ||
|
|
82ca50c7ff | ||
|
|
3437b64947 | ||
|
|
b895d9bbca | ||
|
|
f214edaf34 | ||
|
|
165a47b593 | ||
|
|
e06f281f1e | ||
|
|
203c7e660d | ||
|
|
8c360b8754 | ||
|
|
90b6511d11 | ||
|
|
bb356257cb | ||
|
|
e1950e408e | ||
|
|
99d5722ce1 | ||
|
|
324eeb8d40 | ||
|
|
449e8dc905 | ||
|
|
c491c8232e | ||
|
|
aa02cc7968 | ||
|
|
cfa13d6b9d | ||
|
|
af4eabc800 | ||
|
|
e1f5678d7c | ||
|
|
609b7c82ee | ||
|
|
8d66e1e732 | ||
|
|
c925f094f2 | ||
|
|
5caaa8586d | ||
|
|
1b1cf1557d | ||
|
|
35d8a7eec5 | ||
|
|
d428c3e1a4 | ||
|
|
63850f3139 | ||
|
|
04c8270d43 | ||
|
|
74a960e239 | ||
|
|
5a1bcae085 | ||
|
|
051eb78312 | ||
|
|
15808e55fd | ||
|
|
c886c0b415 | ||
|
|
47472447eb | ||
|
|
1a40215e91 | ||
|
|
d3fde85c39 | ||
|
|
40bd66cb86 | ||
|
|
bdd94b1f8a | ||
|
|
1c907f6a6f | ||
|
|
39e3ed9c25 | ||
|
|
4b5711253e | ||
|
|
bd554c7c29 | ||
|
|
2261951b15 | ||
|
|
0f82e1cae6 | ||
|
|
b0760157ce | ||
|
|
de2dec9089 | ||
|
|
446c8e622b | ||
|
|
703be2ebb8 | ||
|
|
a56fbc896c | ||
|
|
7b6f5df985 | ||
|
|
d2087907d5 | ||
|
|
cbc2e611a2 | ||
|
|
02126a48fe | ||
|
|
be9af94131 | ||
|
|
dbe1944996 | ||
|
|
6181bdc2e9 | ||
|
|
fe40d1c491 | ||
|
|
9f263fbe4f | ||
|
|
fdd34f387a | ||
|
|
bfab523d83 | ||
|
|
8f69cb166d | ||
|
|
2fc7c23960 | ||
|
|
b0911c9e42 | ||
|
|
a5aa1030e5 | ||
|
|
681e682e73 | ||
|
|
db7518735a | ||
|
|
9c80f3038a | ||
|
|
4dc5bbae06 | ||
|
|
e997ca4242 | ||
|
|
278b4301e5 | ||
|
|
b648f9c46c | ||
|
|
9ce16b60d2 | ||
|
|
f4a7604632 | ||
|
|
7cebb3e93f | ||
|
|
c82726e13d | ||
|
|
2fcfc336d0 | ||
|
|
39ff84b2e2 | ||
|
|
44804f05f3 | ||
|
|
5e828ab8af | ||
|
|
313f4f326b | ||
|
|
ed43bf327e | ||
|
|
30aabc6253 | ||
|
|
5eade62121 | ||
|
|
2669afa1f8 | ||
|
|
d42c6f9b72 | ||
|
|
34f064ca33 | ||
|
|
ad8d0a270c | ||
|
|
363fcc3b56 | ||
|
|
9521ec2c52 | ||
|
|
688d341baf | ||
|
|
cdd4001378 | ||
|
|
d8d56ff020 | ||
|
|
44b3647689 | ||
|
|
818bb76e89 | ||
|
|
8c01cad06b | ||
|
|
86ca7c4440 | ||
|
|
d7b6856322 | ||
|
|
e2d9cbb41d | ||
|
|
57bc7563da | ||
|
|
7741e9f936 | ||
|
|
2f08bb465a | ||
|
|
4fb048e3a9 | ||
|
|
82af3012bd | ||
|
|
11425f21e6 | ||
|
|
55f35a998b | ||
|
|
53cfce2ce7 | ||
|
|
68ce335034 | ||
|
|
6ce5c1a26a | ||
|
|
ae4540acd7 | ||
|
|
a814d31c9b | ||
|
|
ef9863518b | ||
|
|
eb740204d4 | ||
|
|
5583298322 | ||
|
|
74b06435a0 | ||
|
|
a26b0c5512 | ||
|
|
095e07b3f1 | ||
|
|
b2eb1b6231 | ||
|
|
9d838f1d9c | ||
|
|
cbf6bd29b0 | ||
|
|
0e84df9af2 | ||
|
|
7feacc8a1a | ||
|
|
5ada22dd15 | ||
|
|
6d56011695 | ||
|
|
da167eacd5 | ||
|
|
5df0c55daa | ||
|
|
b01e798b48 | ||
|
|
0256ee76db | ||
|
|
e99eecb8be | ||
|
|
d1ae579a6f | ||
|
|
90d3f50eba | ||
|
|
c1b6d660a4 | ||
|
|
0b88b63597 | ||
|
|
0cc6439748 | ||
|
|
ad53c48d0f | ||
|
|
59a5c11ef6 | ||
|
|
1cb2d443f9 | ||
|
|
a0dbf6c5db | ||
|
|
fd9d03786b | ||
|
|
7041d40972 | ||
|
|
0b46982e6d | ||
|
|
29906c6288 | ||
|
|
3380bd3e82 | ||
|
|
6ae8e7cbb6 | ||
|
|
23c2d9266e | ||
|
|
ba155faaa3 | ||
|
|
fd177fa89f | ||
|
|
0b051c1400 | ||
|
|
af8d0f0b65 | ||
|
|
1b7bba195c | ||
|
|
f056f77dc0 | ||
|
|
ee4e7f618f | ||
|
|
cd450f1780 | ||
|
|
fc876978b2 | ||
|
|
d8efaa47f7 | ||
|
|
f0c3514588 | ||
|
|
e1ad4d9dba | ||
|
|
3ab587883e | ||
|
|
b02e1a1515 | ||
|
|
41780add40 | ||
|
|
b07a61e4f1 | ||
|
|
dead2a9bed | ||
|
|
94389c3913 | ||
|
|
3e972eddbf | ||
|
|
cdeb1e86bd | ||
|
|
9a69b76880 | ||
|
|
7d5df2b69e | ||
|
|
d203eee5ab |
@@ -26,10 +26,10 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python 3.11
|
- name: Set up Python 3.13
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.11
|
python-version: 3.13
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/pip
|
path: ~/.cache/pip
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ jobs:
|
|||||||
name: Tests
|
name: Tests
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.9", "3.10", "3.11"]
|
python-version: ["3.10", "3.11", "3.13"]
|
||||||
database: [sqlite, postgres]
|
database: [sqlite, postgres]
|
||||||
exclude:
|
exclude:
|
||||||
- database: sqlite
|
|
||||||
python-version: "3.9"
|
|
||||||
- database: sqlite
|
- database: sqlite
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
|
- database: sqlite
|
||||||
|
python-version: "3.11"
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
|
|||||||
Vendored
+79
-95
@@ -6,10 +6,14 @@
|
|||||||
{%- else %}
|
{%- else %}
|
||||||
{%- set titlesuffix = "" %}
|
{%- set titlesuffix = "" %}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
|
{%- set lang_attr = 'en' if language == None else (language | replace('_', '-')) %}
|
||||||
|
|
||||||
|
{# Build sphinx_version_info tuple from sphinx_version string in pure Jinja #}
|
||||||
|
{%- set (_ver_major, _ver_minor) = (sphinx_version.split('.') | list)[:2] | map('int') -%}
|
||||||
|
{%- set sphinx_version_info = (_ver_major, _ver_minor, -1) -%}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<!--[if IE 8]><html class="no-js lt-ie9" lang="en" > <![endif]-->
|
<html class="writer-html5" lang="{{ lang_attr }}"{% if sphinx_version_info >= (7, 2) %} data-content_root="{{ content_root }}"{% endif %}>
|
||||||
<!--[if gt IE 8]><!--> <html class="no-js" lang="en" > <!--<![endif]-->
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
{{ metatags }}
|
{{ metatags }}
|
||||||
@@ -18,59 +22,50 @@
|
|||||||
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{#- CSS #}
|
||||||
{#- CSS #}
|
{%- for css_file in css_files %}
|
||||||
{%- for css in css_files %}
|
{%- if css_file|attr("filename") %}
|
||||||
{%- if css|attr("rel") %}
|
{{ css_tag(css_file) }}
|
||||||
<link rel="{{ css.rel }}" href="{{ pathto(css.filename, 1) }}" type="text/css"{% if css.title is not none %} title="{{ css.title }}"{% endif %} />
|
|
||||||
{%- else %}
|
{%- else %}
|
||||||
<link rel="stylesheet" href="{{ pathto(css, 1) }}" type="text/css" />
|
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
|
|
||||||
{%- for cssfile in extra_css_files %}
|
{#- FAVICON #}
|
||||||
<link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" />
|
{%- if favicon_url %}
|
||||||
{%- endfor -%}
|
<link rel="shortcut icon" href="{{ favicon_url }}"/>
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
{#- FAVICON
|
{#- CANONICAL URL (deprecated) #}
|
||||||
favicon_url is the only context var necessary since Sphinx 4.
|
{%- if theme_canonical_url and not pageurl %}
|
||||||
In Sphinx<4, we use favicon but need to prepend path info.
|
|
||||||
#}
|
|
||||||
{%- set _favicon_url = favicon_url | default(pathto('_static/' + (favicon or ""), 1)) %}
|
|
||||||
{%- if favicon_url or favicon %}
|
|
||||||
<link rel="shortcut icon" href="{{ _favicon_url }}"/>
|
|
||||||
{%- endif %}
|
|
||||||
|
|
||||||
{#- CANONICAL URL (deprecated) #}
|
|
||||||
{%- if theme_canonical_url and not pageurl %}
|
|
||||||
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
|
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|
||||||
{#- CANONICAL URL #}
|
{#- CANONICAL URL #}
|
||||||
{%- if pageurl %}
|
{%- if pageurl %}
|
||||||
<link rel="canonical" href="{{ pageurl|e }}" />
|
<link rel="canonical" href="{{ pageurl|e }}" />
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|
||||||
{#- JAVASCRIPTS #}
|
{#- JAVASCRIPTS #}
|
||||||
{%- block scripts %}
|
{%- block scripts %}
|
||||||
<!--[if lt IE 9]>
|
{%- if not embedded %}
|
||||||
<script src="{{ pathto('_static/js/html5shiv.min.js', 1) }}"></script>
|
{%- for scriptfile in script_files %}
|
||||||
<![endif]-->
|
{{ js_tag(scriptfile) }}
|
||||||
{%- if not embedded %}
|
{%- endfor %}
|
||||||
{# XXX Sphinx 1.8.0 made this an external js-file, quick fix until we refactor the template to inherert more blocks directly from sphinx #}
|
|
||||||
{%- for scriptfile in script_files %}
|
|
||||||
{{ js_tag(scriptfile) }}
|
|
||||||
{%- endfor %}
|
|
||||||
<script src="{{ pathto('_static/js/theme.js', 1) }}"></script>
|
<script src="{{ pathto('_static/js/theme.js', 1) }}"></script>
|
||||||
|
|
||||||
|
{%- if READTHEDOCS or DEBUG %}
|
||||||
|
<script src="{{ pathto('_static/js/versions.js', 1) }}"></script>
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
{#- OPENSEARCH #}
|
{#- OPENSEARCH #}
|
||||||
{%- if use_opensearch %}
|
{%- if use_opensearch %}
|
||||||
<link rel="search" type="application/opensearchdescription+xml"
|
<link rel="search" type="application/opensearchdescription+xml"
|
||||||
title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}"
|
title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}"
|
||||||
href="{{ pathto('_static/opensearch.xml', 1) }}"/>
|
href="{{ pathto('_static/opensearch.xml', 1) }}"/>
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- endblock %}
|
{%- endblock %}
|
||||||
|
|
||||||
{%- block linktags %}
|
{%- block linktags %}
|
||||||
{%- if hasdoc('about') %}
|
{%- if hasdoc('about') %}
|
||||||
@@ -123,23 +118,23 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="main navigation">
|
{%- block navigation %}
|
||||||
{% block menu %}
|
{#- Translators: This is an ARIA section label for the main navigation menu -#}
|
||||||
{#
|
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="{{ _('Navigation menu') }}">
|
||||||
The singlehtml builder doesn't handle this toctree call when the
|
{%- block menu %}
|
||||||
toctree is empty. Skip building this for now.
|
{%- set toctree = toctree(maxdepth=theme_navigation_depth|int,
|
||||||
#}
|
collapse=theme_collapse_navigation|tobool,
|
||||||
{% if 'singlehtml' not in builder %}
|
includehidden=theme_includehidden|tobool,
|
||||||
{% set global_toc = toctree(maxdepth=theme_navigation_depth|int, collapse=theme_collapse_navigation, includehidden=True) %}
|
titles_only=theme_titles_only|tobool) %}
|
||||||
{% endif %}
|
{%- if toctree %}
|
||||||
{% if global_toc %}
|
{{ toctree }}
|
||||||
{{ global_toc }}
|
{%- else %}
|
||||||
{% else %}
|
|
||||||
<!-- Local TOC -->
|
<!-- Local TOC -->
|
||||||
<div class="local-toc">{{ toc }}</div>
|
<div class="local-toc">{{ toc }}</div>
|
||||||
{% endif %}
|
{%- endif %}
|
||||||
{% endblock %}
|
{%- endblock %}
|
||||||
</div>
|
</div>
|
||||||
|
{%- endblock %}
|
||||||
|
|
||||||
{% if theme_display_version %}
|
{% if theme_display_version %}
|
||||||
{%- set nav_version = version %}
|
{%- set nav_version = version %}
|
||||||
@@ -158,53 +153,42 @@
|
|||||||
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
|
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
|
||||||
|
|
||||||
{# MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #}
|
{# MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #}
|
||||||
<nav class="wy-nav-top" role="navigation" aria-label="top navigation">
|
<nav class="wy-nav-top" aria-label="{{ _('Mobile navigation menu') }}" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}>
|
||||||
{% block mobile_nav %}
|
{%- block mobile_nav %}
|
||||||
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
|
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
|
||||||
<a href="{{ pathto('index') }}">{{ project }}</a>
|
<a href="{{ pathto(master_doc) }}">{{ project }}</a>
|
||||||
{% endblock %}
|
{%- endblock %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="wy-nav-content">
|
||||||
{# PAGE CONTENT #}
|
{%- block content %}
|
||||||
<div class="wy-nav-content">
|
{%- if theme_style_external_links|tobool %}
|
||||||
<div class="rst-content">
|
<div class="rst-content style-external-links">
|
||||||
{% include "breadcrumbs.html" %}
|
{%- else %}
|
||||||
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
|
<div class="rst-content">
|
||||||
<div itemprop="articleBody" class="section">
|
{%- endif %}
|
||||||
{% block body %}{% endblock %}
|
{% include "breadcrumbs.html" %}
|
||||||
</div>
|
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
|
||||||
<div class="articleComments">
|
{%- block document %}
|
||||||
{% block comments %}{% endblock %}
|
<div itemprop="articleBody">
|
||||||
</div>
|
{% block body %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
{% include "footer.html" %}
|
{%- if self.comments()|trim %}
|
||||||
|
<div class="articleComments">
|
||||||
|
{%- block comments %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{%- endif%}
|
||||||
|
</div>
|
||||||
|
{%- endblock %}
|
||||||
|
{% include "footer.html" %}
|
||||||
|
</div>
|
||||||
|
{%- endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% include "versions.html" %}
|
{% include "versions.html" %}
|
||||||
|
|
||||||
{% if not embedded %}
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
var DOCUMENTATION_OPTIONS = {
|
|
||||||
URL_ROOT:'{{ url_root }}',
|
|
||||||
VERSION:'{{ release|e }}',
|
|
||||||
COLLAPSE_INDEX:false,
|
|
||||||
FILE_SUFFIX:'{{ '' if no_search_suffix else file_suffix }}',
|
|
||||||
HAS_SOURCE: {{ has_source|lower }},
|
|
||||||
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
{%- for scriptfile in script_files %}
|
|
||||||
<script type="text/javascript" src="{{ pathto(scriptfile, 1) }}"></script>
|
|
||||||
{%- endfor %}
|
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# RTD hosts this file, so just load on non RTD builds #}
|
{# RTD hosts this file, so just load on non RTD builds #}
|
||||||
{% if not READTHEDOCS %}
|
{% if not READTHEDOCS %}
|
||||||
<script type="text/javascript" src="{{ pathto('_static/js/theme.js', 1) }}"></script>
|
<script type="text/javascript" src="{{ pathto('_static/js/theme.js', 1) }}"></script>
|
||||||
@@ -214,7 +198,7 @@
|
|||||||
{% if theme_sticky_navigation %}
|
{% if theme_sticky_navigation %}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
jQuery(function () {
|
jQuery(function () {
|
||||||
SphinxRtdTheme.StickyNav.enable();
|
SphinxRtdTheme.Navigation.enable({{ 'true' if theme_sticky_navigation|tobool else 'false' }});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+184
-166
@@ -1,136 +1,86 @@
|
|||||||
{#
|
{# TEMPLATE VAR SETTINGS #}
|
||||||
basic/layout.html
|
|
||||||
~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
Master layout template for Sphinx themes.
|
|
||||||
|
|
||||||
:copyright: Copyright 2007-2013 by the Sphinx team, see AUTHORS.
|
|
||||||
:license: BSD, see LICENSE for details.
|
|
||||||
#}
|
|
||||||
{%- block doctype -%}
|
|
||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
|
||||||
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
||||||
{%- endblock %}
|
|
||||||
{%- set reldelim1 = reldelim1 is not defined and ' »' or reldelim1 %}
|
|
||||||
{%- set reldelim2 = reldelim2 is not defined and ' |' or reldelim2 %}
|
|
||||||
{%- set render_sidebar = (not embedded) and (not theme_nosidebar|tobool) and
|
|
||||||
(sidebars != []) %}
|
|
||||||
{%- set url_root = pathto('', 1) %}
|
{%- set url_root = pathto('', 1) %}
|
||||||
{# XXX necessary? #}
|
|
||||||
{%- if url_root == '#' %}{% set url_root = '' %}{% endif %}
|
{%- if url_root == '#' %}{% set url_root = '' %}{% endif %}
|
||||||
{%- if not embedded and docstitle %}
|
{%- if not embedded and docstitle %}
|
||||||
{%- set titlesuffix = " — "|safe + docstitle|e %}
|
{%- set titlesuffix = " — "|safe + docstitle|e %}
|
||||||
{%- else %}
|
{%- else %}
|
||||||
{%- set titlesuffix = "" %}
|
{%- set titlesuffix = "" %}
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
|
{%- set lang_attr = 'en' if language == None else (language | replace('_', '-')) %}
|
||||||
|
|
||||||
{%- macro relbar() %}
|
{# Build sphinx_version_info tuple from sphinx_version string in pure Jinja #}
|
||||||
<div class="related">
|
{%- set (_ver_major, _ver_minor) = (sphinx_version.split('.') | list)[:2] | map('int') -%}
|
||||||
<h3>{{ _('Navigation') }}</h3>
|
{%- set sphinx_version_info = (_ver_major, _ver_minor, -1) -%}
|
||||||
<ul>
|
|
||||||
{%- for rellink in rellinks %}
|
|
||||||
<li class="right" {% if loop.first %}style="margin-right: 10px"{% endif %}>
|
|
||||||
<a href="{{ pathto(rellink[0]) }}" title="{{ rellink[1]|striptags|e }}"
|
|
||||||
{{ accesskey(rellink[2]) }}>{{ rellink[3] }}</a>
|
|
||||||
{%- if not loop.first %}{{ reldelim2 }}{% endif %}</li>
|
|
||||||
{%- endfor %}
|
|
||||||
{%- block rootrellink %}
|
|
||||||
<li><a href="{{ pathto(master_doc) }}">{{ shorttitle|e }}</a>{{ reldelim1 }}</li>
|
|
||||||
{%- endblock %}
|
|
||||||
{%- for parent in parents %}
|
|
||||||
<li><a href="{{ parent.link|e }}" {% if loop.last %}{{ accesskey("U") }}{% endif %}>{{ parent.title }}</a>{{ reldelim1 }}</li>
|
|
||||||
{%- endfor %}
|
|
||||||
{%- block relbaritems %} {% endblock %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{%- macro sidebar() %}
|
<!DOCTYPE html>
|
||||||
{%- if render_sidebar %}
|
<html class="writer-html5" lang="{{ lang_attr }}"{% if sphinx_version_info >= (7, 2) %} data-content_root="{{ content_root }}"{% endif %}>
|
||||||
<div class="sphinxsidebar">
|
<head>
|
||||||
<div class="sphinxsidebarwrapper">
|
<meta charset="utf-8" />
|
||||||
{%- block sidebarlogo %}
|
{%- if READTHEDOCS and not embedded %}
|
||||||
{%- if logo %}
|
<meta name="readthedocs-addons-api-version" content="1">
|
||||||
<p class="logo"><a href="{{ pathto(master_doc) }}">
|
{%- endif %}
|
||||||
<img class="logo" src="{{ pathto('_static/' + logo, 1) }}" alt="Logo"/>
|
{{- metatags }}
|
||||||
</a></p>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
{%- endif %}
|
{%- block htmltitle %}
|
||||||
{%- endblock %}
|
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
||||||
{%- if sidebars != None %}
|
{%- endblock -%}
|
||||||
{#- new style sidebar: explicitly include/exclude templates #}
|
|
||||||
{%- for sidebartemplate in sidebars %}
|
|
||||||
{%- include sidebartemplate %}
|
|
||||||
{%- endfor %}
|
|
||||||
{%- else %}
|
|
||||||
{#- old style sidebars: using blocks -- should be deprecated #}
|
|
||||||
{%- block sidebartoc %}
|
|
||||||
{%- include "localtoc.html" %}
|
|
||||||
{%- endblock %}
|
|
||||||
{%- block sidebarrel %}
|
|
||||||
{%- include "relations.html" %}
|
|
||||||
{%- endblock %}
|
|
||||||
{%- block sidebarsourcelink %}
|
|
||||||
{%- include "sourcelink.html" %}
|
|
||||||
{%- endblock %}
|
|
||||||
{%- if customsidebar %}
|
|
||||||
{%- include customsidebar %}
|
|
||||||
{%- endif %}
|
|
||||||
{%- block sidebarsearch %}
|
|
||||||
{%- include "searchbox.html" %}
|
|
||||||
{%- endblock %}
|
|
||||||
{%- endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{%- endif %}
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
{%- macro script() %}
|
{#- CSS #}
|
||||||
<script type="text/javascript">
|
{%- for css_file in css_files %}
|
||||||
var DOCUMENTATION_OPTIONS = {
|
{%- if css_file|attr("filename") %}
|
||||||
URL_ROOT: '{{ url_root }}',
|
{{ css_tag(css_file) }}
|
||||||
VERSION: '{{ release|e }}',
|
{%- else %}
|
||||||
COLLAPSE_INDEX: false,
|
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
|
||||||
FILE_SUFFIX: '{{ '' if no_search_suffix else file_suffix }}',
|
{%- endif %}
|
||||||
HAS_SOURCE: {{ has_source|lower }},
|
{%- endfor %}
|
||||||
SOURCELINK_SUFFIX: '{{ sourcelink_suffix }}'
|
|
||||||
};
|
{#
|
||||||
</script>
|
"extra_css_files" is an undocumented Read the Docs theme specific option.
|
||||||
|
There is no need to check for ``|attr("filename")`` here because it's always a string.
|
||||||
|
Note that this option should be removed in favor of regular ``html_css_files``:
|
||||||
|
https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_css_files
|
||||||
|
#}
|
||||||
|
{%- for css_file in extra_css_files %}
|
||||||
|
<link rel="stylesheet" href="{{ pathto(css_file, 1)|escape }}" type="text/css" />
|
||||||
|
{%- endfor -%}
|
||||||
|
|
||||||
|
{#- FAVICON #}
|
||||||
|
{%- if favicon_url %}
|
||||||
|
<link rel="shortcut icon" href="{{ favicon_url }}"/>
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{#- CANONICAL URL (deprecated) #}
|
||||||
|
{%- if theme_canonical_url and not pageurl %}
|
||||||
|
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{#- CANONICAL URL #}
|
||||||
|
{%- if pageurl %}
|
||||||
|
<link rel="canonical" href="{{ pageurl|e }}" />
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
{#- JAVASCRIPTS #}
|
||||||
|
{%- block scripts %}
|
||||||
|
{%- if not embedded %}
|
||||||
{%- for scriptfile in script_files %}
|
{%- for scriptfile in script_files %}
|
||||||
<script type="text/javascript" src="{{ pathto(scriptfile, 1) }}"></script>
|
{{ js_tag(scriptfile) }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
{%- endmacro %}
|
<script src="{{ pathto('_static/js/theme.js', 1) }}"></script>
|
||||||
|
|
||||||
{%- macro css() %}
|
{%- if READTHEDOCS or DEBUG %}
|
||||||
<link rel="stylesheet" href="{{ pathto('_static/' + style, 1) }}" type="text/css" />
|
<script src="{{ pathto('_static/js/versions.js', 1) }}"></script>
|
||||||
<link rel="stylesheet" href="{{ pathto('_static/pygments.css', 1) }}" type="text/css" />
|
{%- endif %}
|
||||||
{%- for cssfile in css_files %}
|
|
||||||
<link rel="stylesheet" href="{{ pathto(cssfile, 1) }}" type="text/css" />
|
|
||||||
{%- endfor %}
|
|
||||||
{%- endmacro %}
|
|
||||||
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
{#- OPENSEARCH #}
|
||||||
<head>
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset={{ encoding }}" />
|
|
||||||
{{ metatags }}
|
|
||||||
{%- block htmltitle %}
|
|
||||||
<title>{{ title|striptags|e }}{{ titlesuffix }}</title>
|
|
||||||
{%- endblock %}
|
|
||||||
{{ css() }}
|
|
||||||
{%- if not embedded %}
|
|
||||||
{{ script() }}
|
|
||||||
{%- if use_opensearch %}
|
{%- if use_opensearch %}
|
||||||
<link rel="search" type="application/opensearchdescription+xml"
|
<link rel="search" type="application/opensearchdescription+xml"
|
||||||
title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}"
|
title="{% trans docstitle=docstitle|e %}Search within {{ docstitle }}{% endtrans %}"
|
||||||
href="{{ pathto('_static/opensearch.xml', 1) }}"/>
|
href="{{ pathto('_static/opensearch.xml', 1) }}"/>
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- if favicon %}
|
{%- endif %}
|
||||||
<link rel="shortcut icon" href="{{ pathto('_static/' + favicon, 1) }}"/>
|
{%- endblock %}
|
||||||
{%- endif %}
|
|
||||||
{%- if theme_canonical_url %}
|
{%- block linktags %}
|
||||||
<link rel="canonical" href="{{ theme_canonical_url }}{{ pagename }}.html"/>
|
|
||||||
{%- endif %}
|
|
||||||
{%- endif %}
|
|
||||||
{%- block linktags %}
|
|
||||||
{%- if hasdoc('about') %}
|
{%- if hasdoc('about') %}
|
||||||
<link rel="author" title="{{ _('About these documents') }}" href="{{ pathto('about') }}" />
|
<link rel="author" title="{{ _('About these documents') }}" href="{{ pathto('about') }}" />
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
@@ -143,67 +93,135 @@
|
|||||||
{%- if hasdoc('copyright') %}
|
{%- if hasdoc('copyright') %}
|
||||||
<link rel="copyright" title="{{ _('Copyright') }}" href="{{ pathto('copyright') }}" />
|
<link rel="copyright" title="{{ _('Copyright') }}" href="{{ pathto('copyright') }}" />
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
<link rel="top" title="{{ docstitle|e }}" href="{{ pathto('index') }}" />
|
|
||||||
{%- if parents %}
|
|
||||||
<link rel="up" title="{{ parents[-1].title|striptags|e }}" href="{{ parents[-1].link|e }}" />
|
|
||||||
{%- endif %}
|
|
||||||
{%- if next %}
|
{%- if next %}
|
||||||
<link rel="next" title="{{ next.title|striptags|e }}" href="{{ next.link|e }}" />
|
<link rel="next" title="{{ next.title|striptags|e }}" href="{{ next.link|e }}" />
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- if prev %}
|
{%- if prev %}
|
||||||
<link rel="prev" title="{{ prev.title|striptags|e }}" href="{{ prev.link|e }}" />
|
<link rel="prev" title="{{ prev.title|striptags|e }}" href="{{ prev.link|e }}" />
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- endblock %}
|
|
||||||
{%- block extrahead %} {% endblock %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{%- block header %}{% endblock %}
|
|
||||||
|
|
||||||
{%- block relbar1 %}{{ relbar() }}{% endblock %}
|
|
||||||
|
|
||||||
{%- block content %}
|
|
||||||
{%- block sidebar1 %} {# possible location for sidebar #} {% endblock %}
|
|
||||||
|
|
||||||
<div class="document">
|
|
||||||
{%- block document %}
|
|
||||||
<div class="documentwrapper">
|
|
||||||
{%- if render_sidebar %}
|
|
||||||
<div class="bodywrapper">
|
|
||||||
{%- endif %}
|
|
||||||
<div class="body">
|
|
||||||
{% block body %} {% endblock %}
|
|
||||||
</div>
|
|
||||||
{%- if render_sidebar %}
|
|
||||||
</div>
|
|
||||||
{%- endif %}
|
|
||||||
</div>
|
|
||||||
{%- endblock %}
|
{%- endblock %}
|
||||||
|
{%- block extrahead %} {% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
{%- block sidebar2 %}{{ sidebar() }}{% endblock %}
|
<body class="wy-body-for-nav">
|
||||||
<div class="clearer"></div>
|
|
||||||
</div>
|
|
||||||
{%- endblock %}
|
|
||||||
|
|
||||||
{%- block relbar2 %}{{ relbar() }}{% endblock %}
|
{%- block extrabody %} {% endblock %}
|
||||||
|
<div class="wy-grid-for-nav">
|
||||||
|
{#- SIDE NAV, TOGGLES ON MOBILE #}
|
||||||
|
<nav data-toggle="wy-nav-shift" class="wy-nav-side">
|
||||||
|
<div class="wy-side-scroll">
|
||||||
|
<div class="wy-side-nav-search" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}>
|
||||||
|
{%- block sidebartitle %}
|
||||||
|
|
||||||
|
{# the logo helper function was removed in Sphinx 6 and deprecated since Sphinx 4 #}
|
||||||
|
{# the master_doc variable was renamed to root_doc in Sphinx 4 (master_doc still exists in later Sphinx versions) #}
|
||||||
|
{%- set _logo_url = logo_url|default(pathto('_static/' + (logo or ""), 1)) %}
|
||||||
|
{%- set _root_doc = root_doc|default(master_doc) %}
|
||||||
|
<a href="{{ pathto(_root_doc) }}"{% if not theme_logo_only %} class="icon icon-home"{% endif %}>
|
||||||
|
{% if not theme_logo_only %}{{ project }}{% endif %}
|
||||||
|
{%- if logo or logo_url %}
|
||||||
|
<img src="{{ _logo_url }}" class="logo" alt="{{ _('Logo') }}"/>
|
||||||
|
{%- endif %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{%- if READTHEDOCS or DEBUG %}
|
||||||
|
{%- if theme_version_selector or theme_language_selector %}
|
||||||
|
<div class="switch-menus">
|
||||||
|
<div class="version-switch"></div>
|
||||||
|
<div class="language-switch"></div>
|
||||||
|
</div>
|
||||||
|
{%- endif %}
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{%- include "searchbox.html" %}
|
||||||
|
|
||||||
|
{%- endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{%- block navigation %}
|
||||||
|
{#- Translators: This is an ARIA section label for the main navigation menu -#}
|
||||||
|
<div class="wy-menu wy-menu-vertical" data-spy="affix" role="navigation" aria-label="{{ _('Navigation menu') }}">
|
||||||
|
{%- block menu %}
|
||||||
|
{%- set toctree = toctree(maxdepth=theme_navigation_depth|int,
|
||||||
|
collapse=theme_collapse_navigation|tobool,
|
||||||
|
includehidden=theme_includehidden|tobool,
|
||||||
|
titles_only=theme_titles_only|tobool) %}
|
||||||
|
{%- if toctree %}
|
||||||
|
{{ toctree }}
|
||||||
|
{%- else %}
|
||||||
|
<!-- Local TOC -->
|
||||||
|
<div class="local-toc">{{ toc }}</div>
|
||||||
|
{%- endif %}
|
||||||
|
{%- endblock %}
|
||||||
|
</div>
|
||||||
|
{%- endblock %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<section data-toggle="wy-nav-shift" class="wy-nav-content-wrap">
|
||||||
|
|
||||||
|
{#- MOBILE NAV, TRIGGLES SIDE NAV ON TOGGLE #}
|
||||||
|
{#- Translators: This is an ARIA section label for the navigation menu that is visible when viewing the page on mobile devices -#}
|
||||||
|
<nav class="wy-nav-top" aria-label="{{ _('Mobile navigation menu') }}" {% if theme_style_nav_header_background %} style="background: {{theme_style_nav_header_background}}" {% endif %}>
|
||||||
|
{%- block mobile_nav %}
|
||||||
|
<i data-toggle="wy-nav-top" class="fa fa-bars"></i>
|
||||||
|
<a href="{{ pathto(master_doc) }}">{{ project }}</a>
|
||||||
|
{%- endblock %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="wy-nav-content">
|
||||||
|
{%- block content %}
|
||||||
|
{%- if theme_style_external_links|tobool %}
|
||||||
|
<div class="rst-content style-external-links">
|
||||||
|
{%- else %}
|
||||||
|
<div class="rst-content">
|
||||||
|
{%- endif %}
|
||||||
|
{% include "breadcrumbs.html" %}
|
||||||
|
<div role="main" class="document" itemscope="itemscope" itemtype="http://schema.org/Article">
|
||||||
|
{%- block document %}
|
||||||
|
<div itemprop="articleBody">
|
||||||
|
{% block body %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{%- if self.comments()|trim %}
|
||||||
|
<div class="articleComments">
|
||||||
|
{%- block comments %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
{%- endif%}
|
||||||
|
</div>
|
||||||
|
{%- endblock %}
|
||||||
|
{% include "footer.html" %}
|
||||||
|
</div>
|
||||||
|
{%- endblock %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% include "versions.html" -%}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
jQuery(function () {
|
||||||
|
SphinxRtdTheme.Navigation.enable({{ 'true' if theme_sticky_navigation|tobool else 'false' }});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#- Do not conflict with RTD insertion of analytics script #}
|
||||||
|
{%- if not READTHEDOCS %}
|
||||||
|
{%- if theme_analytics_id %}
|
||||||
|
<!-- Theme Analytics -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id={{ theme_analytics_id }}"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
|
||||||
|
gtag('config', '{{ theme_analytics_id }}', {
|
||||||
|
'anonymize_ip': {{ 'true' if theme_analytics_anonymize_ip|tobool else 'false' }},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
{%- block footer %}
|
|
||||||
<div class="footer">
|
|
||||||
{%- if show_copyright %}
|
|
||||||
{%- if hasdoc('copyright') %}
|
|
||||||
{% trans path=pathto('copyright'), copyright=copyright|e %}© <a href="{{ path }}">Copyright</a> {{ copyright }}.{% endtrans %}
|
|
||||||
{%- else %}
|
|
||||||
{% trans copyright=copyright|e %}© Copyright {{ copyright }}.{% endtrans %}
|
|
||||||
{%- endif %}
|
|
||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- if last_updated %}
|
{%- endif %}
|
||||||
{% trans last_updated=last_updated|e %}Last updated on {{ last_updated }}.{% endtrans %}
|
|
||||||
{%- endif %}
|
|
||||||
{%- if show_sphinx %}
|
|
||||||
{% trans sphinx_version=sphinx_version|e %}Created using <a href="http://sphinx-doc.org/">Sphinx</a> {{ sphinx_version }}.{% endtrans %}
|
|
||||||
{%- endif %}
|
|
||||||
</div>
|
|
||||||
<p>asdf asdf asdf asdf 22</p>
|
|
||||||
{%- endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
|
{%- block footer %} {% endblock %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1
-15
@@ -39,7 +39,7 @@ as well as the type of underlying hardware. Example:
|
|||||||
"rsa_pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh…nswIDAQAB\n-----END PUBLIC KEY-----\n"
|
"rsa_pubkey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqh…nswIDAQAB\n-----END PUBLIC KEY-----\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
The ``rsa_pubkey`` is optional any only required for certain fatures such as working with reusable
|
The ``rsa_pubkey`` is optional any only required for certain features such as working with reusable
|
||||||
media and NFC cryptography.
|
media and NFC cryptography.
|
||||||
|
|
||||||
Every initialization token can only be used once. On success, you will receive a response containing
|
Every initialization token can only be used once. On success, you will receive a response containing
|
||||||
@@ -208,20 +208,6 @@ Additionally, when creating a device through the user interface or API, a user c
|
|||||||
the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security
|
the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security
|
||||||
policies for official pretix apps like pretixSCAN and pretixPOS.
|
policies for official pretix apps like pretixSCAN and pretixPOS.
|
||||||
|
|
||||||
Removing a device
|
|
||||||
-----------------
|
|
||||||
|
|
||||||
If you want implement a way to to deprovision a device in your software, you can call the ``revoke`` endpoint to
|
|
||||||
invalidate your API key. There is no way to reverse this operation.
|
|
||||||
|
|
||||||
.. sourcecode:: http
|
|
||||||
|
|
||||||
POST /api/v1/device/revoke HTTP/1.1
|
|
||||||
Host: pretix.eu
|
|
||||||
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
|
|
||||||
|
|
||||||
This can also be done by the user through the web interface.
|
|
||||||
|
|
||||||
Event selection
|
Event selection
|
||||||
---------------
|
---------------
|
||||||
|
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ List-level conditional fetching
|
|||||||
If modification checks are not possible with this granularity, you can instead check for the full list.
|
If modification checks are not possible with this granularity, you can instead check for the full list.
|
||||||
In this case, the list of objects may contain a regular HTTP header ``Last-Modified`` with the date of the
|
In this case, the list of objects may contain a regular HTTP header ``Last-Modified`` with the date of the
|
||||||
last modification to any item of that resource. You can then pass this date back in your next request in the
|
last modification to any item of that resource. You can then pass this date back in your next request in the
|
||||||
``If-Modified-Since`` header. If the any object has changed in the meantime, you will receive back a full list
|
``If-Modified-Since`` header. If any object has changed in the meantime, you will receive back a full list
|
||||||
(if something it missing, this means the object has been deleted). If nothing happened, we'll send back a
|
(if something it missing, this means the object has been deleted). If nothing happened, we'll send back a
|
||||||
``304 Not Modified`` return code.
|
``304 Not Modified`` return code.
|
||||||
|
|
||||||
|
|||||||
@@ -421,3 +421,94 @@ Annulment of a check-in
|
|||||||
:statuscode 401: Authentication failure
|
:statuscode 401: Authentication failure
|
||||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||||
:statuscode 404: The requested nonce does not exist.
|
:statuscode 404: The requested nonce does not exist.
|
||||||
|
|
||||||
|
|
||||||
|
Check-in history
|
||||||
|
----------------
|
||||||
|
|
||||||
|
.. rst-class:: rest-resource-table
|
||||||
|
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
Field Type Description
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
id integer Internal ID of the check-in
|
||||||
|
successful boolean Whether the check-in was successful
|
||||||
|
error_reason string Category of reason why the check-in was unsuccessful. Currently
|
||||||
|
``"canceled"``, ``"invalid"``, ``"unpaid"`` ``"product"``,
|
||||||
|
``"rules"``, ``"revoked"``, ``"incomplete"``, ``"already_redeemed"``,
|
||||||
|
``"ambiguous"``, ``"error"``, ``"blocked"``, ``"unapproved"``,
|
||||||
|
``"invalid_time"``, ``"annulled"`` or ``null``
|
||||||
|
error_explanation string Additional, human-readable reason for the check-in to be unsuccessful (or ``null``)
|
||||||
|
position integer Internal ID of the order position (or ``null`` for unknown scans)
|
||||||
|
datetime datetime Logical time when the check-in happened
|
||||||
|
created datetime Time when the check-in appeared on the server
|
||||||
|
list integer Internal ID of the check-in list
|
||||||
|
auto_checked_in boolean Whether the check-in was performed by the system automatically
|
||||||
|
gate integer Internal ID of the gate (or ``null``)
|
||||||
|
device integer Internal ID of the device (or ``null``)
|
||||||
|
device_id integer Organizer-internal ID of the device (or ``null``)
|
||||||
|
type string Type of check-in, currently ``"entry"`` or ``"exit"``
|
||||||
|
===================================== ========================== =======================================================
|
||||||
|
|
||||||
|
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkins/
|
||||||
|
|
||||||
|
Returns a list of all check-in events within a given event.
|
||||||
|
|
||||||
|
**Example request**:
|
||||||
|
|
||||||
|
.. sourcecode:: http
|
||||||
|
|
||||||
|
GET /api/v1/organizers/bigevents/events/sampleconf/checkins/ 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": 1,
|
||||||
|
"next": null,
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"successful": true,
|
||||||
|
"error_reason": null,
|
||||||
|
"error_explanation": null,
|
||||||
|
"position": 1234,
|
||||||
|
"datetime": "2017-12-25T12:45:23Z",
|
||||||
|
"created": "2017-12-25T12:45:23Z",
|
||||||
|
"list": 2,
|
||||||
|
"auto_checked_in": false,
|
||||||
|
"gate": null,
|
||||||
|
"device": null,
|
||||||
|
"device_id": null,
|
||||||
|
"type": "entry",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
:query integer page: The page number in case of a multi-page result set, default is 1
|
||||||
|
:query datetime created_since: Only return check-ins that have been created since the given date (inclusive).
|
||||||
|
:query datetime created_before: Only return check-ins that have been created before the given date (exclusive).
|
||||||
|
:query datetime datetime_since: Only return check-ins that have happened since the given date (inclusive).
|
||||||
|
:query datetime datetime_before: Only return check-ins that have happened before the given date (exclusive).
|
||||||
|
:query boolean successful: Only return check-ins that have (not) been successful.
|
||||||
|
:query boolean error_reason: Only return check-ins with a specific error reason.
|
||||||
|
:query integer list: Only return check-ins from a specific list.
|
||||||
|
:query string type: Only return check-ins of a specific type.
|
||||||
|
:query integer gate: Only return check-ins from a specific gate.
|
||||||
|
:query integer device: Only return check-ins from a specific device.
|
||||||
|
:query boolean auto_checked_in: Only return check-ins that are (not) auto-checked in.
|
||||||
|
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``datetime``, ``created``,
|
||||||
|
and ``id``.
|
||||||
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
|
:param event: The ``slug`` field of the event 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.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ at :ref:`plugin-docs`.
|
|||||||
item_bundles
|
item_bundles
|
||||||
item_add-ons
|
item_add-ons
|
||||||
item_meta_properties
|
item_meta_properties
|
||||||
|
item_program_times
|
||||||
questions
|
questions
|
||||||
question_options
|
question_options
|
||||||
quotas
|
quotas
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ invoice_from_name string Sender address:
|
|||||||
invoice_from string Sender address: Address lines
|
invoice_from string Sender address: Address lines
|
||||||
invoice_from_zipcode string Sender address: ZIP code
|
invoice_from_zipcode string Sender address: ZIP code
|
||||||
invoice_from_city string Sender address: City
|
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_country string Sender address: Country code
|
||||||
invoice_from_tax_id string Sender address: Local Tax ID
|
invoice_from_tax_id string Sender address: Local Tax ID
|
||||||
invoice_from_vat_id string Sender address: EU VAT ID
|
invoice_from_vat_id string Sender address: EU VAT ID
|
||||||
@@ -233,6 +234,7 @@ List of all invoices
|
|||||||
"invoice_from": "Demo street 12",
|
"invoice_from": "Demo street 12",
|
||||||
"invoice_from_zipcode":"",
|
"invoice_from_zipcode":"",
|
||||||
"invoice_from_city":"Demo town",
|
"invoice_from_city":"Demo town",
|
||||||
|
"invoice_from_state":"CA",
|
||||||
"invoice_from_country":"US",
|
"invoice_from_country":"US",
|
||||||
"invoice_from_tax_id":"",
|
"invoice_from_tax_id":"",
|
||||||
"invoice_from_vat_id":"",
|
"invoice_from_vat_id":"",
|
||||||
@@ -381,6 +383,7 @@ Fetching individual invoices
|
|||||||
"invoice_from": "Demo street 12",
|
"invoice_from": "Demo street 12",
|
||||||
"invoice_from_zipcode":"",
|
"invoice_from_zipcode":"",
|
||||||
"invoice_from_city":"Demo town",
|
"invoice_from_city":"Demo town",
|
||||||
|
"invoice_from_state":"CA",
|
||||||
"invoice_from_country":"US",
|
"invoice_from_country":"US",
|
||||||
"invoice_from_tax_id":"",
|
"invoice_from_tax_id":"",
|
||||||
"invoice_from_vat_id":"",
|
"invoice_from_vat_id":"",
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
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.
|
||||||
+36
-10
@@ -139,6 +139,10 @@ has_variations boolean Shows whether
|
|||||||
variations list of objects A list with one object for each variation of this item.
|
variations list of objects A list with one object for each variation of this item.
|
||||||
Can be empty. Only writable during creation,
|
Can be empty. Only writable during creation,
|
||||||
use separate endpoint to modify this later.
|
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
|
├ id integer Internal ID of the variation
|
||||||
├ value multi-lingual string The "name" 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``
|
├ default_price money (string) The price set directly for this variation or ``null``
|
||||||
@@ -225,6 +229,10 @@ meta_data object Values set fo
|
|||||||
|
|
||||||
The ``hidden_if_item_available_mode`` attributes has been added.
|
The ``hidden_if_item_available_mode`` attributes has been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 2025.9
|
||||||
|
|
||||||
|
The ``program_times`` attribute has been added.
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
-----
|
-----
|
||||||
|
|
||||||
@@ -232,9 +240,11 @@ 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
|
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.
|
one variation.
|
||||||
|
|
||||||
Also note that ``variations``, ``bundles``, and ``addons`` are only supported on ``POST``. To update/delete variations,
|
Also note that ``variations``, ``bundles``, ``addons`` and ``program_times`` 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``
|
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`` and/or ``addons``.
|
with nested ``variations``, ``bundles``, ``addons`` and/or ``program_times``.
|
||||||
|
|
||||||
|
``program_times`` is not available to items in event series.
|
||||||
|
|
||||||
Endpoints
|
Endpoints
|
||||||
---------
|
---------
|
||||||
@@ -373,7 +383,8 @@ Endpoints
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"addons": [],
|
"addons": [],
|
||||||
"bundles": []
|
"bundles": [],
|
||||||
|
"program_times": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -525,7 +536,8 @@ Endpoints
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"addons": [],
|
"addons": [],
|
||||||
"bundles": []
|
"bundles": [],
|
||||||
|
"program_times": []
|
||||||
}
|
}
|
||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer to fetch
|
:param organizer: The ``slug`` field of the organizer to fetch
|
||||||
@@ -653,7 +665,13 @@ Endpoints
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"addons": [],
|
"addons": [],
|
||||||
"bundles": []
|
"bundles": [],
|
||||||
|
"program_times": [
|
||||||
|
{
|
||||||
|
"start": "2025-08-14T22:00:00Z",
|
||||||
|
"end": "2025-08-15T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
**Example response**:
|
**Example response**:
|
||||||
@@ -773,7 +791,13 @@ Endpoints
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"addons": [],
|
"addons": [],
|
||||||
"bundles": []
|
"bundles": [],
|
||||||
|
"program_times": [
|
||||||
|
{
|
||||||
|
"start": "2025-08-14T22:00:00Z",
|
||||||
|
"end": "2025-08-15T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer of the event to create an item for
|
:param organizer: The ``slug`` field of the organizer of the event to create an item for
|
||||||
@@ -789,8 +813,9 @@ Endpoints
|
|||||||
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
|
||||||
want to change.
|
want to change.
|
||||||
|
|
||||||
You can change all fields of the resource except the ``has_variations``, ``variations`` and the ``addon`` field. If
|
You can change all fields of the resource except the ``has_variations``, ``variations``, ``addon`` and the
|
||||||
you need to update/delete variations or add-ons please use the nested dedicated endpoints.
|
``program_times`` field. If you need to update/delete variations, add-ons or program times, please use the nested
|
||||||
|
dedicated endpoints.
|
||||||
|
|
||||||
**Example request**:
|
**Example request**:
|
||||||
|
|
||||||
@@ -924,7 +949,8 @@ Endpoints
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"addons": [],
|
"addons": [],
|
||||||
"bundles": []
|
"bundles": [],
|
||||||
|
"program_times": []
|
||||||
}
|
}
|
||||||
|
|
||||||
:param organizer: The ``slug`` field of the organizer to modify
|
:param organizer: The ``slug`` field of the organizer to modify
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ expires datetime The order will
|
|||||||
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
|
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
|
||||||
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order
|
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order
|
||||||
total money (string) Total value of this order
|
total money (string) Total value of this order
|
||||||
|
tax_rounding_mode string Tax rounding mode, see :ref:`algorithms-rounding`
|
||||||
comment string Internal comment on this order
|
comment string Internal comment on this order
|
||||||
api_meta object Meta data for that order. Only available through API, no guarantees
|
api_meta object Meta data for that order. Only available through API, no guarantees
|
||||||
on the content structure. You can use this to save references to your system.
|
on the content structure. You can use this to save references to your system.
|
||||||
@@ -151,6 +152,10 @@ plugin_data object Additional data
|
|||||||
|
|
||||||
The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added.
|
The ``invoice_address.transmission_type`` and ``invoice_address.transmission_info`` attributes have been added.
|
||||||
|
|
||||||
|
.. versionchanged:: 2025.10
|
||||||
|
|
||||||
|
The ``tax_rounding_mode`` attribute has been added.
|
||||||
|
|
||||||
.. _order-position-resource:
|
.. _order-position-resource:
|
||||||
|
|
||||||
Order position resource
|
Order position resource
|
||||||
@@ -358,6 +363,7 @@ List of all orders
|
|||||||
"payment_provider": "banktransfer",
|
"payment_provider": "banktransfer",
|
||||||
"fees": [],
|
"fees": [],
|
||||||
"total": "23.00",
|
"total": "23.00",
|
||||||
|
"tax_rounding_mode": "line",
|
||||||
"comment": "",
|
"comment": "",
|
||||||
"custom_followup_at": null,
|
"custom_followup_at": null,
|
||||||
"checkin_attention": false,
|
"checkin_attention": false,
|
||||||
@@ -602,6 +608,7 @@ Fetching individual orders
|
|||||||
"payment_provider": "banktransfer",
|
"payment_provider": "banktransfer",
|
||||||
"fees": [],
|
"fees": [],
|
||||||
"total": "23.00",
|
"total": "23.00",
|
||||||
|
"tax_rounding_mode": "line",
|
||||||
"comment": "",
|
"comment": "",
|
||||||
"api_meta": {},
|
"api_meta": {},
|
||||||
"custom_followup_at": null,
|
"custom_followup_at": null,
|
||||||
@@ -1011,6 +1018,7 @@ Creating orders
|
|||||||
provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no*
|
provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no*
|
||||||
charge will be created), this is just informative in case you *handled the payment already*.
|
charge will be created), this is just informative in case you *handled the payment already*.
|
||||||
* ``payment_date`` (optional) – Date and time of the completion of the payment.
|
* ``payment_date`` (optional) – Date and time of the completion of the payment.
|
||||||
|
* ``tax_rounding_mode`` (optional)
|
||||||
* ``comment`` (optional)
|
* ``comment`` (optional)
|
||||||
* ``custom_followup_at`` (optional)
|
* ``custom_followup_at`` (optional)
|
||||||
* ``checkin_attention`` (optional)
|
* ``checkin_attention`` (optional)
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ The following values for ``action_types`` are valid with pretix core:
|
|||||||
* ``pretix.event.added``
|
* ``pretix.event.added``
|
||||||
* ``pretix.event.changed``
|
* ``pretix.event.changed``
|
||||||
* ``pretix.event.deleted``
|
* ``pretix.event.deleted``
|
||||||
|
* ``pretix.giftcards.created``
|
||||||
|
* ``pretix.giftcards.modified``
|
||||||
|
* ``pretix.giftcards.transaction.*``
|
||||||
* ``pretix.voucher.added``
|
* ``pretix.voucher.added``
|
||||||
* ``pretix.voucher.changed``
|
* ``pretix.voucher.changed``
|
||||||
* ``pretix.voucher.deleted``
|
* ``pretix.voucher.deleted``
|
||||||
|
|||||||
@@ -178,3 +178,124 @@ Flowchart
|
|||||||
---------
|
---------
|
||||||
|
|
||||||
.. image:: /images/cart_pricing.png
|
.. image:: /images/cart_pricing.png
|
||||||
|
|
||||||
|
|
||||||
|
.. _`algorithms-rounding`:
|
||||||
|
|
||||||
|
Rounding of taxes
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
pretix internally always stores taxes on a per-line level, like this:
|
||||||
|
|
||||||
|
========== ========== =========== ======= =============
|
||||||
|
Product Tax rate Net price Tax Gross price
|
||||||
|
========== ========== =========== ======= =============
|
||||||
|
Ticket A 19 % 84.03 15.97 100.00
|
||||||
|
Ticket B 19 % 84.03 15.97 100.00
|
||||||
|
Ticket C 19 % 84.03 15.97 100.00
|
||||||
|
Ticket D 19 % 84.03 15.97 100.00
|
||||||
|
Ticket E 19 % 84.03 15.97 100.00
|
||||||
|
Sum 420.15 79.85 500.00
|
||||||
|
========== ========== =========== ======= =============
|
||||||
|
|
||||||
|
Whether the net price is computed from the gross price or vice versa is configured on the tax rule and may differ for every line.
|
||||||
|
|
||||||
|
The line-based computation has a few significant advantages:
|
||||||
|
|
||||||
|
- We can report both net and gross prices for every individual ticket.
|
||||||
|
|
||||||
|
- We can report both net and gross prices for every filter imaginable, such as the gross sum of all sales of Ticket A
|
||||||
|
or the net sum of all sales for a specific date in an event series. All numbers will be exact.
|
||||||
|
|
||||||
|
- When splitting the order into two, both net price and gross price are split without any changes in rounding.
|
||||||
|
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
|
However, calculating the tax rate from the net total has significant disadvantages:
|
||||||
|
|
||||||
|
- It is impossible to guarantee a stable gross price this way, i.e. if you advertise a price of €100 per ticket to
|
||||||
|
consumers, they will be confused when they only need to pay €499.98 for 5 tickets.
|
||||||
|
|
||||||
|
- Some prices are impossible, e.g. you cannot sell a ticket for a gross price of €99.99 at a 19% tax rate, since there
|
||||||
|
is no two-decimal net price that would be computed to a gross price of €99.99.
|
||||||
|
|
||||||
|
- When splitting an order into two, the combined of the new orders is not guaranteed to be the same as the total of the
|
||||||
|
original order. Therefore, additional payments or refunds of very small amounts might be necessary.
|
||||||
|
|
||||||
|
To allow organizers to make their own choices on this matter, pretix provides the following options:
|
||||||
|
|
||||||
|
Compute taxes for every line individually
|
||||||
|
"""""""""""""""""""""""""""""""""""""""""
|
||||||
|
|
||||||
|
Algorithm identifier: ``line``
|
||||||
|
|
||||||
|
This is our original algorithm where the tax value is rounded for every line individually.
|
||||||
|
|
||||||
|
**This is our current default algorithm and we recommend it whenever you do not have different requirements** (see below).
|
||||||
|
For the example above:
|
||||||
|
|
||||||
|
========== ========== =========== ======= =============
|
||||||
|
Product Tax rate Net price Tax Gross price
|
||||||
|
========== ========== =========== ======= =============
|
||||||
|
Ticket A 19 % 84.03 15.97 100.00
|
||||||
|
Ticket B 19 % 84.03 15.97 100.00
|
||||||
|
Ticket C 19 % 84.03 15.97 100.00
|
||||||
|
Ticket D 19 % 84.03 15.97 100.00
|
||||||
|
Ticket E 19 % 84.03 15.97 100.00
|
||||||
|
Sum 420.15 79.85 500.00
|
||||||
|
========== ========== =========== ======= =============
|
||||||
|
|
||||||
|
|
||||||
|
Compute taxes based on net total
|
||||||
|
""""""""""""""""""""""""""""""""
|
||||||
|
|
||||||
|
Algorithm identifier: ``sum_by_net``
|
||||||
|
|
||||||
|
In this algorithm, the tax value and gross total are computed from the sum of the net prices. To accomplish this within
|
||||||
|
our data model, the gross price and tax of some of the tickets will be changed by the minimum currency unit (e.g. €0.01).
|
||||||
|
The net price of the tickets always stay the same.
|
||||||
|
|
||||||
|
**This is the algorithm intended by EN 16931 invoices and our recommendation to use for e-invoicing when (primarily) business customers are involved.**
|
||||||
|
|
||||||
|
The main downside is that it might be confusing when selling to consumers, since the amounts to be paid change in unexpected ways.
|
||||||
|
For the example above, the customer expects to pay 5 times 100.00, but they are are in fact charged 499.98:
|
||||||
|
|
||||||
|
========== ========== =========== ============================== ==============================
|
||||||
|
Product Tax rate Net price Tax Gross price
|
||||||
|
========== ========== =========== ============================== ==============================
|
||||||
|
Ticket A 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding)
|
||||||
|
Ticket B 19 % 84.03 15.96 (incl. -0.01 rounding) 99.99 (incl. -0.01 rounding)
|
||||||
|
Ticket C 19 % 84.03 15.97 100.00
|
||||||
|
Ticket D 19 % 84.03 15.97 100.00
|
||||||
|
Ticket E 19 % 84.03 15.97 100.00
|
||||||
|
Sum 420.15 78.83 499.98
|
||||||
|
========== ========== =========== ============================== ==============================
|
||||||
|
|
||||||
|
Compute taxes based on net total with stable gross prices
|
||||||
|
"""""""""""""""""""""""""""""""""""""""""""""""""""""""""
|
||||||
|
|
||||||
|
Algorithm identifier: ``sum_by_net_keep_gross``
|
||||||
|
|
||||||
|
In this algorithm, the tax value and gross total are computed from the sum of the net prices. However, the net prices
|
||||||
|
of some of the tickets will be changed automatically by the minimum currency unit (e.g. €0.01) such that the resulting
|
||||||
|
gross prices stay the same.
|
||||||
|
|
||||||
|
**This is less confusing to consumers and the end result is still compliant to EN 16931, so we recommend this for e-invoicing when (primarily) consumers are involved.**
|
||||||
|
|
||||||
|
The main downside is that it might be confusing when selling to business customers, since the prices of the identical tickets appear to be different.
|
||||||
|
Full computation for the example above:
|
||||||
|
|
||||||
|
========== ========== ============================= ============================== =============
|
||||||
|
Product Tax rate Net price Tax Gross price
|
||||||
|
========== ========== ============================= ============================== =============
|
||||||
|
Ticket A 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
|
||||||
|
Ticket B 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
|
||||||
|
Ticket C 19 % 84.03 15.97 100.00
|
||||||
|
Ticket D 19 % 84.03 15.97 100.00
|
||||||
|
Ticket E 19 % 84.03 15.97 100.00
|
||||||
|
Sum 420.17 79.83 500.00
|
||||||
|
========== ========== ============================= ============================== =============
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 49 KiB |
@@ -23,6 +23,7 @@ partition "For every cart position" {
|
|||||||
--> "Store as line_price (gross), tax_rate"
|
--> "Store as line_price (gross), tax_rate"
|
||||||
}
|
}
|
||||||
--> "Apply discount engine"
|
--> "Apply discount engine"
|
||||||
|
--> "Apply tax rounding"
|
||||||
--> "Store as price (gross)"
|
--> "Store as price (gross)"
|
||||||
|
|
||||||
@enduml
|
@enduml
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
sphinx==7.4.*
|
sphinx==9.1.*
|
||||||
jinja2==3.1.*
|
sphinx-rtd-theme~=3.1.0
|
||||||
sphinx-rtd-theme
|
sphinxcontrib-httpdomain~=2.0.0
|
||||||
sphinxcontrib-httpdomain
|
sphinxcontrib-images~=1.0.1
|
||||||
sphinxcontrib-images
|
sphinxcontrib-jquery~=4.1
|
||||||
sphinxcontrib-jquery
|
sphinxcontrib-spelling~=8.0.2
|
||||||
sphinxcontrib-spelling==8.*
|
sphinxemoji~=0.3.2
|
||||||
sphinxemoji
|
|
||||||
pyenchant==3.3.*
|
pyenchant==3.3.*
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
-e ../
|
-e ../
|
||||||
sphinx==7.4.*
|
sphinx==9.1.*
|
||||||
jinja2==3.1.*
|
sphinx-rtd-theme~=3.1.0
|
||||||
sphinx-rtd-theme
|
sphinxcontrib-httpdomain~=2.0.0
|
||||||
sphinxcontrib-httpdomain
|
sphinxcontrib-images~=1.0.1
|
||||||
sphinxcontrib-images
|
sphinxcontrib-jquery~=4.1
|
||||||
sphinxcontrib-jquery
|
sphinxcontrib-spelling~=8.0.2
|
||||||
sphinxcontrib-spelling==8.*
|
sphinxemoji~=0.3.2
|
||||||
sphinxemoji
|
|
||||||
pyenchant==3.3.*
|
pyenchant==3.3.*
|
||||||
|
|||||||
+24
-24
@@ -3,7 +3,7 @@ name = "pretix"
|
|||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
description = "Reinventing presales, one ticket at a time"
|
description = "Reinventing presales, one ticket at a time"
|
||||||
readme = "README.rst"
|
readme = "README.rst"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.10"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
keywords = ["tickets", "web", "shop", "ecommerce"]
|
keywords = ["tickets", "web", "shop", "ecommerce"]
|
||||||
authors = [
|
authors = [
|
||||||
@@ -29,18 +29,19 @@ dependencies = [
|
|||||||
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
|
"arabic-reshaper==3.0.0", # Support for Arabic in reportlab
|
||||||
"babel",
|
"babel",
|
||||||
"BeautifulSoup4==4.14.*",
|
"BeautifulSoup4==4.14.*",
|
||||||
"bleach==6.2.*",
|
"bleach==6.3.*",
|
||||||
"celery==5.5.*",
|
"celery==5.6.*",
|
||||||
"chardet==5.2.*",
|
"chardet==5.2.*",
|
||||||
"cryptography>=44.0.0",
|
"cryptography>=44.0.0",
|
||||||
"css-inline==0.17.*",
|
"css-inline==0.20.*",
|
||||||
"defusedcsv>=1.1.0",
|
"defusedcsv>=1.1.0",
|
||||||
"Django[argon2]==4.2.*,>=4.2.24",
|
"dnspython==2.*",
|
||||||
"django-bootstrap3==25.2",
|
"Django[argon2]==4.2.*,>=4.2.26",
|
||||||
"django-compressor==4.5.1",
|
"django-bootstrap3==26.1",
|
||||||
"django-countries==7.6.*",
|
"django-compressor==4.6.0",
|
||||||
|
"django-countries==8.2.*",
|
||||||
"django-filter==25.1",
|
"django-filter==25.1",
|
||||||
"django-formset-js-improved==0.5.0.4",
|
"django-formset-js-improved==0.5.0.5",
|
||||||
"django-formtools==2.5.1",
|
"django-formtools==2.5.1",
|
||||||
"django-hierarkey==2.0.*,>=2.0.1",
|
"django-hierarkey==2.0.*,>=2.0.1",
|
||||||
"django-hijack==3.7.*",
|
"django-hijack==3.7.*",
|
||||||
@@ -49,22 +50,22 @@ dependencies = [
|
|||||||
"django-localflavor==5.0",
|
"django-localflavor==5.0",
|
||||||
"django-markup",
|
"django-markup",
|
||||||
"django-oauth-toolkit==2.3.*",
|
"django-oauth-toolkit==2.3.*",
|
||||||
"django-otp==1.6.*",
|
"django-otp==1.7.*",
|
||||||
"django-phonenumber-field==7.3.*",
|
"django-phonenumber-field==8.4.*",
|
||||||
"django-redis==6.0.*",
|
"django-redis==6.0.*",
|
||||||
"django-scopes==2.0.*",
|
"django-scopes==2.0.*",
|
||||||
"django-statici18n==2.6.*",
|
"django-statici18n==2.6.*",
|
||||||
"djangorestframework==3.16.*",
|
"djangorestframework==3.16.*",
|
||||||
"dnspython==2.7.*",
|
"dnspython==2.8.*",
|
||||||
"drf_ujson2==1.7.*",
|
"drf_ujson2==1.7.*",
|
||||||
"geoip2==5.*",
|
"geoip2==5.*",
|
||||||
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
"importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
|
||||||
"isoweek",
|
"isoweek",
|
||||||
"jsonschema",
|
"jsonschema",
|
||||||
"kombu==5.5.*",
|
"kombu==5.6.*",
|
||||||
"libsass==0.23.*",
|
"libsass==0.23.*",
|
||||||
"lxml",
|
"lxml",
|
||||||
"markdown==3.9", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
"markdown==3.10.2", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
|
||||||
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
|
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
|
||||||
"mt-940==4.30.*",
|
"mt-940==4.30.*",
|
||||||
"oauthlib==3.3.*",
|
"oauthlib==3.3.*",
|
||||||
@@ -72,33 +73,32 @@ dependencies = [
|
|||||||
"packaging",
|
"packaging",
|
||||||
"paypalrestsdk==1.13.*",
|
"paypalrestsdk==1.13.*",
|
||||||
"paypal-checkout-serversdk==1.0.*",
|
"paypal-checkout-serversdk==1.0.*",
|
||||||
"PyJWT==2.10.*",
|
"PyJWT==2.11.*",
|
||||||
"phonenumberslite==9.0.*",
|
"phonenumberslite==9.0.*",
|
||||||
"Pillow==11.3.*",
|
"Pillow==12.1.*",
|
||||||
"pretix-plugin-build",
|
"pretix-plugin-build",
|
||||||
"protobuf==6.33.*",
|
"protobuf==6.33.*",
|
||||||
"psycopg2-binary",
|
"psycopg2-binary",
|
||||||
"pycountry",
|
"pycountry",
|
||||||
"pycparser==2.23",
|
"pycparser==3.0",
|
||||||
"pycryptodome==3.23.*",
|
"pycryptodome==3.23.*",
|
||||||
"pypdf==6.1.*",
|
"pypdf==6.5.*",
|
||||||
"python-bidi==0.6.*", # Support for Arabic in reportlab
|
"python-bidi==0.6.*", # Support for Arabic in reportlab
|
||||||
"python-dateutil==2.9.*",
|
"python-dateutil==2.9.*",
|
||||||
"pytz",
|
"pytz",
|
||||||
"pytz-deprecation-shim==0.1.*",
|
"pytz-deprecation-shim==0.1.*",
|
||||||
"pyuca",
|
"pyuca",
|
||||||
"qrcode==8.2",
|
"qrcode==8.2",
|
||||||
"redis==6.4.*",
|
"redis==7.1.*",
|
||||||
"reportlab==4.4.*",
|
"reportlab==4.4.*",
|
||||||
"requests==2.32.*",
|
"requests==2.32.*",
|
||||||
"sentry-sdk==2.42.*",
|
"sentry-sdk==2.52.*",
|
||||||
"sepaxml==2.7.*",
|
"sepaxml==2.7.*",
|
||||||
"stripe==7.9.*",
|
"stripe==7.9.*",
|
||||||
"text-unidecode==1.*",
|
"text-unidecode==1.*",
|
||||||
"tlds>=2020041600",
|
"tlds>=2020041600",
|
||||||
"tqdm==4.*",
|
"tqdm==4.*",
|
||||||
"ua-parser==1.0.*",
|
"ua-parser==1.0.*",
|
||||||
"vat_moss_forked==2020.3.20.0.11.0",
|
|
||||||
"vobject==0.9.*",
|
"vobject==0.9.*",
|
||||||
"webauthn==2.7.*",
|
"webauthn==2.7.*",
|
||||||
"zeep==4.3.*"
|
"zeep==4.3.*"
|
||||||
@@ -110,10 +110,10 @@ dev = [
|
|||||||
"aiohttp==3.13.*",
|
"aiohttp==3.13.*",
|
||||||
"coverage",
|
"coverage",
|
||||||
"coveralls",
|
"coveralls",
|
||||||
"fakeredis==2.32.*",
|
"fakeredis==2.33.*",
|
||||||
"flake8==7.3.*",
|
"flake8==7.3.*",
|
||||||
"freezegun",
|
"freezegun",
|
||||||
"isort==6.1.*",
|
"isort==7.0.*",
|
||||||
"pep8-naming==0.15.*",
|
"pep8-naming==0.15.*",
|
||||||
"potypo",
|
"potypo",
|
||||||
"pytest-asyncio>=0.24",
|
"pytest-asyncio>=0.24",
|
||||||
@@ -123,7 +123,7 @@ dev = [
|
|||||||
"pytest-mock==3.15.*",
|
"pytest-mock==3.15.*",
|
||||||
"pytest-sugar",
|
"pytest-sugar",
|
||||||
"pytest-xdist==3.8.*",
|
"pytest-xdist==3.8.*",
|
||||||
"pytest==8.4.*",
|
"pytest==9.0.*",
|
||||||
"responses",
|
"responses",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -19,4 +19,4 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
__version__ = "2025.9.0"
|
__version__ = "2026.2.0.dev0"
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 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):
|
class WebHook(models.Model):
|
||||||
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
|
organizer = models.ForeignKey('pretixbase.Organizer', on_delete=models.CASCADE, related_name='webhooks')
|
||||||
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
|
enabled = models.BooleanField(default=True, verbose_name=_("Enable webhook"))
|
||||||
target_url = models.URLField(verbose_name=_("Target URL"), max_length=255)
|
target_url = models.URLField(verbose_name=_("Target URL"), max_length=1024)
|
||||||
all_events = models.BooleanField(default=True, verbose_name=_("All events (including newly created ones)"))
|
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)
|
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)
|
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):
|
class WebHookCall(models.Model):
|
||||||
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
|
webhook = models.ForeignKey('WebHook', on_delete=models.CASCADE, related_name='calls')
|
||||||
datetime = models.DateTimeField(auto_now_add=True)
|
datetime = models.DateTimeField(auto_now_add=True)
|
||||||
target_url = models.URLField(max_length=255)
|
target_url = models.URLField(max_length=1024)
|
||||||
action_type = models.CharField(max_length=255)
|
action_type = models.CharField(max_length=255)
|
||||||
is_retry = models.BooleanField(default=False)
|
is_retry = models.BooleanField(default=False)
|
||||||
execution_time = models.FloatField(null=True)
|
execution_time = models.FloatField(null=True)
|
||||||
|
|||||||
@@ -795,6 +795,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'invoice_address_asked',
|
'invoice_address_asked',
|
||||||
'invoice_address_required',
|
'invoice_address_required',
|
||||||
'invoice_address_vatid',
|
'invoice_address_vatid',
|
||||||
|
'invoice_address_vatid_required_countries',
|
||||||
'invoice_address_company_required',
|
'invoice_address_company_required',
|
||||||
'invoice_address_beneficiary',
|
'invoice_address_beneficiary',
|
||||||
'invoice_address_custom_field',
|
'invoice_address_custom_field',
|
||||||
@@ -805,6 +806,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'invoice_reissue_after_modify',
|
'invoice_reissue_after_modify',
|
||||||
'invoice_include_free',
|
'invoice_include_free',
|
||||||
'invoice_generate',
|
'invoice_generate',
|
||||||
|
'invoice_generate_only_business',
|
||||||
'invoice_period',
|
'invoice_period',
|
||||||
'invoice_numbers_consecutive',
|
'invoice_numbers_consecutive',
|
||||||
'invoice_numbers_prefix',
|
'invoice_numbers_prefix',
|
||||||
@@ -820,6 +822,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'invoice_address_from',
|
'invoice_address_from',
|
||||||
'invoice_address_from_zipcode',
|
'invoice_address_from_zipcode',
|
||||||
'invoice_address_from_city',
|
'invoice_address_from_city',
|
||||||
|
'invoice_address_from_state',
|
||||||
'invoice_address_from_country',
|
'invoice_address_from_country',
|
||||||
'invoice_address_from_tax_id',
|
'invoice_address_from_tax_id',
|
||||||
'invoice_address_from_vat_id',
|
'invoice_address_from_vat_id',
|
||||||
@@ -829,6 +832,7 @@ class EventSettingsSerializer(SettingsSerializer):
|
|||||||
'invoice_eu_currencies',
|
'invoice_eu_currencies',
|
||||||
'invoice_logo_image',
|
'invoice_logo_image',
|
||||||
'invoice_renderer_highlight_order_code',
|
'invoice_renderer_highlight_order_code',
|
||||||
|
'tax_rounding',
|
||||||
'cancel_allow_user',
|
'cancel_allow_user',
|
||||||
'cancel_allow_user_until',
|
'cancel_allow_user_until',
|
||||||
'cancel_allow_user_unpaid_keep',
|
'cancel_allow_user_unpaid_keep',
|
||||||
@@ -941,6 +945,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
|||||||
'invoice_address_asked',
|
'invoice_address_asked',
|
||||||
'invoice_address_required',
|
'invoice_address_required',
|
||||||
'invoice_address_vatid',
|
'invoice_address_vatid',
|
||||||
|
'invoice_address_vatid_required_countries',
|
||||||
'invoice_address_company_required',
|
'invoice_address_company_required',
|
||||||
'invoice_address_beneficiary',
|
'invoice_address_beneficiary',
|
||||||
'invoice_address_custom_field',
|
'invoice_address_custom_field',
|
||||||
@@ -951,6 +956,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
|
|||||||
'invoice_address_from',
|
'invoice_address_from',
|
||||||
'invoice_address_from_zipcode',
|
'invoice_address_from_zipcode',
|
||||||
'invoice_address_from_city',
|
'invoice_address_from_city',
|
||||||
|
'invoice_address_from_state',
|
||||||
'invoice_address_from_country',
|
'invoice_address_from_country',
|
||||||
'invoice_address_from_tax_id',
|
'invoice_address_from_tax_id',
|
||||||
'invoice_address_from_vat_id',
|
'invoice_address_from_vat_id',
|
||||||
|
|||||||
@@ -47,8 +47,9 @@ from pretix.api.serializers.event import MetaDataField
|
|||||||
from pretix.api.serializers.fields import UploadedFileField
|
from pretix.api.serializers.fields import UploadedFileField
|
||||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
|
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemProgramTime,
|
||||||
ItemVariationMetaValue, Question, QuestionOption, Quota, SalesChannel,
|
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
||||||
|
SalesChannel,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -187,6 +188,12 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer):
|
|||||||
'position', 'price_included', 'multi_allowed')
|
'position', 'price_included', 'multi_allowed')
|
||||||
|
|
||||||
|
|
||||||
|
class InlineItemProgramTimeSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = ItemProgramTime
|
||||||
|
fields = ('start', 'end')
|
||||||
|
|
||||||
|
|
||||||
class ItemBundleSerializer(serializers.ModelSerializer):
|
class ItemBundleSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ItemBundle
|
model = ItemBundle
|
||||||
@@ -212,6 +219,37 @@ class ItemBundleSerializer(serializers.ModelSerializer):
|
|||||||
return data
|
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 ItemAddOnSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ItemAddOn
|
model = ItemAddOn
|
||||||
@@ -250,6 +288,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
|||||||
addons = InlineItemAddOnSerializer(many=True, required=False)
|
addons = InlineItemAddOnSerializer(many=True, required=False)
|
||||||
bundles = InlineItemBundleSerializer(many=True, required=False)
|
bundles = InlineItemBundleSerializer(many=True, required=False)
|
||||||
variations = InlineItemVariationSerializer(many=True, required=False)
|
variations = InlineItemVariationSerializer(many=True, required=False)
|
||||||
|
program_times = InlineItemProgramTimeSerializer(many=True, required=False)
|
||||||
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
tax_rate = ItemTaxRateField(source='*', read_only=True)
|
||||||
meta_data = MetaDataField(required=False, source='*')
|
meta_data = MetaDataField(required=False, source='*')
|
||||||
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
|
||||||
@@ -271,7 +310,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
|||||||
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
|
'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
|
||||||
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
|
||||||
'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations',
|
'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations',
|
||||||
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
|
'addons', 'bundles', 'program_times', 'original_price', 'require_approval', 'generate_tickets',
|
||||||
'show_quota_left', 'hidden_if_available', 'hidden_if_item_available', 'hidden_if_item_available_mode', 'allow_waitinglist',
|
'show_quota_left', 'hidden_if_available', 'hidden_if_item_available', 'hidden_if_item_available_mode', 'allow_waitinglist',
|
||||||
'issue_giftcard', 'meta_data',
|
'issue_giftcard', 'meta_data',
|
||||||
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
|
'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type',
|
||||||
@@ -294,9 +333,9 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
|||||||
|
|
||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
data = super().validate(data)
|
data = super().validate(data)
|
||||||
if self.instance and ('addons' in data or 'variations' in data or 'bundles' in 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, or variations via PATCH/PUT is not supported. Please use the '
|
raise ValidationError(_('Updating add-ons, bundles, program times or variations via PATCH/PUT is not '
|
||||||
'dedicated nested endpoint.'))
|
'supported. Please use the dedicated nested endpoint.'))
|
||||||
|
|
||||||
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
|
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'))
|
Item.clean_available(data.get('available_from'), data.get('available_until'))
|
||||||
@@ -347,6 +386,13 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
|||||||
ItemAddOn.clean_max_min_count(addon_data.get('max_count', 0), addon_data.get('min_count', 0))
|
ItemAddOn.clean_max_min_count(addon_data.get('max_count', 0), addon_data.get('min_count', 0))
|
||||||
return value
|
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
|
@cached_property
|
||||||
def item_meta_properties(self):
|
def item_meta_properties(self):
|
||||||
return {
|
return {
|
||||||
@@ -364,6 +410,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
|||||||
variations_data = validated_data.pop('variations') if 'variations' in validated_data else {}
|
variations_data = validated_data.pop('variations') if 'variations' in validated_data else {}
|
||||||
addons_data = validated_data.pop('addons') if 'addons' 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 {}
|
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)
|
meta_data = validated_data.pop('meta_data', None)
|
||||||
picture = validated_data.pop('picture', None)
|
picture = validated_data.pop('picture', None)
|
||||||
require_membership_types = validated_data.pop('require_membership_types', [])
|
require_membership_types = validated_data.pop('require_membership_types', [])
|
||||||
@@ -398,6 +445,8 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
|||||||
ItemAddOn.objects.create(base_item=item, **addon_data)
|
ItemAddOn.objects.create(base_item=item, **addon_data)
|
||||||
for bundle_data in bundles_data:
|
for bundle_data in bundles_data:
|
||||||
ItemBundle.objects.create(base_item=item, **bundle_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
|
# Meta data
|
||||||
if meta_data is not None:
|
if meta_data is not None:
|
||||||
|
|||||||
@@ -52,9 +52,10 @@ from pretix.base.decimal import round_decimal
|
|||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.invoicing.transmission import get_transmission_types
|
from pretix.base.invoicing.transmission import get_transmission_types
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
|
CachedFile, Checkin, Customer, Device, Invoice, InvoiceAddress,
|
||||||
ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
|
InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question,
|
||||||
ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher,
|
QuestionAnswer, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule,
|
||||||
|
Voucher,
|
||||||
)
|
)
|
||||||
from pretix.base.models.orders import (
|
from pretix.base.models.orders import (
|
||||||
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
|
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
|
||||||
@@ -64,10 +65,13 @@ from pretix.base.pdf import get_images, get_variables
|
|||||||
from pretix.base.services.cart import error_messages
|
from pretix.base.services.cart import error_messages
|
||||||
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
|
from pretix.base.services.locking import LOCK_TRUST_WINDOW, lock_objects
|
||||||
from pretix.base.services.pricing import (
|
from pretix.base.services.pricing import (
|
||||||
apply_discounts, get_line_price, get_listed_price, is_included_for_free,
|
apply_discounts, apply_rounding, get_line_price, get_listed_price,
|
||||||
|
is_included_for_free,
|
||||||
)
|
)
|
||||||
from pretix.base.services.quotas import QuotaAvailability
|
from pretix.base.services.quotas import QuotaAvailability
|
||||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
from pretix.base.settings import (
|
||||||
|
COUNTRIES_WITH_STATE_IN_ADDRESS, ROUNDING_MODES,
|
||||||
|
)
|
||||||
from pretix.base.signals import register_ticket_outputs
|
from pretix.base.signals import register_ticket_outputs
|
||||||
from pretix.helpers.countries import CachedCountries
|
from pretix.helpers.countries import CachedCountries
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
@@ -187,7 +191,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
|
|||||||
{"transmission_info": {r: "This field is required for the selected type of invoice transmission."}}
|
{"transmission_info": {r: "This field is required for the selected type of invoice transmission."}}
|
||||||
)
|
)
|
||||||
break # do not call else branch of for loop
|
break # do not call else branch of for loop
|
||||||
elif t.exclusive:
|
elif t.is_exclusive(self.context["request"].event, data.get("country"), data.get("is_business")):
|
||||||
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
|
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
|
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
|
||||||
@@ -325,7 +329,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
class CheckinSerializer(I18nAwareModelSerializer):
|
class InlineCheckinSerializer(I18nAwareModelSerializer):
|
||||||
device_id = serializers.SlugRelatedField(
|
device_id = serializers.SlugRelatedField(
|
||||||
source='device',
|
source='device',
|
||||||
slug_field='device_id',
|
slug_field='device_id',
|
||||||
@@ -337,6 +341,21 @@ class CheckinSerializer(I18nAwareModelSerializer):
|
|||||||
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'device_id', 'type')
|
fields = ('id', 'datetime', 'list', 'auto_checked_in', 'gate', 'device', 'device_id', 'type')
|
||||||
|
|
||||||
|
|
||||||
|
class CheckinSerializer(I18nAwareModelSerializer):
|
||||||
|
device_id = serializers.SlugRelatedField(
|
||||||
|
source='device',
|
||||||
|
slug_field='device_id',
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Checkin
|
||||||
|
fields = (
|
||||||
|
'id', 'successful', 'error_reason', 'error_explanation', 'position', 'datetime', 'list', 'created',
|
||||||
|
'auto_checked_in', 'gate', 'device', 'device_id', 'type'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PrintLogSerializer(serializers.ModelSerializer):
|
class PrintLogSerializer(serializers.ModelSerializer):
|
||||||
device_id = serializers.SlugRelatedField(
|
device_id = serializers.SlugRelatedField(
|
||||||
source='device',
|
source='device',
|
||||||
@@ -560,7 +579,7 @@ class OrderPositionPluginDataField(serializers.Field):
|
|||||||
|
|
||||||
|
|
||||||
class OrderPositionSerializer(I18nAwareModelSerializer):
|
class OrderPositionSerializer(I18nAwareModelSerializer):
|
||||||
checkins = CheckinSerializer(many=True, read_only=True)
|
checkins = InlineCheckinSerializer(many=True, read_only=True)
|
||||||
print_logs = PrintLogSerializer(many=True, read_only=True)
|
print_logs = PrintLogSerializer(many=True, read_only=True)
|
||||||
answers = AnswerSerializer(many=True)
|
answers = AnswerSerializer(many=True)
|
||||||
downloads = PositionDownloadsField(source='*', read_only=True)
|
downloads = PositionDownloadsField(source='*', read_only=True)
|
||||||
@@ -685,6 +704,16 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
|
|||||||
if 'answers.question' in self.context['expand']:
|
if 'answers.question' in self.context['expand']:
|
||||||
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
|
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
|
||||||
|
|
||||||
|
if 'addons' in self.context['expand']:
|
||||||
|
# Experimental feature, undocumented on purpose for now in case we need to remove it again
|
||||||
|
# for performance reasons
|
||||||
|
subl = CheckinListOrderPositionSerializer(read_only=True, many=True, context={
|
||||||
|
**self.context,
|
||||||
|
'expand': [v for v in self.context['expand'] if v != 'addons'],
|
||||||
|
'pdf_data': False,
|
||||||
|
})
|
||||||
|
self.fields['addons'] = subl
|
||||||
|
|
||||||
|
|
||||||
class OrderPaymentTypeField(serializers.Field):
|
class OrderPaymentTypeField(serializers.Field):
|
||||||
# TODO: Remove after pretix 2.2
|
# TODO: Remove after pretix 2.2
|
||||||
@@ -833,14 +862,15 @@ class OrderSerializer(I18nAwareModelSerializer):
|
|||||||
list_serializer_class = OrderListSerializer
|
list_serializer_class = OrderListSerializer
|
||||||
fields = (
|
fields = (
|
||||||
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
|
'code', 'event', 'status', 'testmode', 'secret', 'email', 'phone', 'locale', 'datetime', 'expires', 'payment_date',
|
||||||
'payment_provider', 'fees', 'total', 'comment', 'custom_followup_at', 'invoice_address', 'positions', 'downloads',
|
'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'comment', 'custom_followup_at', 'invoice_address',
|
||||||
'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
|
'positions', 'downloads', 'checkin_attention', 'checkin_text', 'last_modified', 'payments', 'refunds',
|
||||||
'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date', 'plugin_data',
|
'require_approval', 'sales_channel', 'url', 'customer', 'valid_if_pending', 'api_meta', 'cancellation_date',
|
||||||
|
'plugin_data',
|
||||||
)
|
)
|
||||||
read_only_fields = (
|
read_only_fields = (
|
||||||
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
|
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
|
||||||
'payment_provider', 'fees', 'total', 'positions', 'downloads', 'customer',
|
'payment_provider', 'fees', 'total', 'tax_rounding_mode', 'positions', 'downloads', 'customer',
|
||||||
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date'
|
'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel', 'cancellation_date',
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -1159,6 +1189,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
queryset=SalesChannel.objects.none(),
|
queryset=SalesChannel.objects.none(),
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
tax_rounding_mode = serializers.ChoiceField(choices=ROUNDING_MODES, allow_null=True, required=False,)
|
||||||
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
|
locale = serializers.ChoiceField(choices=[], required=False, allow_null=True)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -1175,7 +1206,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
fields = ('code', 'status', 'testmode', 'email', 'phone', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
|
||||||
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
|
'invoice_address', 'positions', 'checkin_attention', 'checkin_text', 'payment_info', 'payment_date',
|
||||||
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
|
'consume_carts', 'force', 'send_email', 'simulate', 'customer', 'custom_followup_at',
|
||||||
'require_approval', 'valid_if_pending', 'expires', 'api_meta')
|
'require_approval', 'valid_if_pending', 'expires', 'api_meta', 'tax_rounding_mode')
|
||||||
|
|
||||||
def validate_payment_provider(self, pp):
|
def validate_payment_provider(self, pp):
|
||||||
if pp is None:
|
if pp is None:
|
||||||
@@ -1580,7 +1611,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
order.sales_channel,
|
order.sales_channel,
|
||||||
[
|
[
|
||||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
|
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
|
||||||
bool(cp.addon_to), cp.is_bundled, pos._voucher_discount)
|
cp.addon_to, cp.is_bundled, pos._voucher_discount)
|
||||||
for cp in order_positions
|
for cp in order_positions
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -1701,7 +1732,32 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
else:
|
else:
|
||||||
f.save()
|
f.save()
|
||||||
|
|
||||||
order.total += sum([f.value for f in fees])
|
rounding_mode = validated_data.get("tax_rounding_mode")
|
||||||
|
if not rounding_mode:
|
||||||
|
if isinstance(self.context.get("auth"), Device):
|
||||||
|
# Safety fallback to avoid differences in tax reporting
|
||||||
|
brand = self.context.get("auth").software_brand or ""
|
||||||
|
if "pretixPOS" in brand or "pretixKIOSK" in brand:
|
||||||
|
rounding_mode = "line"
|
||||||
|
if not rounding_mode:
|
||||||
|
rounding_mode = self.context["event"].settings.tax_rounding
|
||||||
|
changed = apply_rounding(
|
||||||
|
rounding_mode,
|
||||||
|
ia,
|
||||||
|
self.context["event"].currency,
|
||||||
|
[*pos_map.values(), *fees]
|
||||||
|
)
|
||||||
|
for line in changed:
|
||||||
|
if isinstance(line, OrderPosition):
|
||||||
|
line.save(update_fields=[
|
||||||
|
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
|
||||||
|
])
|
||||||
|
elif isinstance(line, OrderFee):
|
||||||
|
line.save(update_fields=[
|
||||||
|
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
|
||||||
|
])
|
||||||
|
|
||||||
|
order.total = sum([c.price for c in pos_map.values()]) + sum([f.value for f in fees])
|
||||||
if simulate:
|
if simulate:
|
||||||
order.fees = fees
|
order.fees = fees
|
||||||
order.positions = pos_map.values()
|
order.positions = pos_map.values()
|
||||||
@@ -1786,7 +1842,7 @@ class InvoiceSerializer(I18nAwareModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Invoice
|
model = Invoice
|
||||||
fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
|
fields = ('event', 'order', 'number', 'is_cancellation', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
|
||||||
'invoice_from_city', 'invoice_from_country', 'invoice_from_tax_id', 'invoice_from_vat_id',
|
'invoice_from_city', 'invoice_from_state', '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', '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_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',
|
'invoice_to_beneficiary', 'invoice_to_transmission_info', 'custom_field', 'date', 'refers', 'locale',
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from pretix.api.serializers.order import (
|
|||||||
OrderFeeCreateSerializer, OrderPositionCreateSerializer,
|
OrderFeeCreateSerializer, OrderPositionCreateSerializer,
|
||||||
)
|
)
|
||||||
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
|
from pretix.base.models import ItemVariation, Order, OrderFee, OrderPosition
|
||||||
from pretix.base.services.orders import OrderError
|
from pretix.base.services.orders import OrderChangeManager, OrderError
|
||||||
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -82,11 +82,11 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
ocm = self.context['ocm']
|
ocm: OrderChangeManager = self.context['ocm']
|
||||||
check_quotas = self.context.get('check_quotas', True)
|
check_quotas = self.context.get('check_quotas', True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ocm.add_position(
|
new_position = ocm.add_position(
|
||||||
item=validated_data['item'],
|
item=validated_data['item'],
|
||||||
variation=validated_data.get('variation'),
|
variation=validated_data.get('variation'),
|
||||||
price=validated_data.get('price'),
|
price=validated_data.get('price'),
|
||||||
@@ -98,7 +98,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
|
|||||||
)
|
)
|
||||||
if self.context.get('commit', True):
|
if self.context.get('commit', True):
|
||||||
ocm.commit(check_quotas=check_quotas)
|
ocm.commit(check_quotas=check_quotas)
|
||||||
return validated_data['order'].positions.order_by('-positionid').first()
|
return new_position.position
|
||||||
else:
|
else:
|
||||||
return OrderPosition() # fake to appease DRF
|
return OrderPosition() # fake to appease DRF
|
||||||
except OrderError as e:
|
except OrderError as e:
|
||||||
@@ -131,7 +131,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
ocm = self.context['ocm']
|
ocm: OrderChangeManager = self.context['ocm']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
f = OrderFee(
|
f = OrderFee(
|
||||||
@@ -146,7 +146,7 @@ class OrderFeeCreateForExistingOrderSerializer(OrderFeeCreateSerializer):
|
|||||||
ocm.add_fee(f)
|
ocm.add_fee(f)
|
||||||
if self.context.get('commit', True):
|
if self.context.get('commit', True):
|
||||||
ocm.commit()
|
ocm.commit()
|
||||||
return validated_data['order'].fees.order_by('-pk').first()
|
return f
|
||||||
else:
|
else:
|
||||||
return OrderFee() # fake to appease DRF
|
return OrderFee() # fake to appease DRF
|
||||||
except OrderError as e:
|
except OrderError as e:
|
||||||
@@ -310,7 +310,7 @@ class OrderPositionChangeSerializer(serializers.ModelSerializer):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
ocm = self.context['ocm']
|
ocm: OrderChangeManager = self.context['ocm']
|
||||||
check_quotas = self.context.get('check_quotas', True)
|
check_quotas = self.context.get('check_quotas', True)
|
||||||
current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None
|
current_seat = {'seat_guid': instance.seat.seat_guid} if instance.seat else None
|
||||||
item = validated_data.get('item', instance.item)
|
item = validated_data.get('item', instance.item)
|
||||||
@@ -399,7 +399,7 @@ class OrderFeeChangeSerializer(serializers.ModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
ocm = self.context['ocm']
|
ocm: OrderChangeManager = self.context['ocm']
|
||||||
value = validated_data.get('value', instance.value)
|
value = validated_data.get('value', instance.value)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ from pretix.base.plugins import (
|
|||||||
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
|
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
|
||||||
PLUGIN_LEVEL_ORGANIZER,
|
PLUGIN_LEVEL_ORGANIZER,
|
||||||
)
|
)
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import mail
|
||||||
from pretix.base.settings import validate_organizer_settings
|
from pretix.base.settings import validate_organizer_settings
|
||||||
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
@@ -363,24 +363,21 @@ class TeamInviteSerializer(serializers.ModelSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _send_invite(self, instance):
|
def _send_invite(self, instance):
|
||||||
try:
|
mail(
|
||||||
mail(
|
instance.email,
|
||||||
instance.email,
|
_('pretix account invitation'),
|
||||||
_('pretix account invitation'),
|
'pretixcontrol/email/invitation.txt',
|
||||||
'pretixcontrol/email/invitation.txt',
|
{
|
||||||
{
|
'user': self,
|
||||||
'user': self,
|
'organizer': self.context['organizer'].name,
|
||||||
'organizer': self.context['organizer'].name,
|
'team': instance.team.name,
|
||||||
'team': instance.team.name,
|
'url': build_global_uri('control:auth.invite', kwargs={
|
||||||
'url': build_global_uri('control:auth.invite', kwargs={
|
'token': instance.token
|
||||||
'token': instance.token
|
})
|
||||||
})
|
},
|
||||||
},
|
event=None,
|
||||||
event=None,
|
locale=get_language_without_region() # TODO: expose?
|
||||||
locale=get_language_without_region() # TODO: expose?
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
pass # Already logged
|
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
if 'email' in validated_data:
|
if 'email' in validated_data:
|
||||||
@@ -443,6 +440,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
|
|||||||
'customer_accounts',
|
'customer_accounts',
|
||||||
'customer_accounts_native',
|
'customer_accounts_native',
|
||||||
'customer_accounts_link_by_email',
|
'customer_accounts_link_by_email',
|
||||||
|
'customer_accounts_require_login_for_order_access',
|
||||||
'invoice_regenerate_allowed',
|
'invoice_regenerate_allowed',
|
||||||
'contact_mail',
|
'contact_mail',
|
||||||
'imprint_url',
|
'imprint_url',
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ event_router.register(r'taxrules', event.TaxRuleViewSet)
|
|||||||
event_router.register(r'seats', event.SeatViewSet)
|
event_router.register(r'seats', event.SeatViewSet)
|
||||||
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
|
||||||
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
|
||||||
|
event_router.register(r'checkins', checkin.CheckinViewSet)
|
||||||
event_router.register(r'cartpositions', cart.CartPositionViewSet)
|
event_router.register(r'cartpositions', cart.CartPositionViewSet)
|
||||||
event_router.register(r'scheduled_exports', exporters.ScheduledEventExportViewSet)
|
event_router.register(r'scheduled_exports', exporters.ScheduledEventExportViewSet)
|
||||||
event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters')
|
event_router.register(r'exporters', exporters.EventExportersViewSet, basename='exporters')
|
||||||
@@ -111,6 +112,7 @@ item_router = routers.DefaultRouter()
|
|||||||
item_router.register(r'variations', item.ItemVariationViewSet)
|
item_router.register(r'variations', item.ItemVariationViewSet)
|
||||||
item_router.register(r'addons', item.ItemAddOnViewSet)
|
item_router.register(r'addons', item.ItemAddOnViewSet)
|
||||||
item_router.register(r'bundles', item.ItemBundleViewSet)
|
item_router.register(r'bundles', item.ItemBundleViewSet)
|
||||||
|
item_router.register(r'program_times', item.ItemProgramTimeViewSet)
|
||||||
|
|
||||||
order_router = routers.DefaultRouter()
|
order_router = routers.DefaultRouter()
|
||||||
order_router.register(r'payments', order.PaymentViewSet)
|
order_router.register(r'payments', order.PaymentViewSet)
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ from pretix.api.serializers.checkin import (
|
|||||||
)
|
)
|
||||||
from pretix.api.serializers.item import QuestionSerializer
|
from pretix.api.serializers.item import QuestionSerializer
|
||||||
from pretix.api.serializers.order import (
|
from pretix.api.serializers.order import (
|
||||||
CheckinListOrderPositionSerializer, FailedCheckinSerializer,
|
CheckinListOrderPositionSerializer, CheckinSerializer,
|
||||||
|
FailedCheckinSerializer,
|
||||||
)
|
)
|
||||||
from pretix.api.views import RichOrderingFilter
|
from pretix.api.views import RichOrderingFilter
|
||||||
from pretix.api.views.order import OrderPositionFilter
|
from pretix.api.views.order import OrderPositionFilter
|
||||||
@@ -96,6 +97,16 @@ with scopes_disabled():
|
|||||||
)
|
)
|
||||||
return queryset.filter(expr)
|
return queryset.filter(expr)
|
||||||
|
|
||||||
|
class CheckinFilter(FilterSet):
|
||||||
|
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
|
||||||
|
created_before = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='lt')
|
||||||
|
datetime_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
|
||||||
|
datetime_before = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='lt')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Checkin
|
||||||
|
fields = ['successful', 'error_reason', 'list', 'type', 'gate', 'device', 'auto_checked_in']
|
||||||
|
|
||||||
|
|
||||||
class CheckinListViewSet(viewsets.ModelViewSet):
|
class CheckinListViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = CheckinListSerializer
|
serializer_class = CheckinListSerializer
|
||||||
@@ -370,15 +381,21 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
|||||||
|
|
||||||
qs = qs.filter(reduce(operator.or_, lists_qs))
|
qs = qs.filter(reduce(operator.or_, lists_qs))
|
||||||
|
|
||||||
|
prefetch_related = [
|
||||||
|
Prefetch(
|
||||||
|
lookup='checkins',
|
||||||
|
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
|
||||||
|
),
|
||||||
|
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
||||||
|
'answers', 'answers__options', 'answers__question',
|
||||||
|
]
|
||||||
|
select_related = [
|
||||||
|
'item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat'
|
||||||
|
]
|
||||||
|
|
||||||
if pdf_data:
|
if pdf_data:
|
||||||
qs = qs.prefetch_related(
|
qs = qs.prefetch_related(
|
||||||
Prefetch(
|
# Don't add to list, we don't want to propagate to addons
|
||||||
lookup='checkins',
|
|
||||||
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
|
|
||||||
),
|
|
||||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
|
||||||
'answers', 'answers__options', 'answers__question',
|
|
||||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
|
|
||||||
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
'event',
|
'event',
|
||||||
@@ -393,32 +410,39 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
))
|
))
|
||||||
).select_related(
|
|
||||||
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
|
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
qs = qs.prefetch_related(
|
|
||||||
Prefetch(
|
|
||||||
lookup='checkins',
|
|
||||||
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
|
|
||||||
),
|
|
||||||
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
|
|
||||||
'answers', 'answers__options', 'answers__question',
|
|
||||||
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
|
||||||
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
|
|
||||||
|
|
||||||
if expand and 'subevent' in expand:
|
if expand and 'subevent' in expand:
|
||||||
qs = qs.prefetch_related(
|
prefetch_related += [
|
||||||
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
|
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
|
||||||
'subevent__seat_category_mappings', 'subevent__meta_values'
|
'subevent__seat_category_mappings', 'subevent__meta_values'
|
||||||
)
|
]
|
||||||
|
|
||||||
if expand and 'item' in expand:
|
if expand and 'item' in expand:
|
||||||
qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values',
|
prefetch_related += [
|
||||||
'item__variations').select_related('item__tax_rule')
|
'item', 'item__addons', 'item__bundles', 'item__meta_values',
|
||||||
|
'item__variations',
|
||||||
|
]
|
||||||
|
select_related.append('item__tax_rule')
|
||||||
|
|
||||||
if expand and 'variation' in expand:
|
if expand and 'variation' in expand:
|
||||||
qs = qs.prefetch_related('variation', 'variation__meta_values')
|
prefetch_related += [
|
||||||
|
'variation', 'variation__meta_values',
|
||||||
|
]
|
||||||
|
|
||||||
|
if expand and 'addons' in expand:
|
||||||
|
prefetch_related += [
|
||||||
|
Prefetch('addons', OrderPosition.objects.prefetch_related(*prefetch_related).select_related(*select_related)),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
prefetch_related += [
|
||||||
|
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
|
||||||
|
]
|
||||||
|
|
||||||
|
if pdf_data:
|
||||||
|
select_related.remove("order") # Don't need it twice on this queryset
|
||||||
|
|
||||||
|
qs = qs.prefetch_related(*prefetch_related).select_related(*select_related)
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -955,6 +979,7 @@ class CheckinRPCSearchView(ListAPIView):
|
|||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
ctx['expand'] = self.request.query_params.getlist('expand')
|
ctx['expand'] = self.request.query_params.getlist('expand')
|
||||||
|
ctx['organizer'] = self.request.organizer
|
||||||
ctx['pdf_data'] = False
|
ctx['pdf_data'] = False
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@@ -1080,3 +1105,25 @@ class CheckinRPCAnnulView(views.APIView):
|
|||||||
checkin_annulled.send(ci.position.order.event, checkin=ci)
|
checkin_annulled.send(ci.position.order.event, checkin=ci)
|
||||||
|
|
||||||
return Response({"status": "ok"}, status=status.HTTP_200_OK)
|
return Response({"status": "ok"}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class CheckinViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
serializer_class = CheckinSerializer
|
||||||
|
queryset = Checkin.all.none()
|
||||||
|
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
|
||||||
|
filterset_class = CheckinFilter
|
||||||
|
ordering = ('created', 'id')
|
||||||
|
ordering_fields = ('created', 'datetime', 'id',)
|
||||||
|
permission = 'can_view_orders'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
qs = Checkin.all.filter().select_related(
|
||||||
|
"position",
|
||||||
|
"device",
|
||||||
|
)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
def get_serializer_context(self):
|
||||||
|
ctx = super().get_serializer_context()
|
||||||
|
ctx['event'] = self.request.event
|
||||||
|
return ctx
|
||||||
|
|||||||
@@ -74,6 +74,11 @@ class ExportersMixin:
|
|||||||
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
|
||||||
def download(self, *args, **kwargs):
|
def download(self, *args, **kwargs):
|
||||||
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
|
||||||
|
if not cf.allowed_for_session(self.request, "exporters-api"):
|
||||||
|
return Response(
|
||||||
|
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
|
||||||
|
status=status.HTTP_410_GONE
|
||||||
|
)
|
||||||
if cf.file:
|
if cf.file:
|
||||||
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
|
||||||
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
|
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
|
||||||
@@ -109,7 +114,8 @@ class ExportersMixin:
|
|||||||
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
cf = CachedFile(web_download=False)
|
cf = CachedFile(web_download=True)
|
||||||
|
cf.bind_to_session(self.request, "exporters-api")
|
||||||
cf.date = now()
|
cf.date = now()
|
||||||
cf.expires = now() + timedelta(hours=24)
|
cf.expires = now() + timedelta(hours=24)
|
||||||
cf.save()
|
cf.save()
|
||||||
|
|||||||
@@ -40,19 +40,19 @@ from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
|||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
from rest_framework import viewsets
|
from rest_framework import viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from pretix.api.pagination import TotalOrderingFilter
|
from pretix.api.pagination import TotalOrderingFilter
|
||||||
from pretix.api.serializers.item import (
|
from pretix.api.serializers.item import (
|
||||||
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer,
|
||||||
ItemSerializer, ItemVariationSerializer, QuestionOptionSerializer,
|
ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer,
|
||||||
QuestionSerializer, QuotaSerializer,
|
QuestionOptionSerializer, QuestionSerializer, QuotaSerializer,
|
||||||
)
|
)
|
||||||
from pretix.api.views import ConditionalListView
|
from pretix.api.views import ConditionalListView
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation,
|
CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime,
|
||||||
Question, QuestionOption, Quota,
|
ItemVariation, Question, QuestionOption, Quota,
|
||||||
)
|
)
|
||||||
from pretix.base.services.quotas import QuotaAvailability
|
from pretix.base.services.quotas import QuotaAvailability
|
||||||
from pretix.helpers.dicts import merge_dicts
|
from pretix.helpers.dicts import merge_dicts
|
||||||
@@ -106,7 +106,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
|
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
|
||||||
'variations__meta_values', 'variations__meta_values__property',
|
'variations__meta_values', 'variations__meta_values__property',
|
||||||
'require_membership_types', 'variations__require_membership_types',
|
'require_membership_types', 'variations__require_membership_types',
|
||||||
'limit_sales_channels', 'variations__limit_sales_channels',
|
'limit_sales_channels', 'variations__limit_sales_channels', 'program_times'
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
@@ -279,6 +279,59 @@ 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 = 'can_change_items'
|
||||||
|
|
||||||
|
@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):
|
class ItemAddOnViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = ItemAddOnSerializer
|
serializer_class = ItemAddOnSerializer
|
||||||
queryset = ItemAddOn.objects.none()
|
queryset = ItemAddOn.objects.none()
|
||||||
@@ -514,7 +567,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
write_permission = 'can_change_items'
|
write_permission = 'can_change_items'
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.event.quotas.all()
|
return self.request.event.quotas.select_related('subevent').prefetch_related('items', 'variations').all()
|
||||||
|
|
||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
queryset = self.filter_queryset(self.get_queryset()).distinct()
|
queryset = self.filter_queryset(self.get_queryset()).distinct()
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ from pretix.base.services.invoices import (
|
|||||||
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
|
||||||
regenerate_invoice, transmit_invoice,
|
regenerate_invoice, transmit_invoice,
|
||||||
)
|
)
|
||||||
from pretix.base.services.mail import SendMailException
|
|
||||||
from pretix.base.services.orders import (
|
from pretix.base.services.orders import (
|
||||||
OrderChangeManager, OrderError, _order_placed_email,
|
OrderChangeManager, OrderError, _order_placed_email,
|
||||||
_order_placed_email_attendee, approve_order, cancel_order, deny_order,
|
_order_placed_email_attendee, approve_order, cancel_order, deny_order,
|
||||||
@@ -344,6 +343,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
|||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
ctx['event'] = self.request.event
|
ctx['event'] = self.request.event
|
||||||
|
ctx['auth'] = self.request.auth
|
||||||
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
|
ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true'
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
@@ -438,8 +438,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
|||||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except PaymentException as e:
|
except PaymentException as e:
|
||||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except SendMailException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return self.retrieve(request, [], **kwargs)
|
return self.retrieve(request, [], **kwargs)
|
||||||
return Response(
|
return Response(
|
||||||
@@ -633,10 +631,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
|
|||||||
order = self.get_object()
|
order = self.get_object()
|
||||||
if not order.email:
|
if not order.email:
|
||||||
return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
try:
|
order.resend_link(user=self.request.user, auth=self.request.auth)
|
||||||
order.resend_link(user=self.request.user, auth=self.request.auth)
|
|
||||||
except SendMailException:
|
|
||||||
return Response({'detail': _('There was an error sending the mail. Please try again later.')}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
status=status.HTTP_204_NO_CONTENT
|
status=status.HTTP_204_NO_CONTENT
|
||||||
@@ -1615,8 +1610,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
)
|
)
|
||||||
except Quota.QuotaExceededException:
|
except Quota.QuotaExceededException:
|
||||||
pass
|
pass
|
||||||
except SendMailException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
serializer = OrderPaymentSerializer(r, context=serializer.context)
|
serializer = OrderPaymentSerializer(r, context=serializer.context)
|
||||||
|
|
||||||
@@ -1654,8 +1647,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
|
|||||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except PaymentException as e:
|
except PaymentException as e:
|
||||||
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except SendMailException:
|
|
||||||
pass
|
|
||||||
return self.retrieve(request, [], **kwargs)
|
return self.retrieve(request, [], **kwargs)
|
||||||
|
|
||||||
@action(detail=True, methods=['POST'])
|
@action(detail=True, methods=['POST'])
|
||||||
@@ -2030,7 +2021,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
|
|||||||
else:
|
else:
|
||||||
order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id)
|
order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id)
|
||||||
c = generate_cancellation(inv)
|
c = generate_cancellation(inv)
|
||||||
if inv.order.status != Order.STATUS_CANCELED:
|
if invoice_qualified(order):
|
||||||
inv = generate_invoice(order)
|
inv = generate_invoice(order)
|
||||||
else:
|
else:
|
||||||
inv = c
|
inv = c
|
||||||
|
|||||||
@@ -249,12 +249,17 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
|||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
value = serializer.validated_data.pop('value')
|
value = serializer.validated_data.pop('value')
|
||||||
inst = serializer.save(issuer=self.request.organizer)
|
inst = serializer.save(issuer=self.request.organizer)
|
||||||
inst.transactions.create(value=value, acceptor=self.request.organizer)
|
|
||||||
inst.log_action(
|
inst.log_action(
|
||||||
'pretix.giftcards.transaction.manual',
|
action='pretix.giftcards.created',
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
data=merge_dicts(self.request.data, {'id': inst.pk})
|
)
|
||||||
|
inst.transactions.create(value=value, acceptor=self.request.organizer)
|
||||||
|
inst.log_action(
|
||||||
|
action='pretix.giftcards.transaction.manual',
|
||||||
|
user=self.request.user,
|
||||||
|
auth=self.request.auth,
|
||||||
|
data=merge_dicts(self.request.data, {'id': inst.pk, 'acceptor_id': self.request.organizer.id})
|
||||||
)
|
)
|
||||||
|
|
||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
@@ -269,7 +274,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
|||||||
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
|
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
|
||||||
testmode=serializer.instance.testmode)
|
testmode=serializer.instance.testmode)
|
||||||
inst.log_action(
|
inst.log_action(
|
||||||
'pretix.giftcards.modified',
|
action='pretix.giftcards.modified',
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
data=self.request.data,
|
data=self.request.data,
|
||||||
@@ -282,10 +287,10 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
|||||||
diff = value - old_value
|
diff = value - old_value
|
||||||
inst.transactions.create(value=diff, acceptor=self.request.organizer)
|
inst.transactions.create(value=diff, acceptor=self.request.organizer)
|
||||||
inst.log_action(
|
inst.log_action(
|
||||||
'pretix.giftcards.transaction.manual',
|
action='pretix.giftcards.transaction.manual',
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
data={'value': diff}
|
data={'value': diff, 'acceptor_id': self.request.organizer.id}
|
||||||
)
|
)
|
||||||
|
|
||||||
return inst
|
return inst
|
||||||
@@ -309,10 +314,14 @@ class GiftCardViewSet(viewsets.ModelViewSet):
|
|||||||
}, status=status.HTTP_409_CONFLICT)
|
}, status=status.HTTP_409_CONFLICT)
|
||||||
gc.transactions.create(value=value, text=text, info=info, acceptor=self.request.organizer)
|
gc.transactions.create(value=value, text=text, info=info, acceptor=self.request.organizer)
|
||||||
gc.log_action(
|
gc.log_action(
|
||||||
'pretix.giftcards.transaction.manual',
|
action='pretix.giftcards.transaction.manual',
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
auth=self.request.auth,
|
auth=self.request.auth,
|
||||||
data={'value': value, 'text': text}
|
data={
|
||||||
|
'value': value,
|
||||||
|
'text': text,
|
||||||
|
'acceptor_id': self.request.organizer.id
|
||||||
|
}
|
||||||
)
|
)
|
||||||
return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK)
|
return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
@@ -721,7 +730,7 @@ class MembershipViewSet(viewsets.ModelViewSet):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return Membership.objects.filter(
|
return Membership.objects.filter(
|
||||||
customer__organizer=self.request.organizer
|
customer__organizer=self.request.organizer
|
||||||
)
|
).select_related('customer')
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
ctx = super().get_serializer_context()
|
ctx = super().get_serializer_context()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import F, Q
|
from django.db.models import F, Q
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
@@ -64,8 +65,13 @@ class VoucherViewSet(viewsets.ModelViewSet):
|
|||||||
permission = 'can_view_vouchers'
|
permission = 'can_view_vouchers'
|
||||||
write_permission = 'can_change_vouchers'
|
write_permission = 'can_change_vouchers'
|
||||||
|
|
||||||
|
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.event.vouchers.select_related('seat').all()
|
return Voucher.annotate_budget_used(
|
||||||
|
self.request.event.vouchers
|
||||||
|
).select_related(
|
||||||
|
'item', 'quota', 'seat', 'variation'
|
||||||
|
)
|
||||||
|
|
||||||
@transaction.atomic()
|
@transaction.atomic()
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
|||||||
from pretix.base.signals import periodic_task
|
from pretix.base.signals import periodic_task
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
from pretix.helpers import OF_SELF
|
from pretix.helpers import OF_SELF
|
||||||
|
from pretix.helpers.celery import get_task_priority
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_ALL_EVENTS = None
|
_ALL_EVENTS = None
|
||||||
@@ -173,6 +174,35 @@ 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,
|
||||||
|
'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,
|
||||||
|
'acceptor_id': logentry.parsed_data.get('acceptor_id'),
|
||||||
|
'giftcard': giftcard.pk,
|
||||||
|
'action': logentry.action_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent):
|
class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent):
|
||||||
|
|
||||||
def build_payload(self, logentry: LogEntry):
|
def build_payload(self, logentry: LogEntry):
|
||||||
@@ -432,6 +462,18 @@ def register_default_webhook_events(sender, **kwargs):
|
|||||||
'pretix.customer.anonymized',
|
'pretix.customer.anonymized',
|
||||||
_('Customer account anonymized'),
|
_('Customer account anonymized'),
|
||||||
),
|
),
|
||||||
|
ParametrizedGiftcardWebhookEvent(
|
||||||
|
'pretix.giftcards.created',
|
||||||
|
_('Gift card added'),
|
||||||
|
),
|
||||||
|
ParametrizedGiftcardWebhookEvent(
|
||||||
|
'pretix.giftcards.modified',
|
||||||
|
_('Gift card modified'),
|
||||||
|
),
|
||||||
|
ParametrizedGiftcardTransactionWebhookEvent(
|
||||||
|
'pretix.giftcards.transaction.*',
|
||||||
|
_('Gift card used in transcation'),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -474,7 +516,10 @@ def notify_webhooks(logentry_ids: list):
|
|||||||
)
|
)
|
||||||
|
|
||||||
for wh in webhooks:
|
for wh in webhooks:
|
||||||
send_webhook.apply_async(args=(logentry.id, notification_type.action_type, wh.pk))
|
send_webhook.apply_async(
|
||||||
|
args=(logentry.id, notification_type.action_type, wh.pk),
|
||||||
|
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=60, acks_late=True, autoretry_for=(DatabaseError,),)
|
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=60, acks_late=True, autoretry_for=(DatabaseError,),)
|
||||||
|
|||||||
@@ -112,23 +112,6 @@ def oidc_validate_and_complete_config(config):
|
|||||||
scope="openid",
|
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:
|
if "token_endpoint_auth_methods_supported" in provider_config:
|
||||||
token_endpoint_auth_methods_supported = provider_config.get("token_endpoint_auth_methods_supported",
|
token_endpoint_auth_methods_supported = provider_config.get("token_endpoint_auth_methods_supported",
|
||||||
["client_secret_basic"])
|
["client_secret_basic"])
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ StaticMapping = namedtuple('StaticMapping', ('id', 'pretix_model', 'external_obj
|
|||||||
|
|
||||||
class OutboundSyncProvider:
|
class OutboundSyncProvider:
|
||||||
max_attempts = 5
|
max_attempts = 5
|
||||||
|
list_field_joiner = "," # set to None to keep native lists in properties
|
||||||
|
|
||||||
def __init__(self, event):
|
def __init__(self, event):
|
||||||
self.event = event
|
self.event = event
|
||||||
@@ -281,7 +282,8 @@ class OutboundSyncProvider:
|
|||||||
'Please update value mapping for field "{field_name}" - option "{val}" not assigned'
|
'Please update value mapping for field "{field_name}" - option "{val}" not assigned'
|
||||||
).format(field_name=key, val=val)])
|
).format(field_name=key, val=val)])
|
||||||
|
|
||||||
val = ",".join(val)
|
if self.list_field_joiner:
|
||||||
|
val = self.list_field_joiner.join(val)
|
||||||
return val
|
return val
|
||||||
|
|
||||||
def get_properties(self, inputs: dict, property_mappings: List[dict]):
|
def get_properties(self, inputs: dict, property_mappings: List[dict]):
|
||||||
|
|||||||
@@ -71,15 +71,20 @@ def assign_properties(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _add_to_list(out, field_name, current_value, new_item, list_sep):
|
def _add_to_list(out, field_name, current_value, new_item_input, list_sep):
|
||||||
new_item = str(new_item)
|
|
||||||
if list_sep is not None:
|
if list_sep is not None:
|
||||||
new_item = new_item.replace(list_sep, "")
|
new_items = str(new_item_input).split(list_sep)
|
||||||
current_value = current_value.split(list_sep) if current_value else []
|
current_value = current_value.split(list_sep) if current_value else []
|
||||||
elif not isinstance(current_value, (list, tuple)):
|
else:
|
||||||
current_value = [str(current_value)]
|
new_items = [str(new_item_input)]
|
||||||
if new_item not in current_value:
|
if not isinstance(current_value, (list, tuple)):
|
||||||
new_list = current_value + [new_item]
|
current_value = [str(current_value)]
|
||||||
|
|
||||||
|
new_list = list(current_value)
|
||||||
|
for new_item in new_items:
|
||||||
|
if new_item not in current_value:
|
||||||
|
new_list.append(new_item)
|
||||||
|
if new_list != current_value:
|
||||||
if list_sep is not None:
|
if list_sep is not None:
|
||||||
new_list = list_sep.join(new_list)
|
new_list = list_sep.join(new_list)
|
||||||
out[field_name] = new_list
|
out[field_name] = new_list
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from itertools import groupby
|
|||||||
from smtplib import SMTPResponseException
|
from smtplib import SMTPResponseException
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
|
|
||||||
|
import bleach
|
||||||
import css_inline
|
import css_inline
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail.backends.smtp import EmailBackend
|
from django.core.mail.backends.smtp import EmailBackend
|
||||||
@@ -34,8 +35,11 @@ from django.utils.translation import get_language, gettext_lazy as _
|
|||||||
|
|
||||||
from pretix.base.models import Event
|
from pretix.base.models import Event
|
||||||
from pretix.base.signals import register_html_mail_renderers
|
from pretix.base.signals import register_html_mail_renderers
|
||||||
from pretix.base.templatetags.rich_text import markdown_compile_email
|
from pretix.base.templatetags.rich_text import (
|
||||||
from pretix.helpers.format import SafeFormatter, format_map
|
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.services.placeholders import ( # noqa
|
from pretix.base.services.placeholders import ( # noqa
|
||||||
get_available_placeholders, PlaceholderContext
|
get_available_placeholders, PlaceholderContext
|
||||||
@@ -133,13 +137,26 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
|||||||
def template_name(self):
|
def template_name(self):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def compile_markdown(self, plaintext):
|
def compile_markdown(self, plaintext, context=None):
|
||||||
return markdown_compile_email(plaintext)
|
return markdown_compile_email(plaintext, context=context)
|
||||||
|
|
||||||
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
|
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
|
||||||
body_md = self.compile_markdown(plain_body)
|
apply_format_map = not isinstance(plain_body, FormattedString)
|
||||||
|
body_md = self.compile_markdown(plain_body, context)
|
||||||
if context:
|
if context:
|
||||||
body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
|
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
|
||||||
|
)
|
||||||
htmlctx = {
|
htmlctx = {
|
||||||
'site': settings.PRETIX_INSTANCE_NAME,
|
'site': settings.PRETIX_INSTANCE_NAME,
|
||||||
'site_url': settings.SITE_URL,
|
'site_url': settings.SITE_URL,
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
|||||||
_('Invoice sender:') + ' ' + _('Address'),
|
_('Invoice sender:') + ' ' + _('Address'),
|
||||||
_('Invoice sender:') + ' ' + _('ZIP code'),
|
_('Invoice sender:') + ' ' + _('ZIP code'),
|
||||||
_('Invoice sender:') + ' ' + _('City'),
|
_('Invoice sender:') + ' ' + _('City'),
|
||||||
|
_('Invoice sender:') + ' ' + pgettext('address', 'State'),
|
||||||
_('Invoice sender:') + ' ' + _('Country'),
|
_('Invoice sender:') + ' ' + _('Country'),
|
||||||
_('Invoice sender:') + ' ' + _('Tax ID'),
|
_('Invoice sender:') + ' ' + _('Tax ID'),
|
||||||
_('Invoice sender:') + ' ' + _('VAT ID'),
|
_('Invoice sender:') + ' ' + _('VAT ID'),
|
||||||
@@ -291,6 +292,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
|||||||
i.invoice_from,
|
i.invoice_from,
|
||||||
i.invoice_from_zipcode,
|
i.invoice_from_zipcode,
|
||||||
i.invoice_from_city,
|
i.invoice_from_city,
|
||||||
|
i.invoice_from_state,
|
||||||
i.invoice_from_country,
|
i.invoice_from_country,
|
||||||
i.invoice_from_tax_id,
|
i.invoice_from_tax_id,
|
||||||
i.invoice_from_vat_id,
|
i.invoice_from_vat_id,
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class ItemDataExporter(ListExporter):
|
|||||||
row += [
|
row += [
|
||||||
_("Yes") if i.active and v.active else "",
|
_("Yes") if i.active and v.active else "",
|
||||||
", ".join([str(sn.label) for sn in sales_channels]),
|
", ".join([str(sn.label) for sn in sales_channels]),
|
||||||
v.default_price or i.default_price,
|
v.default_price if v.default_price is not None else i.default_price,
|
||||||
_("Yes") if i.free_price else "",
|
_("Yes") if i.free_price else "",
|
||||||
str(i.tax_rule) if i.tax_rule else "",
|
str(i.tax_rule) if i.tax_rule else "",
|
||||||
_("Yes") if i.admission else "",
|
_("Yes") if i.admission else "",
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ from zoneinfo import ZoneInfo
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import (
|
from django.db.models import (
|
||||||
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef,
|
Case, CharField, Count, DateTimeField, Exists, F, IntegerField, Max, Min,
|
||||||
Q, Subquery, Sum, When,
|
OuterRef, Q, Subquery, Sum, When,
|
||||||
)
|
)
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
@@ -144,6 +144,18 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
d = OrderedDict(d)
|
d = OrderedDict(d)
|
||||||
if not self.is_multievent and not self.event.has_subevents:
|
if not self.is_multievent and not self.event.has_subevents:
|
||||||
del d['event_date_range']
|
del d['event_date_range']
|
||||||
|
if not self.is_multievent:
|
||||||
|
d["items"] = forms.ModelMultipleChoiceField(
|
||||||
|
label=_("Products"),
|
||||||
|
queryset=self.event.items.all(),
|
||||||
|
widget=forms.CheckboxSelectMultiple(
|
||||||
|
attrs={"class": "scrolling-multiple-choice"}
|
||||||
|
),
|
||||||
|
help_text=_("If none are selected, all products are included. Orders are included if they contain "
|
||||||
|
"at least one position of this product. The order totals etc. still include all products "
|
||||||
|
"contained in the order."),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
def _get_all_payment_methods(self, qs):
|
def _get_all_payment_methods(self, qs):
|
||||||
@@ -249,6 +261,14 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
pcnt=Subquery(s, output_field=IntegerField())
|
pcnt=Subquery(s, output_field=IntegerField())
|
||||||
).select_related('invoice_address', 'customer')
|
).select_related('invoice_address', 'customer')
|
||||||
|
|
||||||
|
if form_data.get('items'):
|
||||||
|
qs = qs.filter(
|
||||||
|
Exists(OrderPosition.all.filter(
|
||||||
|
order=OuterRef('pk'),
|
||||||
|
item__in=form_data["items"]
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
qs = self._date_filter(qs, form_data, rel='')
|
qs = self._date_filter(qs, form_data, rel='')
|
||||||
|
|
||||||
if form_data['paid_only']:
|
if form_data['paid_only']:
|
||||||
@@ -364,7 +384,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
order.invoice_address.city,
|
order.invoice_address.city,
|
||||||
order.invoice_address.country if order.invoice_address.country else
|
order.invoice_address.country if order.invoice_address.country else
|
||||||
order.invoice_address.country_old,
|
order.invoice_address.country_old,
|
||||||
order.invoice_address.state,
|
order.invoice_address.state_for_address,
|
||||||
order.invoice_address.custom_field,
|
order.invoice_address.custom_field,
|
||||||
order.invoice_address.vat_id,
|
order.invoice_address.vat_id,
|
||||||
]
|
]
|
||||||
@@ -440,6 +460,14 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
if form_data['paid_only']:
|
if form_data['paid_only']:
|
||||||
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
||||||
|
|
||||||
|
if form_data.get('items'):
|
||||||
|
qs = qs.filter(
|
||||||
|
Exists(OrderPosition.all.filter(
|
||||||
|
order=OuterRef('order'),
|
||||||
|
item__in=form_data["items"]
|
||||||
|
))
|
||||||
|
)
|
||||||
|
|
||||||
qs = self._date_filter(qs, form_data, rel='order__')
|
qs = self._date_filter(qs, form_data, rel='order__')
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -515,7 +543,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
order.invoice_address.city,
|
order.invoice_address.city,
|
||||||
order.invoice_address.country if order.invoice_address.country else
|
order.invoice_address.country if order.invoice_address.country else
|
||||||
order.invoice_address.country_old,
|
order.invoice_address.country_old,
|
||||||
order.invoice_address.state,
|
order.invoice_address.state_for_address,
|
||||||
order.invoice_address.vat_id,
|
order.invoice_address.vat_id,
|
||||||
]
|
]
|
||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
@@ -535,6 +563,11 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
if form_data['paid_only']:
|
if form_data['paid_only']:
|
||||||
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
|
||||||
|
|
||||||
|
if form_data.get('items'):
|
||||||
|
qs = qs.filter(
|
||||||
|
item__in=form_data["items"]
|
||||||
|
)
|
||||||
|
|
||||||
qs = self._date_filter(qs, form_data, rel='order__')
|
qs = self._date_filter(qs, form_data, rel='order__')
|
||||||
return qs
|
return qs
|
||||||
|
|
||||||
@@ -610,13 +643,14 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
headers.append(_('Attendee name') + ': ' + str(label))
|
headers.append(_('Attendee name') + ': ' + str(label))
|
||||||
headers += [
|
headers += [
|
||||||
_('Attendee email'),
|
_('Attendee email'),
|
||||||
_('Company'),
|
_('Attendee company'),
|
||||||
_('Address'),
|
_('Address'),
|
||||||
_('ZIP code'),
|
_('ZIP code'),
|
||||||
_('City'),
|
_('City'),
|
||||||
_('Country'),
|
_('Country'),
|
||||||
pgettext('address', 'State'),
|
pgettext('address', 'State'),
|
||||||
_('Voucher'),
|
_('Voucher'),
|
||||||
|
_('Voucher budget usage'),
|
||||||
_('Pseudonymization ID'),
|
_('Pseudonymization ID'),
|
||||||
_('Ticket secret'),
|
_('Ticket secret'),
|
||||||
_('Seat ID'),
|
_('Seat ID'),
|
||||||
@@ -650,7 +684,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
options[q.pk].append(o)
|
options[q.pk].append(o)
|
||||||
headers.append(str(q.question))
|
headers.append(str(q.question))
|
||||||
headers += [
|
headers += [
|
||||||
_('Company'),
|
_('Invoice address company'),
|
||||||
_('Invoice address name'),
|
_('Invoice address name'),
|
||||||
]
|
]
|
||||||
if name_scheme and len(name_scheme['fields']) > 1:
|
if name_scheme and len(name_scheme['fields']) > 1:
|
||||||
@@ -732,8 +766,9 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
op.zipcode or '',
|
op.zipcode or '',
|
||||||
op.city or '',
|
op.city or '',
|
||||||
op.country if op.country else '',
|
op.country if op.country else '',
|
||||||
op.state or '',
|
op.state_for_address or '',
|
||||||
op.voucher.code if op.voucher else '',
|
op.voucher.code if op.voucher else '',
|
||||||
|
op.voucher_budget_use if op.voucher_budget_use else '',
|
||||||
op.pseudonymization_id,
|
op.pseudonymization_id,
|
||||||
op.secret,
|
op.secret,
|
||||||
]
|
]
|
||||||
@@ -797,7 +832,7 @@ class OrderListExporter(MultiSheetListExporter):
|
|||||||
order.invoice_address.city,
|
order.invoice_address.city,
|
||||||
order.invoice_address.country if order.invoice_address.country else
|
order.invoice_address.country if order.invoice_address.country else
|
||||||
order.invoice_address.country_old,
|
order.invoice_address.country_old,
|
||||||
order.invoice_address.state,
|
order.invoice_address.state_for_address,
|
||||||
order.invoice_address.vat_id,
|
order.invoice_address.vat_id,
|
||||||
]
|
]
|
||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
|||||||
@@ -214,21 +214,38 @@ class PasswordRecoverForm(forms.Form):
|
|||||||
error_messages = {
|
error_messages = {
|
||||||
'pw_mismatch': _("Please enter the same password twice"),
|
'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(
|
password = forms.CharField(
|
||||||
label=_('Password'),
|
label=_('Password'),
|
||||||
widget=forms.PasswordInput,
|
widget=forms.PasswordInput(attrs={
|
||||||
|
'autocomplete': 'new-password',
|
||||||
|
}),
|
||||||
max_length=4096,
|
max_length=4096,
|
||||||
required=True
|
required=True
|
||||||
)
|
)
|
||||||
password_repeat = forms.CharField(
|
password_repeat = forms.CharField(
|
||||||
label=_('Repeat password'),
|
label=_('Repeat password'),
|
||||||
widget=forms.PasswordInput,
|
widget=forms.PasswordInput(attrs={
|
||||||
|
'autocomplete': 'new-password',
|
||||||
|
}),
|
||||||
max_length=4096,
|
max_length=4096,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, user_id=None, *args, **kwargs):
|
def __init__(self, user_id=None, *args, **kwargs):
|
||||||
self.user_id = user_id
|
initial = kwargs.pop('initial', {})
|
||||||
super().__init__(*args, **kwargs)
|
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)
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
password1 = self.cleaned_data.get('password', '')
|
password1 = self.cleaned_data.get('password', '')
|
||||||
@@ -243,11 +260,7 @@ class PasswordRecoverForm(forms.Form):
|
|||||||
|
|
||||||
def clean_password(self):
|
def clean_password(self):
|
||||||
password1 = self.cleaned_data.get('password', '')
|
password1 = self.cleaned_data.get('password', '')
|
||||||
try:
|
if validate_password(password1, user=self.user) is not None:
|
||||||
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')
|
raise forms.ValidationError(_(password_validators_help_texts()), code='pw_invalid')
|
||||||
return password1
|
return password1
|
||||||
|
|
||||||
@@ -307,3 +320,10 @@ class ReauthForm(forms.Form):
|
|||||||
self.error_messages['inactive'],
|
self.error_messages['inactive'],
|
||||||
code='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,8 +66,10 @@ from geoip2.errors import AddressNotFoundError
|
|||||||
from phonenumber_field.formfields import PhoneNumberField
|
from phonenumber_field.formfields import PhoneNumberField
|
||||||
from phonenumber_field.phonenumber import PhoneNumber
|
from phonenumber_field.phonenumber import PhoneNumber
|
||||||
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
from phonenumber_field.widgets import PhoneNumberPrefixWidget
|
||||||
from phonenumbers import NumberParseException, national_significant_number
|
from phonenumbers import (
|
||||||
from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE
|
COUNTRY_CODE_TO_REGION_CODE, REGION_CODE_FOR_NON_GEO_ENTITY,
|
||||||
|
NumberParseException, national_significant_number,
|
||||||
|
)
|
||||||
from PIL import ImageOps
|
from PIL import ImageOps
|
||||||
|
|
||||||
from pretix.base.forms.widgets import (
|
from pretix.base.forms.widgets import (
|
||||||
@@ -83,7 +85,7 @@ from pretix.base.invoicing.transmission import (
|
|||||||
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
|
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
|
||||||
from pretix.base.models.tax import ask_for_vat_id
|
from pretix.base.models.tax import ask_for_vat_id
|
||||||
from pretix.base.services.tax import (
|
from pretix.base.services.tax import (
|
||||||
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
|
VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id,
|
||||||
)
|
)
|
||||||
from pretix.base.settings import (
|
from pretix.base.settings import (
|
||||||
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
|
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
|
||||||
@@ -305,7 +307,9 @@ class WrappedPhonePrefixSelect(Select):
|
|||||||
choices = [("", "---------")]
|
choices = [("", "---------")]
|
||||||
|
|
||||||
if initial:
|
if initial:
|
||||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
|
||||||
|
if all(v == REGION_CODE_FOR_NON_GEO_ENTITY for v in values):
|
||||||
|
continue
|
||||||
if initial in values:
|
if initial in values:
|
||||||
self.initial = "+%d" % prefix
|
self.initial = "+%d" % prefix
|
||||||
break
|
break
|
||||||
@@ -437,7 +441,9 @@ def guess_phone_prefix_from_request(request, event):
|
|||||||
|
|
||||||
|
|
||||||
def get_phone_prefix(country):
|
def get_phone_prefix(country):
|
||||||
for prefix, values in _COUNTRY_CODE_TO_REGION_CODE.items():
|
if country == REGION_CODE_FOR_NON_GEO_ENTITY:
|
||||||
|
return None
|
||||||
|
for prefix, values in COUNTRY_CODE_TO_REGION_CODE.items():
|
||||||
if country in values:
|
if country in values:
|
||||||
return prefix
|
return prefix
|
||||||
return None
|
return None
|
||||||
@@ -884,18 +890,18 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
if not help_text:
|
if not help_text:
|
||||||
if q.valid_date_min and q.valid_date_max:
|
if q.valid_date_min and q.valid_date_max:
|
||||||
help_text = format_lazy(
|
help_text = format_lazy(
|
||||||
'Please enter a date between {min} and {max}.',
|
_('Please enter a date between {min} and {max}.'),
|
||||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||||
)
|
)
|
||||||
elif q.valid_date_min:
|
elif q.valid_date_min:
|
||||||
help_text = format_lazy(
|
help_text = format_lazy(
|
||||||
'Please enter a date no earlier than {min}.',
|
_('Please enter a date no earlier than {min}.'),
|
||||||
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
|
||||||
)
|
)
|
||||||
elif q.valid_date_max:
|
elif q.valid_date_max:
|
||||||
help_text = format_lazy(
|
help_text = format_lazy(
|
||||||
'Please enter a date no later than {max}.',
|
_('Please enter a date no later than {max}.'),
|
||||||
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
|
||||||
)
|
)
|
||||||
if initial and initial.answer:
|
if initial and initial.answer:
|
||||||
@@ -933,18 +939,18 @@ class BaseQuestionsForm(forms.Form):
|
|||||||
if not help_text:
|
if not help_text:
|
||||||
if q.valid_datetime_min and q.valid_datetime_max:
|
if q.valid_datetime_min and q.valid_datetime_max:
|
||||||
help_text = format_lazy(
|
help_text = format_lazy(
|
||||||
'Please enter a date and time between {min} and {max}.',
|
_('Please enter a date and time between {min} and {max}.'),
|
||||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||||
)
|
)
|
||||||
elif q.valid_datetime_min:
|
elif q.valid_datetime_min:
|
||||||
help_text = format_lazy(
|
help_text = format_lazy(
|
||||||
'Please enter a date and time no earlier than {min}.',
|
_('Please enter a date and time no earlier than {min}.'),
|
||||||
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
|
||||||
)
|
)
|
||||||
elif q.valid_datetime_max:
|
elif q.valid_datetime_max:
|
||||||
help_text = format_lazy(
|
help_text = format_lazy(
|
||||||
'Please enter a date and time no later than {max}.',
|
_('Please enter a date and time no later than {max}.'),
|
||||||
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1165,13 +1171,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
self.fields['vat_id'].help_text = '<br/>'.join([
|
self.fields['vat_id'].help_text = '<br/>'.join([
|
||||||
str(_('Optional, but depending on the country you reside in we might need to charge you '
|
str(_('Optional, but depending on the country you reside in we might need to charge you '
|
||||||
'additional taxes if you do not enter it.')),
|
'additional taxes if you do not enter it.')),
|
||||||
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
|
|
||||||
])
|
])
|
||||||
else:
|
else:
|
||||||
self.fields['vat_id'].help_text = '<br/>'.join([
|
self.fields['vat_id'].help_text = '<br/>'.join([
|
||||||
str(_('Optional, but it might be required for you to claim tax benefits on your invoice '
|
str(_('Optional, but it might be required for you to claim tax benefits on your invoice '
|
||||||
'depending on your and the seller’s country of residence.')),
|
'depending on your and the seller’s country of residence.')),
|
||||||
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
transmission_type_choices = [
|
transmission_type_choices = [
|
||||||
@@ -1358,13 +1362,24 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
"transmission method.")}
|
"transmission method.")}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
vat_id_applicable = (
|
||||||
|
'vat_id' in self.fields and
|
||||||
|
data.get('is_business') and
|
||||||
|
ask_for_vat_id(data.get('country'))
|
||||||
|
)
|
||||||
|
vat_id_required = vat_id_applicable and str(data.get('country')) in self.event.settings.invoice_address_vatid_required_countries
|
||||||
|
if vat_id_required and not data.get('vat_id'):
|
||||||
|
raise ValidationError({
|
||||||
|
"vat_id": _("This field is required.")
|
||||||
|
})
|
||||||
|
|
||||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||||
pass
|
pass # Skip re-validation if it is validated
|
||||||
elif self.validate_vat_id and data.get('is_business') and ask_for_vat_id(data.get('country')) and data.get('vat_id'):
|
elif self.validate_vat_id and vat_id_applicable:
|
||||||
try:
|
try:
|
||||||
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
|
normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country')))
|
||||||
self.instance.vat_id_validated = True
|
self.instance.vat_id_validated = True
|
||||||
self.instance.vat_id = normalized_id
|
self.instance.vat_id = data['vat_id'] = normalized_id
|
||||||
except VATIDFinalError as e:
|
except VATIDFinalError as e:
|
||||||
if self.all_optional:
|
if self.all_optional:
|
||||||
self.instance.vat_id_validated = False
|
self.instance.vat_id_validated = False
|
||||||
@@ -1372,6 +1387,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
else:
|
else:
|
||||||
raise ValidationError({"vat_id": e.message})
|
raise ValidationError({"vat_id": e.message})
|
||||||
except VATIDTemporaryError as e:
|
except VATIDTemporaryError as e:
|
||||||
|
# We couldn't check it online, but we can still normalize it
|
||||||
|
normalized_id = normalize_vat_id(data.get('vat_id'), str(data.get('country')))
|
||||||
|
self.instance.vat_id = data['vat_id'] = normalized_id
|
||||||
self.instance.vat_id_validated = False
|
self.instance.vat_id_validated = False
|
||||||
if self.request and self.vat_warning:
|
if self.request and self.vat_warning:
|
||||||
messages.warning(self.request, e.message)
|
messages.warning(self.request, e.message)
|
||||||
@@ -1399,7 +1417,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
|||||||
|
|
||||||
self.instance.transmission_type = transmission_type.identifier
|
self.instance.transmission_type = transmission_type.identifier
|
||||||
self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data)
|
self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data)
|
||||||
elif transmission_type.exclusive:
|
elif transmission_type.is_exclusive(self.event, data.get("country"), data.get("is_business")):
|
||||||
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
|
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
|
"transmission_type": "The transmission type '%s' must be used for this country or address type." % (
|
||||||
|
|||||||
@@ -39,37 +39,16 @@ from django.contrib.auth.password_validation import (
|
|||||||
password_validators_help_texts, validate_password,
|
password_validators_help_texts, validate_password,
|
||||||
)
|
)
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
from django.urls.base import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from pytz import common_timezones
|
from pytz import common_timezones
|
||||||
|
|
||||||
from pretix.base.models import User
|
from pretix.base.models import User
|
||||||
from pretix.control.forms import SingleLanguageWidget
|
from pretix.control.forms import SingleLanguageWidget
|
||||||
|
from pretix.helpers.format import format_map
|
||||||
|
|
||||||
|
|
||||||
class UserSettingsForm(forms.ModelForm):
|
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(
|
timezone = forms.ChoiceField(
|
||||||
choices=((a, a) for a in common_timezones),
|
choices=((a, a) for a in common_timezones),
|
||||||
label=_("Default timezone"),
|
label=_("Default timezone"),
|
||||||
@@ -93,16 +72,63 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
self.user = kwargs.pop('user')
|
self.user = kwargs.pop('user')
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.fields['email'].required = True
|
self.fields['email'].required = True
|
||||||
if self.user.auth_backend != 'native':
|
self.fields['email'].disabled = True
|
||||||
del self.fields['old_pw']
|
self.fields['email'].help_text = format_map('<a href="{link}"><span class="fa fa-edit"></span> {text}</a>', {
|
||||||
del self.fields['new_pw']
|
'text': _("Change email address"),
|
||||||
del self.fields['new_pw_repeat']
|
'link': reverse('control:user.settings.email.change')
|
||||||
self.fields['email'].disabled = True
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
def clean_old_pw(self):
|
def clean_old_pw(self):
|
||||||
old_pw = self.cleaned_data.get('old_pw')
|
old_pw = self.cleaned_data.get('old_pw')
|
||||||
|
|
||||||
if old_pw and settings.HAS_REDIS:
|
if settings.HAS_REDIS:
|
||||||
from django_redis import get_redis_connection
|
from django_redis import get_redis_connection
|
||||||
rc = get_redis_connection("redis")
|
rc = get_redis_connection("redis")
|
||||||
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
|
cnt = rc.incr('pretix_pwchange_%s' % self.user.pk)
|
||||||
@@ -113,7 +139,7 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
code='rate_limit',
|
code='rate_limit',
|
||||||
)
|
)
|
||||||
|
|
||||||
if old_pw and not check_password(old_pw, self.user.password):
|
if not check_password(old_pw, self.user.password):
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
self.error_messages['pw_current_wrong'],
|
self.error_messages['pw_current_wrong'],
|
||||||
code='pw_current_wrong',
|
code='pw_current_wrong',
|
||||||
@@ -121,59 +147,47 @@ class UserSettingsForm(forms.ModelForm):
|
|||||||
|
|
||||||
return old_pw
|
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):
|
def clean_new_pw(self):
|
||||||
password1 = self.cleaned_data.get('new_pw', '')
|
password1 = self.cleaned_data.get('new_pw', '')
|
||||||
if password1 and validate_password(password1, user=self.user) is not None:
|
if validate_password(password1, user=self.user) is not None:
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
_(password_validators_help_texts()),
|
_(password_validators_help_texts()),
|
||||||
code='pw_invalid'
|
code='pw_invalid'
|
||||||
)
|
)
|
||||||
|
if self.user.check_password(password1):
|
||||||
|
raise forms.ValidationError(
|
||||||
|
self.error_messages['pw_equal'],
|
||||||
|
code='pw_equal',
|
||||||
|
)
|
||||||
return password1
|
return password1
|
||||||
|
|
||||||
def clean_new_pw_repeat(self):
|
def clean_new_pw_repeat(self):
|
||||||
password1 = self.cleaned_data.get('new_pw')
|
password1 = self.cleaned_data.get('new_pw')
|
||||||
password2 = self.cleaned_data.get('new_pw_repeat')
|
password2 = self.cleaned_data.get('new_pw_repeat')
|
||||||
if password1 and password1 != password2:
|
if password1 != password2:
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
self.error_messages['pw_mismatch'],
|
self.error_messages['pw_mismatch'],
|
||||||
code='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')
|
|
||||||
|
|
||||||
if (password1 or email != self.user.email) and not 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():
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
self.error_messages['pw_current'],
|
self.error_messages['duplicate_identifier'],
|
||||||
code='pw_current'
|
code='duplicate_identifier',
|
||||||
)
|
)
|
||||||
|
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)')),
|
|
||||||
))
|
|
||||||
|
|||||||
+50
-12
@@ -34,14 +34,13 @@
|
|||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from asgiref.local import Local
|
||||||
from babel import localedata
|
from babel import localedata
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import translation
|
from django.utils import translation
|
||||||
from django.utils.formats import date_format, number_format
|
from django.utils.formats import date_format, number_format
|
||||||
from django.utils.translation import gettext
|
from django.utils.translation import gettext
|
||||||
|
|
||||||
from pretix.base.templatetags.money import money_filter
|
|
||||||
|
|
||||||
from i18nfield.fields import ( # noqa
|
from i18nfield.fields import ( # noqa
|
||||||
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
|
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
|
||||||
)
|
)
|
||||||
@@ -51,6 +50,9 @@ from i18nfield.strings import LazyI18nString # noqa
|
|||||||
from i18nfield.utils import I18nJSONEncoder # noqa
|
from i18nfield.utils import I18nJSONEncoder # noqa
|
||||||
|
|
||||||
|
|
||||||
|
_active_region = Local()
|
||||||
|
|
||||||
|
|
||||||
class LazyDate:
|
class LazyDate:
|
||||||
def __init__(self, value):
|
def __init__(self, value):
|
||||||
self.value = value
|
self.value = value
|
||||||
@@ -86,6 +88,8 @@ class LazyCurrencyNumber:
|
|||||||
return self.__str__()
|
return self.__str__()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
from pretix.base.templatetags.money import money_filter
|
||||||
|
|
||||||
return money_filter(self.value, self.currency)
|
return money_filter(self.value, self.currency)
|
||||||
|
|
||||||
|
|
||||||
@@ -105,14 +109,41 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
|
|||||||
|
|
||||||
|
|
||||||
def get_babel_locale():
|
def get_babel_locale():
|
||||||
babel_locale = 'en'
|
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal
|
||||||
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal
|
# Also, this returns best-effort region information for number formatting etc
|
||||||
if translation.get_language():
|
current_language = translation.get_language()
|
||||||
if localedata.exists(translation.get_language()):
|
current_region = getattr(_active_region, "value", None)
|
||||||
babel_locale = translation.get_language()
|
|
||||||
elif localedata.exists(translation.get_language()[:2]):
|
# Babel only accepts locales that exist on the system. We try combinations in the following order:
|
||||||
babel_locale = translation.get_language()[:2]
|
# language-languageversion-region
|
||||||
return babel_locale
|
# language-region
|
||||||
|
# language-languageversion
|
||||||
|
# language
|
||||||
|
# fallback to system default
|
||||||
|
# fallback to english
|
||||||
|
|
||||||
|
try_locales = []
|
||||||
|
if current_language:
|
||||||
|
if "-" in current_language:
|
||||||
|
lng_parts = current_language.split("-")
|
||||||
|
if current_region:
|
||||||
|
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}_{current_region.upper()}")
|
||||||
|
try_locales.append(f"{lng_parts[0]}_{current_region.upper()}")
|
||||||
|
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].upper()}")
|
||||||
|
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}")
|
||||||
|
try_locales.append(f"{lng_parts[0]}")
|
||||||
|
else:
|
||||||
|
if current_region:
|
||||||
|
try_locales.append(f"{current_language}_{current_region.upper()}")
|
||||||
|
try_locales.append(f"{current_language}")
|
||||||
|
|
||||||
|
try_locales.append(settings.LANGUAGE_CODE)
|
||||||
|
|
||||||
|
for locale in try_locales:
|
||||||
|
if localedata.exists(locale):
|
||||||
|
return localedata.normalize_locale(locale)
|
||||||
|
|
||||||
|
return "en"
|
||||||
|
|
||||||
|
|
||||||
def get_language_without_region(lng=None):
|
def get_language_without_region(lng=None):
|
||||||
@@ -132,6 +163,10 @@ def get_language_without_region(lng=None):
|
|||||||
return lng
|
return lng
|
||||||
|
|
||||||
|
|
||||||
|
def set_region(region):
|
||||||
|
_active_region.value = region
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def language(lng, region=None):
|
def language(lng, region=None):
|
||||||
"""
|
"""
|
||||||
@@ -143,15 +178,18 @@ def language(lng, region=None):
|
|||||||
formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region``
|
formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region``
|
||||||
attribute will be ignored.
|
attribute will be ignored.
|
||||||
"""
|
"""
|
||||||
_lng = translation.get_language()
|
lng_before = translation.get_language()
|
||||||
|
region_before = getattr(_active_region, "value", None)
|
||||||
lng = lng or settings.LANGUAGE_CODE
|
lng = lng or settings.LANGUAGE_CODE
|
||||||
if '-' not in lng and region:
|
if '-' not in lng and region:
|
||||||
lng += '-' + region.lower()
|
lng += '-' + region.lower()
|
||||||
translation.activate(lng)
|
translation.activate(lng)
|
||||||
|
_active_region.value = region
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
translation.activate(_lng)
|
translation.activate(lng_before)
|
||||||
|
_active_region.value = region_before
|
||||||
|
|
||||||
|
|
||||||
class LazyLocaleException(Exception):
|
class LazyLocaleException(Exception):
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from pretix.base.invoicing.transmission import (
|
|||||||
transmission_types,
|
transmission_types,
|
||||||
)
|
)
|
||||||
from pretix.base.models import Invoice, InvoiceAddress
|
from pretix.base.models import Invoice, InvoiceAddress
|
||||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
from pretix.base.services.mail import mail, render_mail
|
||||||
from pretix.helpers.format import format_map
|
from pretix.helpers.format import format_map
|
||||||
|
|
||||||
|
|
||||||
@@ -133,41 +133,37 @@ class EmailTransmissionProvider(TransmissionProvider):
|
|||||||
template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString)
|
template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString)
|
||||||
subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString)
|
subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString)
|
||||||
|
|
||||||
try:
|
# Do not set to completed because that is done by the email sending task
|
||||||
# Do not set to completed because that is done by the email sending task
|
subject = format_map(subject, context)
|
||||||
subject = format_map(subject, context)
|
email_content = render_mail(template, context)
|
||||||
email_content = render_mail(template, context)
|
mail(
|
||||||
mail(
|
[recipient],
|
||||||
[recipient],
|
subject,
|
||||||
subject,
|
template,
|
||||||
template,
|
context=context,
|
||||||
context=context,
|
event=invoice.order.event,
|
||||||
event=invoice.order.event,
|
locale=invoice.order.locale,
|
||||||
locale=invoice.order.locale,
|
order=invoice.order,
|
||||||
order=invoice.order,
|
invoices=[invoice],
|
||||||
invoices=[invoice],
|
attach_tickets=False,
|
||||||
attach_tickets=False,
|
auto_email=True,
|
||||||
auto_email=True,
|
attach_ical=False,
|
||||||
attach_ical=False,
|
plain_text_only=True,
|
||||||
plain_text_only=True,
|
no_order_links=True,
|
||||||
no_order_links=True,
|
)
|
||||||
)
|
invoice.order.log_action(
|
||||||
except SendMailException:
|
'pretix.event.order.email.invoice',
|
||||||
raise
|
user=None,
|
||||||
else:
|
auth=None,
|
||||||
invoice.order.log_action(
|
data={
|
||||||
'pretix.event.order.email.invoice',
|
'subject': subject,
|
||||||
user=None,
|
'message': email_content,
|
||||||
auth=None,
|
'position': None,
|
||||||
data={
|
'recipient': recipient,
|
||||||
'subject': subject,
|
'invoices': [invoice.pk],
|
||||||
'message': email_content,
|
'attach_tickets': False,
|
||||||
'position': None,
|
'attach_ical': False,
|
||||||
'recipient': recipient,
|
'attach_other_files': [],
|
||||||
'invoices': [invoice.pk],
|
'attach_cached_files': [],
|
||||||
'attach_tickets': False,
|
}
|
||||||
'attach_ical': False,
|
)
|
||||||
'attach_other_files': [],
|
|
||||||
'attach_cached_files': [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -36,9 +36,11 @@ class ItalianSdITransmissionType(TransmissionType):
|
|||||||
identifier = "it_sdi"
|
identifier = "it_sdi"
|
||||||
verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)")
|
verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)")
|
||||||
public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)")
|
public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)")
|
||||||
exclusive = True
|
|
||||||
enforce_transmission = True
|
enforce_transmission = True
|
||||||
|
|
||||||
|
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
|
||||||
|
return str(country) == "IT"
|
||||||
|
|
||||||
def is_available(self, event, country: Country, is_business: bool):
|
def is_available(self, event, country: Country, is_business: bool):
|
||||||
return str(country) == "IT" and super().is_available(event, country, is_business)
|
return str(country) == "IT" and super().is_available(event, country, is_business)
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import datetime
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
|
import textwrap
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -31,7 +32,6 @@ from itertools import groupby
|
|||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
import bleach
|
import bleach
|
||||||
import vat_moss.exchange_rates
|
|
||||||
from bidi import get_display
|
from bidi import get_display
|
||||||
from django.contrib.staticfiles import finders
|
from django.contrib.staticfiles import finders
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
@@ -46,7 +46,6 @@ from reportlab.lib.styles import ParagraphStyle, StyleSheet1
|
|||||||
from reportlab.lib.units import mm
|
from reportlab.lib.units import mm
|
||||||
from reportlab.pdfbase import pdfmetrics
|
from reportlab.pdfbase import pdfmetrics
|
||||||
from reportlab.pdfbase.pdfmetrics import stringWidth
|
from reportlab.pdfbase.pdfmetrics import stringWidth
|
||||||
from reportlab.pdfbase.ttfonts import TTFont
|
|
||||||
from reportlab.pdfgen.canvas import Canvas
|
from reportlab.pdfgen.canvas import Canvas
|
||||||
from reportlab.platypus import (
|
from reportlab.platypus import (
|
||||||
BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate,
|
BaseDocTemplate, Flowable, Frame, KeepTogether, NextPageTemplate,
|
||||||
@@ -59,7 +58,8 @@ from pretix.base.services.currencies import SOURCE_NAMES
|
|||||||
from pretix.base.signals import register_invoice_renderers
|
from pretix.base.signals import register_invoice_renderers
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
from pretix.helpers.reportlab import (
|
from pretix.helpers.reportlab import (
|
||||||
FontFallbackParagraph, ThumbnailingImageReader, reshaper,
|
FontFallbackParagraph, ThumbnailingImageReader, register_ttf_font_if_new,
|
||||||
|
reshaper,
|
||||||
)
|
)
|
||||||
from pretix.presale.style import get_fonts
|
from pretix.presale.style import get_fonts
|
||||||
|
|
||||||
@@ -234,25 +234,25 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
|
|||||||
"""
|
"""
|
||||||
Register fonts with reportlab. By default, this registers the OpenSans font family
|
Register fonts with reportlab. By default, this registers the OpenSans font family
|
||||||
"""
|
"""
|
||||||
pdfmetrics.registerFont(TTFont('OpenSans', finders.find('fonts/OpenSans-Regular.ttf')))
|
register_ttf_font_if_new('OpenSans', finders.find('fonts/OpenSans-Regular.ttf'))
|
||||||
pdfmetrics.registerFont(TTFont('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf')))
|
register_ttf_font_if_new('OpenSansIt', finders.find('fonts/OpenSans-Italic.ttf'))
|
||||||
pdfmetrics.registerFont(TTFont('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf')))
|
register_ttf_font_if_new('OpenSansBd', finders.find('fonts/OpenSans-Bold.ttf'))
|
||||||
pdfmetrics.registerFont(TTFont('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf')))
|
register_ttf_font_if_new('OpenSansBI', finders.find('fonts/OpenSans-BoldItalic.ttf'))
|
||||||
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
|
pdfmetrics.registerFontFamily('OpenSans', normal='OpenSans', bold='OpenSansBd',
|
||||||
italic='OpenSansIt', boldItalic='OpenSansBI')
|
italic='OpenSansIt', boldItalic='OpenSansBI')
|
||||||
|
|
||||||
for family, styles in get_fonts(event=self.event, pdf_support_required=True).items():
|
for family, styles in get_fonts(event=self.event, pdf_support_required=True).items():
|
||||||
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
register_ttf_font_if_new(family, finders.find(styles['regular']['truetype']))
|
||||||
if family == self.event.settings.invoice_renderer_font:
|
if family == self.event.settings.invoice_renderer_font:
|
||||||
self.font_regular = family
|
self.font_regular = family
|
||||||
if 'bold' in styles:
|
if 'bold' in styles:
|
||||||
self.font_bold = family + ' B'
|
self.font_bold = family + ' B'
|
||||||
if 'italic' in styles:
|
if 'italic' in styles:
|
||||||
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
|
register_ttf_font_if_new(family + ' I', finders.find(styles['italic']['truetype']))
|
||||||
if 'bold' in styles:
|
if 'bold' in styles:
|
||||||
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
|
register_ttf_font_if_new(family + ' B', finders.find(styles['bold']['truetype']))
|
||||||
if 'bolditalic' in styles:
|
if 'bolditalic' in styles:
|
||||||
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
|
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
|
||||||
|
|
||||||
def _normalize(self, text):
|
def _normalize(self, text):
|
||||||
# reportlab does not support unicode combination characters
|
# reportlab does not support unicode combination characters
|
||||||
@@ -752,11 +752,59 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
return dt.astimezone(tz).date()
|
return dt.astimezone(tz).date()
|
||||||
|
|
||||||
total = Decimal('0.00')
|
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(
|
for (description, tax_rate, tax_name, net_value, gross_value, subevent, period_start, period_end), lines in addon_aware_groupby(
|
||||||
all_lines,
|
all_lines,
|
||||||
key=_group_key,
|
key=_group_key,
|
||||||
is_addon=lambda l: l.description.startswith(" +"),
|
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
|
# 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,
|
# 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
|
# however this will cause confusion (a) due to useless repetition of the same date all over the invoice
|
||||||
@@ -810,7 +858,10 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
# Group together at the end of the invoice
|
# Group together at the end of the invoice
|
||||||
request_show_service_date = period_line
|
request_show_service_date = period_line
|
||||||
elif period_line:
|
elif period_line:
|
||||||
description += "\n" + period_line
|
description_p_list.append(FontFallbackParagraph(
|
||||||
|
period_line,
|
||||||
|
self.stylesheet['Fineprint']
|
||||||
|
))
|
||||||
|
|
||||||
lines = list(lines)
|
lines = list(lines)
|
||||||
if has_taxes:
|
if has_taxes:
|
||||||
@@ -819,13 +870,13 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
net_price=money_filter(net_value, self.invoice.event.currency),
|
net_price=money_filter(net_value, self.invoice.event.currency),
|
||||||
gross_price=money_filter(gross_value, self.invoice.event.currency),
|
gross_price=money_filter(gross_value, self.invoice.event.currency),
|
||||||
)
|
)
|
||||||
description = description + "\n" + single_price_line
|
description_p_list.append(FontFallbackParagraph(
|
||||||
|
single_price_line,
|
||||||
|
self.stylesheet['Fineprint']
|
||||||
|
))
|
||||||
|
|
||||||
tdata.append((
|
tdata.append((
|
||||||
FontFallbackParagraph(
|
description_p_list.pop(0),
|
||||||
self._clean_text(description, tags=['br']),
|
|
||||||
self.stylesheet['Normal']
|
|
||||||
),
|
|
||||||
str(len(lines)),
|
str(len(lines)),
|
||||||
localize(tax_rate) + " %",
|
localize(tax_rate) + " %",
|
||||||
FontFallbackParagraph(
|
FontFallbackParagraph(
|
||||||
@@ -837,23 +888,52 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
self.stylesheet['NormalRight']
|
self.stylesheet['NormalRight']
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
|
for p in description_p_list:
|
||||||
|
tdata.append((p, "", "", "", ""))
|
||||||
|
tstyledata.append((
|
||||||
|
'SPAN',
|
||||||
|
(0, len(tdata) - 1),
|
||||||
|
(2, len(tdata) - 1),
|
||||||
|
))
|
||||||
else:
|
else:
|
||||||
if len(lines) > 1:
|
if len(lines) > 1:
|
||||||
single_price_line = pgettext('invoice', 'Single price: {price}').format(
|
single_price_line = pgettext('invoice', 'Single price: {price}').format(
|
||||||
price=money_filter(gross_value, self.invoice.event.currency),
|
price=money_filter(gross_value, self.invoice.event.currency),
|
||||||
)
|
)
|
||||||
description = description + "\n" + single_price_line
|
description_p_list.append(FontFallbackParagraph(
|
||||||
|
single_price_line,
|
||||||
|
self.stylesheet['Fineprint']
|
||||||
|
))
|
||||||
tdata.append((
|
tdata.append((
|
||||||
FontFallbackParagraph(
|
description_p_list.pop(0),
|
||||||
self._clean_text(description, tags=['br']),
|
|
||||||
self.stylesheet['Normal']
|
|
||||||
),
|
|
||||||
str(len(lines)),
|
str(len(lines)),
|
||||||
FontFallbackParagraph(
|
FontFallbackParagraph(
|
||||||
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '),
|
||||||
self.stylesheet['NormalRight']
|
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)
|
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
|
||||||
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
|
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
|
||||||
total += gross_value * len(lines)
|
total += gross_value * len(lines)
|
||||||
@@ -863,13 +943,11 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
|
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '', '', '',
|
||||||
money_filter(total, self.invoice.event.currency)
|
money_filter(total, self.invoice.event.currency)
|
||||||
])
|
])
|
||||||
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
|
|
||||||
else:
|
else:
|
||||||
tdata.append([
|
tdata.append([
|
||||||
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
|
FontFallbackParagraph(self._normalize(pgettext('invoice', 'Invoice total')), self.stylesheet['Bold']), '',
|
||||||
money_filter(total, self.invoice.event.currency)
|
money_filter(total, self.invoice.event.currency)
|
||||||
])
|
])
|
||||||
colwidths = [a * doc.width for a in (.65, .20, .15)]
|
|
||||||
|
|
||||||
if not self.invoice.is_cancellation:
|
if not self.invoice.is_cancellation:
|
||||||
if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING:
|
if self.invoice.event.settings.invoice_show_payments and self.invoice.order.status == Order.STATUS_PENDING:
|
||||||
@@ -980,7 +1058,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
|
|||||||
|
|
||||||
def fmt(val):
|
def fmt(val):
|
||||||
try:
|
try:
|
||||||
return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display)
|
return money_filter(val, self.invoice.foreign_currency_display)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return localize(val) + ' ' + self.invoice.foreign_currency_display
|
return localize(val) + ' ' + self.invoice.foreign_currency_display
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,11 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
import dns.resolver
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import gettext_lazy as _, pgettext
|
from django.utils.translation import gettext_lazy as _, pgettext
|
||||||
@@ -61,7 +64,7 @@ class PeppolIdValidator:
|
|||||||
"0020": "[0-9]{9}",
|
"0020": "[0-9]{9}",
|
||||||
"0201": "[0-9a-zA-Z]{6}",
|
"0201": "[0-9a-zA-Z]{6}",
|
||||||
"0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}",
|
"0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}",
|
||||||
"0208": "0[0-9]{9}",
|
"0208": "[01][0-9]{9}",
|
||||||
"0209": ".*",
|
"0209": ".*",
|
||||||
"0210": "[A-Z0-9]+",
|
"0210": "[A-Z0-9]+",
|
||||||
"0211": "IT[0-9]{11}",
|
"0211": "IT[0-9]{11}",
|
||||||
@@ -70,6 +73,9 @@ class PeppolIdValidator:
|
|||||||
"0205": "[A-Z0-9]+",
|
"0205": "[A-Z0-9]+",
|
||||||
"0221": "T[0-9]{13}",
|
"0221": "T[0-9]{13}",
|
||||||
"0230": ".*",
|
"0230": ".*",
|
||||||
|
"0244": "[0-9]{13}",
|
||||||
|
"0245": "[0-9]{10}",
|
||||||
|
"0246": "DE[0-9]{9}(-[0-9]{5})?(\\.[0-9A-Z]{1,8})?",
|
||||||
"9901": ".*",
|
"9901": ".*",
|
||||||
"9902": "[1-9][0-9]{7}",
|
"9902": "[1-9][0-9]{7}",
|
||||||
"9904": "DK[0-9]{8}",
|
"9904": "DK[0-9]{8}",
|
||||||
@@ -117,12 +123,14 @@ class PeppolIdValidator:
|
|||||||
"9951": ".*",
|
"9951": ".*",
|
||||||
"9952": ".*",
|
"9952": ".*",
|
||||||
"9953": ".*",
|
"9953": ".*",
|
||||||
"9954": ".*",
|
|
||||||
"9956": "0[0-9]{9}",
|
"9956": "0[0-9]{9}",
|
||||||
"9957": ".*",
|
"9957": ".*",
|
||||||
"9959": ".*",
|
"9959": ".*",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def __init__(self, validate_online=False):
|
||||||
|
self.validate_online = validate_online
|
||||||
|
|
||||||
def __call__(self, value):
|
def __call__(self, value):
|
||||||
if ":" not in value:
|
if ":" not in value:
|
||||||
raise ValidationError(_("A Peppol participant ID always starts with a prefix, followed by a colon (:)."))
|
raise ValidationError(_("A Peppol participant ID always starts with a prefix, followed by a colon (:)."))
|
||||||
@@ -136,6 +144,28 @@ class PeppolIdValidator:
|
|||||||
raise ValidationError(_("The Peppol participant ID does not match the validation rules for the prefix "
|
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."),
|
"%(number)s. Please reach out to us if you are sure this ID is correct."),
|
||||||
params={"number": prefix})
|
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
|
return value
|
||||||
|
|
||||||
|
|
||||||
@@ -149,13 +179,21 @@ class PeppolTransmissionType(TransmissionType):
|
|||||||
def is_available(self, event, country: Country, is_business: bool):
|
def is_available(self, event, country: Country, is_business: bool):
|
||||||
return is_business and super().is_available(event, country, is_business)
|
return is_business and super().is_available(event, country, is_business)
|
||||||
|
|
||||||
|
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
|
||||||
|
if is_business and str(country) == "BE" and event and event.settings.invoice_address_from_country == "BE":
|
||||||
|
# Peppol is required to be used for intra-Belgian B2B invoices
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def invoice_address_form_fields(self) -> dict:
|
def invoice_address_form_fields(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"transmission_peppol_participant_id": forms.CharField(
|
"transmission_peppol_participant_id": forms.CharField(
|
||||||
label=_("Peppol participant ID"),
|
label=_("Peppol participant ID"),
|
||||||
validators=[
|
validators=[
|
||||||
PeppolIdValidator(),
|
PeppolIdValidator(
|
||||||
|
validate_online=True,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
#
|
#
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_countries.fields import Country
|
from django_countries.fields import Country
|
||||||
|
|
||||||
from pretix.base.models import Invoice, InvoiceAddress
|
from pretix.base.models import Invoice, InvoiceAddress
|
||||||
@@ -58,15 +59,6 @@ class TransmissionType:
|
|||||||
"""
|
"""
|
||||||
return 100
|
return 100
|
||||||
|
|
||||||
@property
|
|
||||||
def exclusive(self) -> bool:
|
|
||||||
"""
|
|
||||||
If a transmission type is exclusive, no other type can be chosen if this type is
|
|
||||||
available. Use e.g. if a certain transmission type is legally required in a certain
|
|
||||||
jurisdiction.
|
|
||||||
"""
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enforce_transmission(self) -> bool:
|
def enforce_transmission(self) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -82,6 +74,15 @@ class TransmissionType:
|
|||||||
for provider, _ in providers
|
for provider, _ in providers
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
|
||||||
|
"""
|
||||||
|
If a transmission type is exclusive, no other type can be chosen if this type is
|
||||||
|
available. Use e.g. if a certain transmission type is legally required in a certain
|
||||||
|
jurisdiction. Event can be None in organizer-level contexts. Exclusiveness has no effect if
|
||||||
|
the type is not available.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
|
def invoice_address_form_fields_required(self, country: Country, is_business: bool):
|
||||||
return set()
|
return set()
|
||||||
|
|
||||||
@@ -106,6 +107,22 @@ class TransmissionType:
|
|||||||
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
|
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
|
||||||
return transmission_info
|
return transmission_info
|
||||||
|
|
||||||
|
def describe_info(self, transmission_info: dict, country: Country, is_business: bool):
|
||||||
|
form_data = self.transmission_info_to_form_data(transmission_info)
|
||||||
|
data = []
|
||||||
|
visible_field_keys = self.invoice_address_form_fields_visible(country, is_business)
|
||||||
|
for k, f in self.invoice_address_form_fields.items():
|
||||||
|
if k not in visible_field_keys:
|
||||||
|
continue
|
||||||
|
v = form_data.get(k)
|
||||||
|
if v is True:
|
||||||
|
v = _("Yes")
|
||||||
|
elif v is False:
|
||||||
|
v = _("No")
|
||||||
|
if v:
|
||||||
|
data.append((f.label, v))
|
||||||
|
return data
|
||||||
|
|
||||||
def pdf_watermark(self) -> Optional[str]:
|
def pdf_watermark(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Return a watermark that should be rendered across the PDF file.
|
Return a watermark that should be rendered across the PDF file.
|
||||||
|
|||||||
@@ -294,14 +294,28 @@ def metric_values():
|
|||||||
channel = app.broker_connection().channel()
|
channel = app.broker_connection().channel()
|
||||||
if hasattr(channel, 'client') and channel.client is not None:
|
if hasattr(channel, 'client') and channel.client is not None:
|
||||||
client = channel.client
|
client = channel.client
|
||||||
|
priority_steps = settings.CELERY_BROKER_TRANSPORT_OPTIONS.get("priority_steps", [0])
|
||||||
|
sep = settings.CELERY_BROKER_TRANSPORT_OPTIONS.get("sep", ":")
|
||||||
|
|
||||||
for q in settings.CELERY_TASK_QUEUES:
|
for q in settings.CELERY_TASK_QUEUES:
|
||||||
llen = client.llen(q.name)
|
queue_lengths = []
|
||||||
lfirst = client.lindex(q.name, -1)
|
queue_delays = []
|
||||||
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = llen
|
for prio in priority_steps:
|
||||||
if lfirst:
|
if prio:
|
||||||
ldata = json.loads(lfirst)
|
qname = f"{q.name}{sep}{prio}"
|
||||||
dt = time.time() - ldata.get('created', 0)
|
else:
|
||||||
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = dt
|
qname = q.name
|
||||||
|
queue_length = client.llen(qname)
|
||||||
|
queue_lengths.append(queue_length)
|
||||||
|
oldest_queue_item = client.lindex(qname, -1)
|
||||||
|
if oldest_queue_item:
|
||||||
|
ldata = json.loads(oldest_queue_item)
|
||||||
|
oldest_item_age = time.time() - ldata.get('created', 0)
|
||||||
|
queue_delays.append(oldest_item_age)
|
||||||
|
|
||||||
|
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = sum(queue_lengths)
|
||||||
|
if queue_delays:
|
||||||
|
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = max(queue_delays)
|
||||||
else:
|
else:
|
||||||
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0
|
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ from django.utils.translation.trans_real import (
|
|||||||
parse_accept_lang_header,
|
parse_accept_lang_header,
|
||||||
)
|
)
|
||||||
|
|
||||||
from pretix.base.i18n import get_language_without_region
|
from pretix.base.i18n import get_language_without_region, set_region
|
||||||
from pretix.base.settings import global_settings_object
|
from pretix.base.settings import global_settings_object
|
||||||
from pretix.multidomain.urlreverse import (
|
from pretix.multidomain.urlreverse import (
|
||||||
get_event_domain, get_organizer_domain,
|
get_event_domain, get_organizer_domain,
|
||||||
@@ -92,10 +92,14 @@ class LocaleMiddleware(MiddlewareMixin):
|
|||||||
)
|
)
|
||||||
if '-' not in language and settings_holder.settings.region:
|
if '-' not in language and settings_holder.settings.region:
|
||||||
language += '-' + settings_holder.settings.region
|
language += '-' + settings_holder.settings.region
|
||||||
|
if settings_holder.settings.region:
|
||||||
|
set_region(settings_holder.settings.region)
|
||||||
else:
|
else:
|
||||||
gs = global_settings_object(request)
|
gs = global_settings_object(request)
|
||||||
if '-' not in language and gs.settings.region:
|
if '-' not in language and gs.settings.region:
|
||||||
language += '-' + gs.settings.region
|
language += '-' + gs.settings.region
|
||||||
|
if gs.settings.region:
|
||||||
|
set_region(gs.settings.region)
|
||||||
|
|
||||||
translation.activate(language)
|
translation.activate(language)
|
||||||
request.LANGUAGE_CODE = get_language_without_region()
|
request.LANGUAGE_CODE = get_language_without_region()
|
||||||
|
|||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
# Generated by Django 4.2.17 on 2025-04-20 13:58
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("pretixbase", "0292_giftcard_customer"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="cartposition",
|
||||||
|
name="price_includes_rounding_correction",
|
||||||
|
field=models.DecimalField(
|
||||||
|
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="cartposition",
|
||||||
|
name="tax_code",
|
||||||
|
field=models.CharField(max_length=190, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="cartposition",
|
||||||
|
name="tax_value_includes_rounding_correction",
|
||||||
|
field=models.DecimalField(
|
||||||
|
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="orderfee",
|
||||||
|
name="tax_value_includes_rounding_correction",
|
||||||
|
field=models.DecimalField(
|
||||||
|
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="orderfee",
|
||||||
|
name="value_includes_rounding_correction",
|
||||||
|
field=models.DecimalField(
|
||||||
|
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="orderposition",
|
||||||
|
name="price_includes_rounding_correction",
|
||||||
|
field=models.DecimalField(
|
||||||
|
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="orderposition",
|
||||||
|
name="tax_value_includes_rounding_correction",
|
||||||
|
field=models.DecimalField(
|
||||||
|
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="transaction",
|
||||||
|
name="price_includes_rounding_correction",
|
||||||
|
field=models.DecimalField(
|
||||||
|
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="transaction",
|
||||||
|
name="tax_value_includes_rounding_correction",
|
||||||
|
field=models.DecimalField(
|
||||||
|
decimal_places=2, default=Decimal("0.00"), max_digits=13
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="order",
|
||||||
|
name="tax_rounding_mode",
|
||||||
|
field=models.CharField(default="line", max_length=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# Generated by Django 4.2.26 on 2026-01-22 13:44
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import pretix.base.models.mail
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("pretixbase", "0296_invoice_invoice_from_state"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="OutgoingMail",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True, primary_key=True, serialize=False
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("guid", models.UUIDField(db_index=True, default=uuid.uuid4)),
|
||||||
|
("status", models.CharField(default="queued", max_length=200)),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("sent", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("inflight_since", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("retry_after", models.DateTimeField(blank=True, null=True)),
|
||||||
|
("error", models.TextField(null=True)),
|
||||||
|
("error_detail", models.TextField(null=True)),
|
||||||
|
("sensitive", models.BooleanField(default=False)),
|
||||||
|
("subject", models.TextField()),
|
||||||
|
("body_plain", models.TextField()),
|
||||||
|
("body_html", models.TextField(null=True)),
|
||||||
|
("sender", models.CharField(max_length=500)),
|
||||||
|
("headers", models.JSONField(default=dict)),
|
||||||
|
("to", models.JSONField(default=list)),
|
||||||
|
("cc", models.JSONField(default=list)),
|
||||||
|
("bcc", models.JSONField(default=list)),
|
||||||
|
("recipient_count", models.IntegerField()),
|
||||||
|
("should_attach_tickets", models.BooleanField(default=False)),
|
||||||
|
("should_attach_ical", models.BooleanField(default=False)),
|
||||||
|
("should_attach_other_files", models.JSONField(default=list)),
|
||||||
|
("actual_attachments", models.JSONField(default=list)),
|
||||||
|
(
|
||||||
|
"customer",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
|
||||||
|
related_name="outgoing_mails",
|
||||||
|
to="pretixbase.customer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"event",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
|
||||||
|
related_name="outgoing_mails",
|
||||||
|
to="pretixbase.event",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"order",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
|
||||||
|
related_name="outgoing_mails",
|
||||||
|
to="pretixbase.order",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"orderposition",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
|
||||||
|
related_name="outgoing_mails",
|
||||||
|
to="pretixbase.orderposition",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"organizer",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="outgoing_mails",
|
||||||
|
to="pretixbase.organizer",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"should_attach_cached_files",
|
||||||
|
models.ManyToManyField(
|
||||||
|
related_name="outgoing_mails", to="pretixbase.cachedfile"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"should_attach_invoices",
|
||||||
|
models.ManyToManyField(
|
||||||
|
related_name="outgoing_mails", to="pretixbase.invoice"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="outgoing_mails",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ("-created",),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -47,6 +47,19 @@ class DataImportError(LazyLocaleException):
|
|||||||
super().__init__(msg)
|
super().__init__(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_duplicates(values):
|
||||||
|
used = set()
|
||||||
|
had_duplicates = False
|
||||||
|
for i, value in enumerate(values):
|
||||||
|
c = 0
|
||||||
|
while values[i] in used:
|
||||||
|
c += 1
|
||||||
|
values[i] = f'{value}__{c}'
|
||||||
|
had_duplicates = True
|
||||||
|
used.add(values[i])
|
||||||
|
return had_duplicates
|
||||||
|
|
||||||
|
|
||||||
def parse_csv(file, length=None, mode="strict", charset=None):
|
def parse_csv(file, length=None, mode="strict", charset=None):
|
||||||
file.seek(0)
|
file.seek(0)
|
||||||
data = file.read(length)
|
data = file.read(length)
|
||||||
@@ -70,6 +83,7 @@ def parse_csv(file, length=None, mode="strict", charset=None):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
|
reader = csv.DictReader(io.StringIO(data), dialect=dialect)
|
||||||
|
reader._had_duplicates = rename_duplicates(reader.fieldnames)
|
||||||
return reader
|
return reader
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,12 @@ from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
|
|||||||
from .invoices import Invoice, InvoiceLine, invoice_filename
|
from .invoices import Invoice, InvoiceLine, invoice_filename
|
||||||
from .items import (
|
from .items import (
|
||||||
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
|
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
|
||||||
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
|
ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question,
|
||||||
SubEventItem, SubEventItemVariation, itempicture_upload_to,
|
QuestionOption, Quota, SubEventItem, SubEventItemVariation,
|
||||||
|
itempicture_upload_to,
|
||||||
)
|
)
|
||||||
from .log import LogEntry
|
from .log import LogEntry
|
||||||
|
from .mail import OutgoingMail
|
||||||
from .media import ReusableMedium
|
from .media import ReusableMedium
|
||||||
from .memberships import Membership, MembershipType
|
from .memberships import Membership, MembershipType
|
||||||
from .notifications import NotificationSetting
|
from .notifications import NotificationSetting
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
import binascii
|
import binascii
|
||||||
import json
|
import json
|
||||||
import operator
|
import operator
|
||||||
|
import secrets
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from functools import reduce
|
from functools import reduce
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ from django.contrib.auth.models import (
|
|||||||
)
|
)
|
||||||
from django.contrib.auth.tokens import default_token_generator
|
from django.contrib.auth.tokens import default_token_generator
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.exceptions import BadRequest, PermissionDenied
|
||||||
from django.db import IntegrityError, models, transaction
|
from django.db import IntegrityError, models, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.crypto import get_random_string, salted_hmac
|
from django.utils.crypto import get_random_string, salted_hmac
|
||||||
@@ -51,7 +53,6 @@ from django.utils.timezone import now
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from django_otp.models import Device
|
from django_otp.models import Device
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
|
||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.helpers.urls import build_absolute_uri
|
from pretix.helpers.urls import build_absolute_uri
|
||||||
@@ -239,9 +240,11 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
|
|
||||||
USERNAME_FIELD = 'email'
|
USERNAME_FIELD = 'email'
|
||||||
REQUIRED_FIELDS = []
|
REQUIRED_FIELDS = []
|
||||||
|
MAX_CONFIRMATION_CODE_ATTEMPTS = 10
|
||||||
|
|
||||||
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
|
email = models.EmailField(unique=True, db_index=True, null=True, blank=True,
|
||||||
verbose_name=_('Email'), max_length=190)
|
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,
|
fullname = models.CharField(max_length=255, blank=True, null=True,
|
||||||
verbose_name=_('Full name'))
|
verbose_name=_('Full name'))
|
||||||
is_active = models.BooleanField(default=True,
|
is_active = models.BooleanField(default=True,
|
||||||
@@ -331,27 +334,95 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
|
|||||||
return self.email
|
return self.email
|
||||||
|
|
||||||
def send_security_notice(self, messages, email=None):
|
def send_security_notice(self, messages, email=None):
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import mail
|
||||||
|
|
||||||
try:
|
with language(self.locale):
|
||||||
with language(self.locale):
|
msg = '- ' + '\n- '.join(str(m) for m in messages)
|
||||||
msg = '- ' + '\n- '.join(str(m) for m in messages)
|
|
||||||
|
|
||||||
mail(
|
mail(
|
||||||
email or self.email,
|
email or self.email,
|
||||||
_('Account information changed'),
|
_('Account information changed'),
|
||||||
'pretixcontrol/email/security_notice.txt',
|
'pretixcontrol/email/security_notice.txt',
|
||||||
{
|
{
|
||||||
'user': self,
|
'user': self,
|
||||||
'messages': msg,
|
'messages': msg,
|
||||||
'url': build_absolute_uri('control:user.settings')
|
'url': build_absolute_uri('control:user.settings')
|
||||||
},
|
},
|
||||||
event=None,
|
event=None,
|
||||||
user=self,
|
user=self,
|
||||||
locale=self.locale
|
locale=self.locale
|
||||||
)
|
)
|
||||||
except SendMailException:
|
|
||||||
pass # Already logged
|
def send_confirmation_code(self, session, reason, email=None, state=None):
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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
|
||||||
|
|
||||||
def send_password_reset(self):
|
def send_password_reset(self):
|
||||||
from pretix.base.services.mail import mail
|
from pretix.base.services.mail import mail
|
||||||
@@ -633,6 +704,8 @@ class U2FDevice(Device):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def webauthndevice(self):
|
def webauthndevice(self):
|
||||||
|
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||||
|
|
||||||
d = json.loads(self.json_data)
|
d = json.loads(self.json_data)
|
||||||
return PublicKeyCredentialDescriptor(websafe_decode(d['keyHandle']))
|
return PublicKeyCredentialDescriptor(websafe_decode(d['keyHandle']))
|
||||||
|
|
||||||
@@ -662,6 +735,8 @@ class WebAuthnDevice(Device):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def webauthndevice(self):
|
def webauthndevice(self):
|
||||||
|
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||||
|
|
||||||
return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id))
|
return PublicKeyCredentialDescriptor(websafe_decode(self.credential_id))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from django.urls import reverse
|
|||||||
from django.utils.crypto import get_random_string
|
from django.utils.crypto import get_random_string
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
|
from pretix.helpers.celery import get_task_priority
|
||||||
from pretix.helpers.json import CustomJSONEncoder
|
from pretix.helpers.json import CustomJSONEncoder
|
||||||
|
|
||||||
|
|
||||||
@@ -58,6 +59,37 @@ class CachedFile(models.Model):
|
|||||||
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins
|
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins
|
||||||
session_key = models.TextField(null=True, blank=True) # only allow download in this session
|
session_key = models.TextField(null=True, blank=True) # only allow download in this session
|
||||||
|
|
||||||
|
def session_key_for_request(self, request, salt=None):
|
||||||
|
from ...api.models import OAuthAccessToken, OAuthApplication
|
||||||
|
from .devices import Device
|
||||||
|
from .organizer import TeamAPIToken
|
||||||
|
|
||||||
|
if hasattr(request, "auth") and isinstance(request.auth, OAuthAccessToken):
|
||||||
|
k = f'app:{request.auth.application.pk}'
|
||||||
|
elif hasattr(request, "auth") and isinstance(request.auth, OAuthApplication):
|
||||||
|
k = f'app:{request.auth.pk}'
|
||||||
|
elif hasattr(request, "auth") and isinstance(request.auth, TeamAPIToken):
|
||||||
|
k = f'token:{request.auth.pk}'
|
||||||
|
elif hasattr(request, "auth") and isinstance(request.auth, Device):
|
||||||
|
k = f'device:{request.auth.pk}'
|
||||||
|
elif request.session.session_key:
|
||||||
|
k = request.session.session_key
|
||||||
|
else:
|
||||||
|
raise ValueError("No auth method found to bind to")
|
||||||
|
|
||||||
|
if salt:
|
||||||
|
k = f"{k}!{salt}"
|
||||||
|
return k
|
||||||
|
|
||||||
|
def allowed_for_session(self, request, salt=None):
|
||||||
|
return (
|
||||||
|
not self.session_key or
|
||||||
|
self.session_key_for_request(request, salt) == self.session_key
|
||||||
|
)
|
||||||
|
|
||||||
|
def bind_to_session(self, request, salt=None):
|
||||||
|
self.session_key = self.session_key_for_request(request, salt)
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_delete, sender=CachedFile)
|
@receiver(post_delete, sender=CachedFile)
|
||||||
def cached_file_delete(sender, instance, **kwargs):
|
def cached_file_delete(sender, instance, **kwargs):
|
||||||
@@ -98,6 +130,8 @@ class LoggingMixin:
|
|||||||
organizer_id = self.event.organizer_id
|
organizer_id = self.event.organizer_id
|
||||||
elif hasattr(self, 'organizer_id'):
|
elif hasattr(self, 'organizer_id'):
|
||||||
organizer_id = self.organizer_id
|
organizer_id = self.organizer_id
|
||||||
|
elif hasattr(self, 'issuer_id'):
|
||||||
|
organizer_id = self.issuer_id
|
||||||
|
|
||||||
if user and not user.is_authenticated:
|
if user and not user.is_authenticated:
|
||||||
user = None
|
user = None
|
||||||
@@ -131,9 +165,15 @@ class LoggingMixin:
|
|||||||
logentry.save()
|
logentry.save()
|
||||||
|
|
||||||
if logentry.notification_type:
|
if logentry.notification_type:
|
||||||
notify.apply_async(args=(logentry.pk,))
|
notify.apply_async(
|
||||||
|
args=(logentry.pk,),
|
||||||
|
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||||
|
)
|
||||||
if logentry.webhook_type:
|
if logentry.webhook_type:
|
||||||
notify_webhooks.apply_async(args=(logentry.pk,))
|
notify_webhooks.apply_async(
|
||||||
|
args=(logentry.pk,),
|
||||||
|
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||||
|
)
|
||||||
|
|
||||||
return logentry
|
return logentry
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from i18nfield.fields import I18nCharField
|
|||||||
from phonenumber_field.modelfields import PhoneNumberField
|
from phonenumber_field.modelfields import PhoneNumberField
|
||||||
|
|
||||||
from pretix.base.banlist import banned
|
from pretix.base.banlist import banned
|
||||||
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
from pretix.base.models.fields import MultiStringField
|
from pretix.base.models.fields import MultiStringField
|
||||||
from pretix.base.models.giftcards import GiftCardTransaction
|
from pretix.base.models.giftcards import GiftCardTransaction
|
||||||
@@ -164,6 +165,28 @@ class Customer(LoggedModel):
|
|||||||
self.attendee_profiles.all().delete()
|
self.attendee_profiles.all().delete()
|
||||||
self.invoice_addresses.all().delete()
|
self.invoice_addresses.all().delete()
|
||||||
|
|
||||||
|
def send_security_notice(self, message, email=None):
|
||||||
|
from pretix.base.services.mail import SendMailException, mail
|
||||||
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
|
|
||||||
|
try:
|
||||||
|
with language(self.locale):
|
||||||
|
mail(
|
||||||
|
email or self.email,
|
||||||
|
self.organizer.settings.mail_subject_customer_security_notice,
|
||||||
|
self.organizer.settings.mail_text_customer_security_notice,
|
||||||
|
{
|
||||||
|
**self.get_email_context(),
|
||||||
|
'message': str(message),
|
||||||
|
'url': build_absolute_uri(self.organizer, 'presale:organizer.customer.index')
|
||||||
|
},
|
||||||
|
customer=self,
|
||||||
|
organizer=self.organizer,
|
||||||
|
locale=self.locale
|
||||||
|
)
|
||||||
|
except SendMailException:
|
||||||
|
pass # Already logged
|
||||||
|
|
||||||
@scopes_disabled()
|
@scopes_disabled()
|
||||||
def assign_identifier(self):
|
def assign_identifier(self):
|
||||||
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789')
|
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789')
|
||||||
@@ -293,6 +316,7 @@ class Customer(LoggedModel):
|
|||||||
locale=self.locale,
|
locale=self.locale,
|
||||||
customer=self,
|
customer=self,
|
||||||
organizer=self.organizer,
|
organizer=self.organizer,
|
||||||
|
sensitive=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def usable_gift_cards(self, used_cards=[]):
|
def usable_gift_cards(self, used_cards=[]):
|
||||||
@@ -349,7 +373,7 @@ class AttendeeProfile(models.Model):
|
|||||||
def state_name(self):
|
def state_name(self):
|
||||||
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||||
if sd:
|
if sd:
|
||||||
return sd.name
|
return _(sd.name)
|
||||||
return self.state
|
return self.state
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from pretix.base.decimal import round_decimal
|
|||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
|
|
||||||
PositionInfo = namedtuple('PositionInfo',
|
PositionInfo = namedtuple('PositionInfo',
|
||||||
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'is_addon_to',
|
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
|
||||||
'voucher_discount'])
|
'voucher_discount'])
|
||||||
|
|
||||||
|
|
||||||
@@ -279,6 +279,42 @@ class Discount(LoggedModel):
|
|||||||
for idx in condition_idx_group:
|
for idx in condition_idx_group:
|
||||||
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
|
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
|
||||||
|
|
||||||
|
def _addon_idx(self, positions, idx):
|
||||||
|
"""
|
||||||
|
If we have the following cart:
|
||||||
|
|
||||||
|
- Main product
|
||||||
|
- 10x Addon product 5€
|
||||||
|
- Main product
|
||||||
|
- 10x Addon product 5€
|
||||||
|
|
||||||
|
And we have a discount rule that grants "every 10th product is free", people tend to expect
|
||||||
|
|
||||||
|
- Main product
|
||||||
|
- 9x Addon product 5€
|
||||||
|
- 1x Addon product free
|
||||||
|
- Main product
|
||||||
|
- 9x Addon product 5€
|
||||||
|
- 1x Addon product free
|
||||||
|
|
||||||
|
And get confused if they get
|
||||||
|
|
||||||
|
- Main product
|
||||||
|
- 8x Addon product 5€
|
||||||
|
- 2x Addon product free
|
||||||
|
- Main product
|
||||||
|
- 10x Addon product 5€
|
||||||
|
|
||||||
|
Even if the result is the same. Therefore, we sort positions in the cart not only by price, but also by their
|
||||||
|
relative index within their addon group. This is only a heuristic and there are *still* scenarios where the more
|
||||||
|
unexpected version happens, e.g. if prices are different. We need to accept this as long as discounts work on
|
||||||
|
cart level and not on addon-group level, but this simple sorting reduces the number of support issues by making
|
||||||
|
the weird case less likely.
|
||||||
|
"""
|
||||||
|
if not positions[idx].addon_to:
|
||||||
|
return 0
|
||||||
|
return len([1 for i, p in positions.items() if i < idx and p.addon_to == positions[idx].addon_to])
|
||||||
|
|
||||||
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
|
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
|
||||||
if len(condition_idx_group) < self.condition_min_count:
|
if len(condition_idx_group) < self.condition_min_count:
|
||||||
return
|
return
|
||||||
@@ -288,8 +324,8 @@ class Discount(LoggedModel):
|
|||||||
|
|
||||||
if self.benefit_only_apply_to_cheapest_n_matches:
|
if self.benefit_only_apply_to_cheapest_n_matches:
|
||||||
# sort by line_price
|
# sort by line_price
|
||||||
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
|
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
|
||||||
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
|
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
|
||||||
|
|
||||||
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
||||||
# want to match multiples of 3
|
# want to match multiples of 3
|
||||||
@@ -434,7 +470,7 @@ class Discount(LoggedModel):
|
|||||||
for idx, p in positions.items():
|
for idx, p in positions.items():
|
||||||
subevent_to_idx[p.subevent_id].append(idx)
|
subevent_to_idx[p.subevent_id].append(idx)
|
||||||
for v in subevent_to_idx.values():
|
for v in subevent_to_idx.values():
|
||||||
v.sort(key=lambda idx: positions[idx].line_price_gross)
|
v.sort(key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
|
||||||
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
|
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
|
||||||
|
|
||||||
# Build groups of exactly condition_min_count distinct subevents
|
# Build groups of exactly condition_min_count distinct subevents
|
||||||
@@ -458,7 +494,7 @@ class Discount(LoggedModel):
|
|||||||
|
|
||||||
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
|
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
|
||||||
# and 2 from the end" scheme to optimize price distribution among groups
|
# and 2 from the end" scheme to optimize price distribution among groups
|
||||||
candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross)
|
candidates = sorted(candidates, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
|
||||||
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
|
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
|
||||||
candidate = candidates[0]
|
candidate = candidates[0]
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -847,7 +847,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
from ..signals import event_copy_data
|
from ..signals import event_copy_data
|
||||||
from . import (
|
from . import (
|
||||||
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
|
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
|
||||||
ItemVariationMetaValue, Question, Quota,
|
ItemProgramTime, ItemVariationMetaValue, Question, Quota,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
|
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
|
||||||
@@ -990,6 +990,12 @@ class Event(EventMixin, LoggedModel):
|
|||||||
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
|
ia.bundled_variation = variation_map[ia.bundled_variation.pk]
|
||||||
ia.save(force_insert=True)
|
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 = {}
|
quota_map = {}
|
||||||
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
|
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
|
||||||
quota_map[q.pk] = q
|
quota_map[q.pk] = q
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ class Invoice(models.Model):
|
|||||||
invoice_from_name = models.CharField(max_length=190, null=True)
|
invoice_from_name = models.CharField(max_length=190, null=True)
|
||||||
invoice_from_zipcode = 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_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_country = FastCountryField(null=True)
|
||||||
invoice_from_tax_id = models.CharField(max_length=190, null=True)
|
invoice_from_tax_id = models.CharField(max_length=190, null=True)
|
||||||
invoice_from_vat_id = models.CharField(max_length=190, null=True)
|
invoice_from_vat_id = models.CharField(max_length=190, null=True)
|
||||||
@@ -218,10 +219,23 @@ class Invoice(models.Model):
|
|||||||
taxidrow = "ABN: %s" % self.invoice_from_tax_id
|
taxidrow = "ABN: %s" % self.invoice_from_tax_id
|
||||||
else:
|
else:
|
||||||
taxidrow = pgettext("invoice", "Tax ID: %s") % self.invoice_from_tax_id
|
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 = [
|
parts = [
|
||||||
self.invoice_from_name,
|
self.invoice_from_name,
|
||||||
self.invoice_from,
|
self.invoice_from,
|
||||||
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
((self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or "") + " " + (state_name or "")).strip(),
|
||||||
self.invoice_from_country.name if self.invoice_from_country else "",
|
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 "",
|
pgettext("invoice", "VAT-ID: %s") % self.invoice_from_vat_id if self.invoice_from_vat_id else "",
|
||||||
taxidrow,
|
taxidrow,
|
||||||
@@ -230,10 +244,22 @@ class Invoice(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def address_invoice_from(self):
|
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 = [
|
parts = [
|
||||||
self.invoice_from_name,
|
self.invoice_from_name,
|
||||||
self.invoice_from,
|
self.invoice_from,
|
||||||
(self.invoice_from_zipcode or "") + " " + (self.invoice_from_city or ""),
|
" ".join(s for s in [self.invoice_from_zipcode, self.invoice_from_city, state_name] if s),
|
||||||
self.invoice_from_country.name if self.invoice_from_country else "",
|
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()])
|
return '\n'.join([p.strip() for p in parts if p and p.strip()])
|
||||||
|
|||||||
@@ -505,8 +505,7 @@ class Item(LoggedModel):
|
|||||||
verbose_name=_("Free price input"),
|
verbose_name=_("Free price input"),
|
||||||
help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
|
help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
|
||||||
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
|
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
|
||||||
"additional donations for your event. This is currently not supported for products that are "
|
"additional donations for your event.")
|
||||||
"bought as an add-on to other products.")
|
|
||||||
)
|
)
|
||||||
free_price_suggestion = models.DecimalField(
|
free_price_suggestion = models.DecimalField(
|
||||||
verbose_name=_("Suggested price"),
|
verbose_name=_("Suggested price"),
|
||||||
@@ -595,10 +594,11 @@ class Item(LoggedModel):
|
|||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
verbose_name=_("Only show after sellout of"),
|
verbose_name=_("Only show after sellout of"),
|
||||||
help_text=_("If you select a product here, this product will only be shown when that product is "
|
help_text=_("If you select a product here, this product will only be shown when that product is "
|
||||||
"sold out. If combined with the option to hide sold-out products, this allows you to "
|
"no longer available. This will happen either because the other product has sold out or because "
|
||||||
"swap out products for more expensive ones once the cheaper option is sold out. There might "
|
"the time is outside of the sales window for the other product. If combined with the option "
|
||||||
"be a short period in which both products are visible while all tickets of the referenced "
|
"to hide sold-out products, this allows you to swap out products for more expensive ones once "
|
||||||
"product are reserved, but not yet sold.")
|
"the cheaper option is sold out. There might be a short period in which both products are visible "
|
||||||
|
"while all tickets of the referenced product are reserved, but not yet sold.")
|
||||||
)
|
)
|
||||||
hidden_if_item_available_mode = models.CharField(
|
hidden_if_item_available_mode = models.CharField(
|
||||||
choices=UNAVAIL_MODES,
|
choices=UNAVAIL_MODES,
|
||||||
@@ -2294,3 +2294,29 @@ class ItemVariationMetaValue(LoggedModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = ('variation', 'property')
|
unique_together = ('variation', 'property')
|
||||||
|
|
||||||
|
|
||||||
|
class ItemProgramTime(models.Model):
|
||||||
|
"""
|
||||||
|
This model can be used to add a program time to an item.
|
||||||
|
|
||||||
|
:param item: The item the program time applies to
|
||||||
|
:type item: Item
|
||||||
|
:param start: The date and time this program time starts
|
||||||
|
:type start: datetime
|
||||||
|
:param end: The date and time this program time ends
|
||||||
|
:type end: datetime
|
||||||
|
"""
|
||||||
|
item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE)
|
||||||
|
start = models.DateTimeField(verbose_name=_("Start"))
|
||||||
|
end = models.DateTimeField(verbose_name=_("End"))
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if hasattr(self, 'item') and self.item and self.item.event.has_subevents:
|
||||||
|
raise ValidationError(_("You cannot use program times on an event series."))
|
||||||
|
self.clean_start_end(start=self.start, end=self.end)
|
||||||
|
super().clean()
|
||||||
|
|
||||||
|
def clean_start_end(self, start: datetime = None, end: datetime = None):
|
||||||
|
if start and end and start > end:
|
||||||
|
raise ValidationError(_("The program end must not be before the program start."))
|
||||||
|
|||||||
@@ -35,11 +35,14 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import connections, models
|
from django.db import connections, models
|
||||||
from django.utils.functional import cached_property
|
from django.utils.functional import cached_property
|
||||||
|
|
||||||
|
from pretix.helpers.celery import get_task_priority
|
||||||
|
|
||||||
|
|
||||||
class VisibleOnlyManager(models.Manager):
|
class VisibleOnlyManager(models.Manager):
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
@@ -138,8 +141,9 @@ class LogEntry(models.Model):
|
|||||||
|
|
||||||
log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
|
log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
|
||||||
if log_entry_type:
|
if log_entry_type:
|
||||||
|
sender = self.event if self.event else self.organizer
|
||||||
link_info = log_entry_type.get_object_link_info(self)
|
link_info = log_entry_type.get_object_link_info(self)
|
||||||
if is_app_active(self.event, meta['plugin']):
|
if is_app_active(sender, meta['plugin']):
|
||||||
return make_link(link_info, log_entry_type.object_link_wrapper)
|
return make_link(link_info, log_entry_type.object_link_wrapper)
|
||||||
else:
|
else:
|
||||||
return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False,
|
return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False,
|
||||||
@@ -186,7 +190,19 @@ class LogEntry(models.Model):
|
|||||||
|
|
||||||
to_notify = [o.id for o in objects if o.notification_type]
|
to_notify = [o.id for o in objects if o.notification_type]
|
||||||
if to_notify:
|
if to_notify:
|
||||||
notify.apply_async(args=(to_notify,))
|
organizer_ids = set(o.organizer_id for o in objects if o.notification_type)
|
||||||
|
notify.apply_async(
|
||||||
|
args=(to_notify,),
|
||||||
|
priority=settings.PRIORITY_CELERY_HIGHEST_FUNC(
|
||||||
|
get_task_priority("notifications", oid) for oid in organizer_ids
|
||||||
|
),
|
||||||
|
)
|
||||||
to_wh = [o.id for o in objects if o.webhook_type]
|
to_wh = [o.id for o in objects if o.webhook_type]
|
||||||
if to_wh:
|
if to_wh:
|
||||||
notify_webhooks.apply_async(args=(to_wh,))
|
organizer_ids = set(o.organizer_id for o in objects if o.webhook_type)
|
||||||
|
notify_webhooks.apply_async(
|
||||||
|
args=(to_wh,),
|
||||||
|
priority=settings.PRIORITY_CELERY_HIGHEST_FUNC(
|
||||||
|
get_task_priority("notifications", oid) for oid in organizer_ids
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.core.mail import get_connection
|
||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django_scopes import scope, scopes_disabled
|
||||||
|
|
||||||
|
|
||||||
|
def CASCADE_IF_QUEUED(collector, field, sub_objs, using):
|
||||||
|
# If the email is still queued and the thing it is related to vanishes, the email can vanish as well
|
||||||
|
cascade_objs = [
|
||||||
|
o for o in sub_objs if o.status == OutgoingMail.STATUS_QUEUED
|
||||||
|
]
|
||||||
|
if cascade_objs:
|
||||||
|
models.CASCADE(collector, field, cascade_objs, using)
|
||||||
|
|
||||||
|
# In all other cases, set to NULL to keep the email on record
|
||||||
|
models.SET_NULL(collector, field, [o for o in sub_objs if o not in cascade_objs], using)
|
||||||
|
|
||||||
|
|
||||||
|
class OutgoingMail(models.Model):
|
||||||
|
STATUS_QUEUED = "queued"
|
||||||
|
STATUS_WITHHELD = "withheld"
|
||||||
|
STATUS_INFLIGHT = "inflight"
|
||||||
|
STATUS_AWAITING_RETRY = "awaiting_retry"
|
||||||
|
STATUS_FAILED = "failed"
|
||||||
|
STATUS_SENT = "sent"
|
||||||
|
STATUS_BOUNCED = "bounced"
|
||||||
|
STATUS_ABORTED = "aborted"
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
(STATUS_QUEUED, _("queued")),
|
||||||
|
(STATUS_INFLIGHT, _("being sent")),
|
||||||
|
(STATUS_AWAITING_RETRY, _("awaiting retry")),
|
||||||
|
(STATUS_WITHHELD, _("withheld")), # for plugin use
|
||||||
|
(STATUS_FAILED, _("failed")),
|
||||||
|
(STATUS_ABORTED, _("aborted")),
|
||||||
|
(STATUS_SENT, _("sent")),
|
||||||
|
(STATUS_BOUNCED, _("bounced")), # for plugin use
|
||||||
|
)
|
||||||
|
STATUS_LIST_ABORTABLE = {
|
||||||
|
STATUS_QUEUED,
|
||||||
|
STATUS_WITHHELD,
|
||||||
|
STATUS_AWAITING_RETRY,
|
||||||
|
}
|
||||||
|
STATUS_LIST_RETRYABLE = {
|
||||||
|
STATUS_FAILED,
|
||||||
|
STATUS_WITHHELD,
|
||||||
|
}
|
||||||
|
|
||||||
|
# The GUID is a globally unique ID for the email added to a header of the email for later tracing
|
||||||
|
# in bug reports etc. We could theoretically also use this as a basis for the Message-ID header, but
|
||||||
|
# we currently don't since we are unsure if some intermediary SMTP servers have opinions on setting
|
||||||
|
# their own Message-ID headers.
|
||||||
|
guid = models.UUIDField(db_index=True, default=uuid.uuid4)
|
||||||
|
|
||||||
|
status = models.CharField(max_length=200, choices=STATUS_CHOICES, default=STATUS_QUEUED)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
# sent will be the time the email was sent or the email failed
|
||||||
|
sent = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
inflight_since = models.DateTimeField(null=True, blank=True)
|
||||||
|
retry_after = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
error = models.TextField(null=True, blank=True)
|
||||||
|
error_detail = models.TextField(null=True, blank=True)
|
||||||
|
|
||||||
|
# There is a conflict here between the different purposes of the model. As a system administrator,
|
||||||
|
# one wants *all* emails to be persisted as long as possible to debug issues. This means that if
|
||||||
|
# e.g. the event or order is deleted, we want SET_NULL behavior. However, in that case, the email
|
||||||
|
# would be an "orphan" forever and there's no way to remove the personal information.
|
||||||
|
# We try to find a middle-ground with the following behaviour:
|
||||||
|
# - The email is always deleted if the entire organizer or user is deleted
|
||||||
|
# - The email is always deleted if it has not yet been sent
|
||||||
|
# - The email is kept in all other cases
|
||||||
|
# This is only an acceptable trade-off since emails are stored for a short period only, and because
|
||||||
|
# orders and customers are never deleted during normal operation. If we ever make this a long-term
|
||||||
|
# storage / email archive, we'd need to find another way to make sure personal information is removed
|
||||||
|
# if personal information of orders etc is removed.
|
||||||
|
organizer = models.ForeignKey(
|
||||||
|
'pretixbase.Organizer',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
event = models.ForeignKey(
|
||||||
|
'pretixbase.Event',
|
||||||
|
on_delete=CASCADE_IF_QUEUED,
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
order = models.ForeignKey(
|
||||||
|
'pretixbase.Order',
|
||||||
|
on_delete=CASCADE_IF_QUEUED,
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
orderposition = models.ForeignKey(
|
||||||
|
'pretixbase.OrderPosition',
|
||||||
|
on_delete=CASCADE_IF_QUEUED,
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
customer = models.ForeignKey(
|
||||||
|
'pretixbase.Customer',
|
||||||
|
on_delete=CASCADE_IF_QUEUED,
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
'pretixbase.User',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
sensitive = models.BooleanField(default=False)
|
||||||
|
subject = models.TextField()
|
||||||
|
body_plain = models.TextField()
|
||||||
|
body_html = models.TextField(null=True)
|
||||||
|
sender = models.CharField(max_length=500)
|
||||||
|
headers = models.JSONField(default=dict)
|
||||||
|
to = models.JSONField(default=list)
|
||||||
|
cc = models.JSONField(default=list)
|
||||||
|
bcc = models.JSONField(default=list)
|
||||||
|
recipient_count = models.IntegerField()
|
||||||
|
|
||||||
|
# We don't store the actual invoices, tickets or calendar invites, so if the email is re-sent at a later time, a
|
||||||
|
# newer version of the files might be used. We accept that risk to save on storage and also because the new
|
||||||
|
# version might actually be more useful.
|
||||||
|
should_attach_invoices = models.ManyToManyField(
|
||||||
|
'pretixbase.Invoice',
|
||||||
|
related_name='outgoing_mails'
|
||||||
|
)
|
||||||
|
should_attach_tickets = models.BooleanField(default=False)
|
||||||
|
should_attach_ical = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
# clean_cached_files makes sure not to delete these as long as the email is in a retryable state
|
||||||
|
should_attach_cached_files = models.ManyToManyField(
|
||||||
|
'pretixbase.CachedFile',
|
||||||
|
related_name='outgoing_mails',
|
||||||
|
)
|
||||||
|
|
||||||
|
# This is used to send files stored in settings. In most cases, these aren't short-lived and should still be there
|
||||||
|
# if the email is sent. Otherwise, they will be skipped. We accept that risk.
|
||||||
|
should_attach_other_files = models.JSONField(default=list)
|
||||||
|
|
||||||
|
# [{name, type size}] of the attachments we actually setn
|
||||||
|
actual_attachments = models.JSONField(default=list)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ('-created',)
|
||||||
|
|
||||||
|
def get_mail_backend(self):
|
||||||
|
if self.event:
|
||||||
|
return self.event.get_mail_backend()
|
||||||
|
elif self.organizer:
|
||||||
|
return self.organizer.get_mail_backend()
|
||||||
|
else:
|
||||||
|
return get_connection(fail_silently=False)
|
||||||
|
|
||||||
|
def scope_manager(self):
|
||||||
|
if self.organizer:
|
||||||
|
return scope(organizer=self.organizer) # noqa
|
||||||
|
else:
|
||||||
|
return scopes_disabled() # noqa
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_failed(self):
|
||||||
|
return self.status in (
|
||||||
|
OutgoingMail.STATUS_FAILED,
|
||||||
|
OutgoingMail.STATUS_AWAITING_RETRY,
|
||||||
|
OutgoingMail.STATUS_BOUNCED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.orderposition_id and not self.order_id:
|
||||||
|
self.order = self.orderposition.order
|
||||||
|
if self.order_id and not self.event_id:
|
||||||
|
self.event = self.order.event
|
||||||
|
if self.event_id and not self.organizer_id:
|
||||||
|
self.organizer = self.event.organizer
|
||||||
|
if self.customer_id and not self.organizer_id:
|
||||||
|
self.organizer = self.customer.organizer
|
||||||
|
self.recipient_count = len(self.to) + len(self.cc) + len(self.bcc)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def log_parameters(self):
|
||||||
|
if self.order:
|
||||||
|
error_log_action_type = 'pretix.event.order.email.error'
|
||||||
|
log_target = self.order
|
||||||
|
elif self.customer:
|
||||||
|
error_log_action_type = 'pretix.customer.email.error'
|
||||||
|
log_target = self.customer
|
||||||
|
elif self.user:
|
||||||
|
error_log_action_type = 'pretix.user.email.error'
|
||||||
|
log_target = self.user
|
||||||
|
else:
|
||||||
|
error_log_action_type = 'pretix.email.error'
|
||||||
|
log_target = None
|
||||||
|
return log_target, error_log_action_type
|
||||||
+189
-119
@@ -81,13 +81,13 @@ from pretix.base.email import get_email_context
|
|||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import Customer, User
|
from pretix.base.models import Customer, User
|
||||||
from pretix.base.reldate import RelativeDateWrapper
|
from pretix.base.reldate import RelativeDateWrapper
|
||||||
from pretix.base.settings import PERSON_NAME_SCHEMES
|
from pretix.base.settings import PERSON_NAME_SCHEMES, ROUNDING_MODES
|
||||||
from pretix.base.signals import allow_ticket_download, order_gracefully_delete
|
from pretix.base.signals import allow_ticket_download, order_gracefully_delete
|
||||||
from pretix.base.timemachine import time_machine_now
|
from pretix.base.timemachine import time_machine_now
|
||||||
|
|
||||||
from ...helpers import OF_SELF
|
from ...helpers import OF_SELF
|
||||||
from ...helpers.countries import CachedCountries, FastCountryField
|
from ...helpers.countries import CachedCountries, FastCountryField
|
||||||
from ...helpers.format import format_map
|
from ...helpers.format import FormattedString, format_map
|
||||||
from ...helpers.names import build_name
|
from ...helpers.names import build_name
|
||||||
from ...testutils.middleware import debugflags_var
|
from ...testutils.middleware import debugflags_var
|
||||||
from ._transactions import (
|
from ._transactions import (
|
||||||
@@ -324,6 +324,11 @@ class Order(LockModel, LoggedModel):
|
|||||||
# Invoice needs to be re-issued when the order is paid again
|
# Invoice needs to be re-issued when the order is paid again
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
tax_rounding_mode = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
choices=ROUNDING_MODES,
|
||||||
|
default="line",
|
||||||
|
)
|
||||||
|
|
||||||
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
|
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
|
||||||
|
|
||||||
@@ -1162,9 +1167,7 @@ class Order(LockModel, LoggedModel):
|
|||||||
only be attached for this position and child positions, the link will only point to the
|
only be attached for this position and child positions, the link will only point to the
|
||||||
position and the attendee email will be used if available.
|
position and the attendee email will be used if available.
|
||||||
"""
|
"""
|
||||||
from pretix.base.services.mail import (
|
from pretix.base.services.mail import mail, render_mail
|
||||||
SendMailException, mail, render_mail,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.email and not (position and position.attendee_email):
|
if not self.email and not (position and position.attendee_email):
|
||||||
return
|
return
|
||||||
@@ -1174,35 +1177,32 @@ class Order(LockModel, LoggedModel):
|
|||||||
if position and position.attendee_email:
|
if position and position.attendee_email:
|
||||||
recipient = position.attendee_email
|
recipient = position.attendee_email
|
||||||
|
|
||||||
try:
|
email_content = render_mail(template, context)
|
||||||
email_content = render_mail(template, context)
|
if not isinstance(subject, FormattedString):
|
||||||
subject = format_map(subject, context)
|
subject = format_map(subject, context)
|
||||||
mail(
|
mail(
|
||||||
recipient, subject, template, context,
|
recipient, subject, template, context,
|
||||||
self.event, self.locale, self, headers=headers, sender=sender,
|
self.event, self.locale, self, headers=headers, sender=sender,
|
||||||
invoices=invoices, attach_tickets=attach_tickets,
|
invoices=invoices, attach_tickets=attach_tickets,
|
||||||
position=position, auto_email=auto_email, attach_ical=attach_ical,
|
position=position, auto_email=auto_email, attach_ical=attach_ical,
|
||||||
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
|
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
|
||||||
)
|
)
|
||||||
except SendMailException:
|
self.log_action(
|
||||||
raise
|
log_entry_type,
|
||||||
else:
|
user=user,
|
||||||
self.log_action(
|
auth=auth,
|
||||||
log_entry_type,
|
data={
|
||||||
user=user,
|
'subject': subject,
|
||||||
auth=auth,
|
'message': email_content,
|
||||||
data={
|
'position': position.positionid if position else None,
|
||||||
'subject': subject,
|
'recipient': recipient,
|
||||||
'message': email_content,
|
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||||
'position': position.positionid if position else None,
|
'attach_tickets': attach_tickets,
|
||||||
'recipient': recipient,
|
'attach_ical': attach_ical,
|
||||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
'attach_other_files': attach_other_files,
|
||||||
'attach_tickets': attach_tickets,
|
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
|
||||||
'attach_ical': attach_ical,
|
}
|
||||||
'attach_other_files': attach_other_files,
|
)
|
||||||
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def resend_link(self, user=None, auth=None):
|
def resend_link(self, user=None, auth=None):
|
||||||
with language(self.locale, self.event.settings.region):
|
with language(self.locale, self.event.settings.region):
|
||||||
@@ -1259,7 +1259,8 @@ class Order(LockModel, LoggedModel):
|
|||||||
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
|
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
|
||||||
create = []
|
create = []
|
||||||
for k in keys:
|
for k in keys:
|
||||||
positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype, taxcode = k
|
(positionid, itemid, variationid, subeventid, price, price_includes_rounding_correction, taxrate,
|
||||||
|
taxruleid, taxvalue, taxvalue_includes_rounding_correction, feetype, internaltype, taxcode) = k
|
||||||
d = target_transaction_count[k] - current_transaction_count[k]
|
d = target_transaction_count[k] - current_transaction_count[k]
|
||||||
if d:
|
if d:
|
||||||
create.append(Transaction(
|
create.append(Transaction(
|
||||||
@@ -1272,9 +1273,11 @@ class Order(LockModel, LoggedModel):
|
|||||||
variation_id=variationid,
|
variation_id=variationid,
|
||||||
subevent_id=subeventid,
|
subevent_id=subeventid,
|
||||||
price=price,
|
price=price,
|
||||||
|
price_includes_rounding_correction=price_includes_rounding_correction,
|
||||||
tax_rate=taxrate,
|
tax_rate=taxrate,
|
||||||
tax_rule_id=taxruleid,
|
tax_rule_id=taxruleid,
|
||||||
tax_value=taxvalue,
|
tax_value=taxvalue,
|
||||||
|
tax_value_includes_rounding_correction=taxvalue_includes_rounding_correction,
|
||||||
tax_code=taxcode,
|
tax_code=taxcode,
|
||||||
fee_type=feetype,
|
fee_type=feetype,
|
||||||
internal_type=internaltype,
|
internal_type=internaltype,
|
||||||
@@ -1449,7 +1452,22 @@ class QuestionAnswer(models.Model):
|
|||||||
super().delete(**kwargs)
|
super().delete(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class AbstractPosition(models.Model):
|
class RoundingCorrectionMixin:
|
||||||
|
|
||||||
|
@property
|
||||||
|
def gross_price_before_rounding(self):
|
||||||
|
return self.price - self.price_includes_rounding_correction
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tax_value_before_rounding(self):
|
||||||
|
return self.tax_value - self.tax_value_includes_rounding_correction
|
||||||
|
|
||||||
|
@property
|
||||||
|
def net_price_before_rounding(self):
|
||||||
|
return self.gross_price_before_rounding - self.tax_value_before_rounding
|
||||||
|
|
||||||
|
|
||||||
|
class AbstractPosition(RoundingCorrectionMixin, models.Model):
|
||||||
"""
|
"""
|
||||||
A position can either be one line of an order or an item placed in a cart.
|
A position can either be one line of an order or an item placed in a cart.
|
||||||
|
|
||||||
@@ -1499,6 +1517,9 @@ class AbstractPosition(models.Model):
|
|||||||
decimal_places=2, max_digits=13,
|
decimal_places=2, max_digits=13,
|
||||||
verbose_name=_("Price")
|
verbose_name=_("Price")
|
||||||
)
|
)
|
||||||
|
price_includes_rounding_correction = models.DecimalField(
|
||||||
|
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||||
|
)
|
||||||
attendee_name_cached = models.CharField(
|
attendee_name_cached = models.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
verbose_name=_("Attendee name"),
|
verbose_name=_("Attendee name"),
|
||||||
@@ -1649,7 +1670,7 @@ class AbstractPosition(models.Model):
|
|||||||
def state_name(self):
|
def state_name(self):
|
||||||
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||||
if sd:
|
if sd:
|
||||||
return sd.name
|
return _(sd.name)
|
||||||
return self.state
|
return self.state
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -1998,40 +2019,30 @@ class OrderPayment(models.Model):
|
|||||||
transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False))
|
transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False))
|
||||||
|
|
||||||
def _send_paid_mail_attendee(self, position, user):
|
def _send_paid_mail_attendee(self, position, user):
|
||||||
from pretix.base.services.mail import SendMailException
|
|
||||||
|
|
||||||
with language(self.order.locale, self.order.event.settings.region):
|
with language(self.order.locale, self.order.event.settings.region):
|
||||||
email_template = self.order.event.settings.mail_text_order_paid_attendee
|
email_template = self.order.event.settings.mail_text_order_paid_attendee
|
||||||
email_subject = self.order.event.settings.mail_subject_order_paid_attendee
|
email_subject = self.order.event.settings.mail_subject_order_paid_attendee
|
||||||
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
|
email_context = get_email_context(event=self.order.event, order=self.order, position=position)
|
||||||
try:
|
position.send_mail(
|
||||||
position.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.order_paid', user,
|
||||||
'pretix.event.order.email.order_paid', user,
|
invoices=[],
|
||||||
invoices=[],
|
attach_tickets=True,
|
||||||
attach_tickets=True,
|
attach_ical=self.order.event.settings.mail_attach_ical
|
||||||
attach_ical=self.order.event.settings.mail_attach_ical
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order paid email could not be sent')
|
|
||||||
|
|
||||||
def _send_paid_mail(self, invoice, user, mail_text):
|
def _send_paid_mail(self, invoice, user, mail_text):
|
||||||
from pretix.base.services.mail import SendMailException
|
|
||||||
|
|
||||||
with language(self.order.locale, self.order.event.settings.region):
|
with language(self.order.locale, self.order.event.settings.region):
|
||||||
email_template = self.order.event.settings.mail_text_order_paid
|
email_template = self.order.event.settings.mail_text_order_paid
|
||||||
email_subject = self.order.event.settings.mail_subject_order_paid
|
email_subject = self.order.event.settings.mail_subject_order_paid
|
||||||
email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
|
email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
|
||||||
try:
|
self.order.send_mail(
|
||||||
self.order.send_mail(
|
email_subject, email_template, email_context,
|
||||||
email_subject, email_template, email_context,
|
'pretix.event.order.email.order_paid', user,
|
||||||
'pretix.event.order.email.order_paid', user,
|
invoices=[invoice] if invoice else [],
|
||||||
invoices=[invoice] if invoice else [],
|
attach_tickets=True,
|
||||||
attach_tickets=True,
|
attach_ical=self.order.event.settings.mail_attach_ical
|
||||||
attach_ical=self.order.event.settings.mail_attach_ical
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order paid email could not be sent')
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def refunded_amount(self):
|
def refunded_amount(self):
|
||||||
@@ -2272,7 +2283,7 @@ class ActivePositionManager(ScopedManager(organizer='order__event__organizer')._
|
|||||||
return super().get_queryset().filter(canceled=False)
|
return super().get_queryset().filter(canceled=False)
|
||||||
|
|
||||||
|
|
||||||
class OrderFee(models.Model):
|
class OrderFee(RoundingCorrectionMixin, models.Model):
|
||||||
"""
|
"""
|
||||||
An OrderFee object represents a fee that is added to the order total independently of
|
An OrderFee object represents a fee that is added to the order total independently of
|
||||||
the actual positions. This might for example be a payment or a shipping fee.
|
the actual positions. This might for example be a payment or a shipping fee.
|
||||||
@@ -2322,6 +2333,9 @@ class OrderFee(models.Model):
|
|||||||
decimal_places=2, max_digits=13,
|
decimal_places=2, max_digits=13,
|
||||||
verbose_name=_("Value")
|
verbose_name=_("Value")
|
||||||
)
|
)
|
||||||
|
value_includes_rounding_correction = models.DecimalField(
|
||||||
|
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||||
|
)
|
||||||
order = models.ForeignKey(
|
order = models.ForeignKey(
|
||||||
Order,
|
Order,
|
||||||
verbose_name=_("Order"),
|
verbose_name=_("Order"),
|
||||||
@@ -2350,6 +2364,9 @@ class OrderFee(models.Model):
|
|||||||
max_digits=13, decimal_places=2,
|
max_digits=13, decimal_places=2,
|
||||||
verbose_name=_('Tax value')
|
verbose_name=_('Tax value')
|
||||||
)
|
)
|
||||||
|
tax_value_includes_rounding_correction = models.DecimalField(
|
||||||
|
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||||
|
)
|
||||||
canceled = models.BooleanField(default=False)
|
canceled = models.BooleanField(default=False)
|
||||||
|
|
||||||
all = ScopedManager(organizer='order__event__organizer')
|
all = ScopedManager(organizer='order__event__organizer')
|
||||||
@@ -2398,17 +2415,23 @@ class OrderFee(models.Model):
|
|||||||
self.fee_type, self.value
|
self.fee_type, self.value
|
||||||
)
|
)
|
||||||
|
|
||||||
def _calculate_tax(self, tax_rule=None, invoice_address=None):
|
def _calculate_tax(self, tax_rule=None, invoice_address=None, event=None):
|
||||||
if tax_rule:
|
if tax_rule:
|
||||||
self.tax_rule = tax_rule
|
self.tax_rule = tax_rule
|
||||||
|
|
||||||
try:
|
if invoice_address:
|
||||||
ia = invoice_address or self.order.invoice_address
|
ia = invoice_address
|
||||||
except InvoiceAddress.DoesNotExist:
|
elif hasattr(self, "order"):
|
||||||
|
try:
|
||||||
|
ia = self.order.invoice_address
|
||||||
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
ia = None
|
||||||
|
else:
|
||||||
ia = None
|
ia = None
|
||||||
|
|
||||||
if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rule_payment == "default":
|
event = event or self.order.event
|
||||||
self.tax_rule = self.order.event.cached_default_tax_rule
|
if not self.tax_rule and self.fee_type == "payment" and event.settings.tax_rule_payment == "default":
|
||||||
|
self.tax_rule = event.cached_default_tax_rule
|
||||||
|
|
||||||
if self.tax_rule:
|
if self.tax_rule:
|
||||||
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
|
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
|
||||||
@@ -2443,6 +2466,24 @@ class OrderFee(models.Model):
|
|||||||
self.order.touch()
|
self.order.touch()
|
||||||
super().delete(**kwargs)
|
super().delete(**kwargs)
|
||||||
|
|
||||||
|
# For historical reasons, OrderFee has "value", but OrderPosition has "price". These properties
|
||||||
|
# help using them the same way.
|
||||||
|
@property
|
||||||
|
def price(self):
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
@price.setter
|
||||||
|
def price(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def price_includes_rounding_correction(self):
|
||||||
|
return self.value_includes_rounding_correction
|
||||||
|
|
||||||
|
@price_includes_rounding_correction.setter
|
||||||
|
def price_includes_rounding_correction(self, value):
|
||||||
|
self.value_includes_rounding_correction = value
|
||||||
|
|
||||||
|
|
||||||
class OrderPosition(AbstractPosition):
|
class OrderPosition(AbstractPosition):
|
||||||
"""
|
"""
|
||||||
@@ -2522,6 +2563,9 @@ class OrderPosition(AbstractPosition):
|
|||||||
max_digits=13, decimal_places=2,
|
max_digits=13, decimal_places=2,
|
||||||
verbose_name=_('Tax value')
|
verbose_name=_('Tax value')
|
||||||
)
|
)
|
||||||
|
tax_value_includes_rounding_correction = models.DecimalField(
|
||||||
|
max_digits=13, decimal_places=2, default=Decimal("0.00"),
|
||||||
|
)
|
||||||
|
|
||||||
secret = models.CharField(max_length=255, null=False, blank=False, db_index=True)
|
secret = models.CharField(max_length=255, null=False, blank=False, db_index=True)
|
||||||
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
|
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
|
||||||
@@ -2694,7 +2738,14 @@ class OrderPosition(AbstractPosition):
|
|||||||
setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
|
setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
|
||||||
else:
|
else:
|
||||||
setattr(op, f.name, getattr(cartpos, f.name))
|
setattr(op, f.name, getattr(cartpos, f.name))
|
||||||
op._calculate_tax()
|
|
||||||
|
op.tax_value = cartpos.tax_value
|
||||||
|
op.tax_value_includes_rounding_correction = cartpos.tax_value_includes_rounding_correction
|
||||||
|
op.tax_rate = cartpos.tax_rate
|
||||||
|
op.tax_code = cartpos.tax_code
|
||||||
|
op.tax_rule = cartpos.item.tax_rule
|
||||||
|
# todo: is removing this safe? op._calculate_tax()
|
||||||
|
|
||||||
if cartpos.voucher:
|
if cartpos.voucher:
|
||||||
op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher
|
op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher
|
||||||
|
|
||||||
@@ -2849,45 +2900,40 @@ class OrderPosition(AbstractPosition):
|
|||||||
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
|
||||||
:param attach_ical: Attach relevant ICS files
|
:param attach_ical: Attach relevant ICS files
|
||||||
"""
|
"""
|
||||||
from pretix.base.services.mail import (
|
from pretix.base.services.mail import mail, render_mail
|
||||||
SendMailException, mail, render_mail,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.attendee_email:
|
if not self.attendee_email:
|
||||||
return
|
return
|
||||||
|
|
||||||
with language(self.order.locale, self.order.event.settings.region):
|
with language(self.order.locale, self.order.event.settings.region):
|
||||||
recipient = self.attendee_email
|
recipient = self.attendee_email
|
||||||
try:
|
email_content = render_mail(template, context)
|
||||||
email_content = render_mail(template, context)
|
if not isinstance(subject, FormattedString):
|
||||||
subject = format_map(subject, context)
|
subject = format_map(subject, context)
|
||||||
mail(
|
mail(
|
||||||
recipient, subject, template, context,
|
recipient, subject, template, context,
|
||||||
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
|
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
|
||||||
position=self,
|
position=self,
|
||||||
invoices=invoices,
|
invoices=invoices,
|
||||||
attach_tickets=attach_tickets,
|
attach_tickets=attach_tickets,
|
||||||
attach_ical=attach_ical,
|
attach_ical=attach_ical,
|
||||||
attach_other_files=attach_other_files,
|
attach_other_files=attach_other_files,
|
||||||
)
|
)
|
||||||
except SendMailException:
|
self.order.log_action(
|
||||||
raise
|
log_entry_type,
|
||||||
else:
|
user=user,
|
||||||
self.order.log_action(
|
auth=auth,
|
||||||
log_entry_type,
|
data={
|
||||||
user=user,
|
'subject': subject,
|
||||||
auth=auth,
|
'message': email_content,
|
||||||
data={
|
'recipient': recipient,
|
||||||
'subject': subject,
|
'invoices': [i.pk for i in invoices] if invoices else [],
|
||||||
'message': email_content,
|
'attach_tickets': attach_tickets,
|
||||||
'recipient': recipient,
|
'attach_ical': attach_ical,
|
||||||
'invoices': [i.pk for i in invoices] if invoices else [],
|
'attach_other_files': attach_other_files,
|
||||||
'attach_tickets': attach_tickets,
|
'attach_cached_files': [],
|
||||||
'attach_ical': attach_ical,
|
}
|
||||||
'attach_other_files': attach_other_files,
|
)
|
||||||
'attach_cached_files': [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def resend_link(self, user=None, auth=None):
|
def resend_link(self, user=None, auth=None):
|
||||||
|
|
||||||
@@ -3027,6 +3073,9 @@ class Transaction(models.Model):
|
|||||||
decimal_places=2, max_digits=13,
|
decimal_places=2, max_digits=13,
|
||||||
verbose_name=_("Price")
|
verbose_name=_("Price")
|
||||||
)
|
)
|
||||||
|
price_includes_rounding_correction = models.DecimalField(
|
||||||
|
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||||
|
)
|
||||||
tax_rate = models.DecimalField(
|
tax_rate = models.DecimalField(
|
||||||
max_digits=7, decimal_places=2,
|
max_digits=7, decimal_places=2,
|
||||||
verbose_name=_('Tax rate')
|
verbose_name=_('Tax rate')
|
||||||
@@ -3044,6 +3093,9 @@ class Transaction(models.Model):
|
|||||||
max_digits=13, decimal_places=2,
|
max_digits=13, decimal_places=2,
|
||||||
verbose_name=_('Tax value')
|
verbose_name=_('Tax value')
|
||||||
)
|
)
|
||||||
|
tax_value_includes_rounding_correction = models.DecimalField(
|
||||||
|
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||||
|
)
|
||||||
fee_type = models.CharField(
|
fee_type = models.CharField(
|
||||||
max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True
|
max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True
|
||||||
)
|
)
|
||||||
@@ -3073,14 +3125,19 @@ class Transaction(models.Model):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def key(obj):
|
def key(obj):
|
||||||
if isinstance(obj, Transaction):
|
if isinstance(obj, Transaction):
|
||||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
|
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
|
||||||
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code)
|
obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id,
|
||||||
|
obj.tax_value, obj.tax_value_includes_rounding_correction, obj.fee_type,
|
||||||
|
obj.internal_type, obj.tax_code)
|
||||||
elif isinstance(obj, OrderPosition):
|
elif isinstance(obj, OrderPosition):
|
||||||
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate,
|
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
|
||||||
obj.tax_rule_id, obj.tax_value, None, None, obj.tax_code)
|
obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id,
|
||||||
|
obj.tax_value, obj.tax_value_includes_rounding_correction, None,
|
||||||
|
None, obj.tax_code)
|
||||||
elif isinstance(obj, OrderFee):
|
elif isinstance(obj, OrderFee):
|
||||||
return (None, None, None, None, obj.value, obj.tax_rate,
|
return (None, None, None, None, obj.value, obj.value_includes_rounding_correction,
|
||||||
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code)
|
obj.tax_rate, obj.tax_rule_id, obj.tax_value, obj.tax_value_includes_rounding_correction,
|
||||||
|
obj.fee_type, obj.internal_type, obj.tax_code)
|
||||||
raise ValueError('invalid state') # noqa
|
raise ValueError('invalid state') # noqa
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -3091,6 +3148,14 @@ class Transaction(models.Model):
|
|||||||
def full_tax_value(self):
|
def full_tax_value(self):
|
||||||
return self.tax_value * self.count
|
return self.tax_value * self.count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_price_includes_rounding_correction(self):
|
||||||
|
return self.price_includes_rounding_correction * self.count
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_tax_value_includes_rounding_correction(self):
|
||||||
|
return self.tax_value_includes_rounding_correction * self.count
|
||||||
|
|
||||||
|
|
||||||
class CartPosition(AbstractPosition):
|
class CartPosition(AbstractPosition):
|
||||||
"""
|
"""
|
||||||
@@ -3131,6 +3196,13 @@ class CartPosition(AbstractPosition):
|
|||||||
max_digits=7, decimal_places=2, default=Decimal('0.00'),
|
max_digits=7, decimal_places=2, default=Decimal('0.00'),
|
||||||
verbose_name=_('Tax rate')
|
verbose_name=_('Tax rate')
|
||||||
)
|
)
|
||||||
|
tax_code = models.CharField(
|
||||||
|
max_length=190,
|
||||||
|
null=True, blank=True,
|
||||||
|
)
|
||||||
|
tax_value_includes_rounding_correction = models.DecimalField(
|
||||||
|
max_digits=13, decimal_places=2, default=Decimal("0.00")
|
||||||
|
)
|
||||||
listed_price = models.DecimalField(
|
listed_price = models.DecimalField(
|
||||||
decimal_places=2, max_digits=13, null=True,
|
decimal_places=2, max_digits=13, null=True,
|
||||||
)
|
)
|
||||||
@@ -3171,9 +3243,15 @@ class CartPosition(AbstractPosition):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def tax_value(self):
|
def tax_value(self):
|
||||||
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))),
|
price = self.gross_price_before_rounding
|
||||||
|
net = round_decimal(price - (price * (1 - 100 / (100 + self.tax_rate))),
|
||||||
self.event.currency)
|
self.event.currency)
|
||||||
return self.price - net
|
return self.gross_price_before_rounding - net + self.tax_value_includes_rounding_correction
|
||||||
|
|
||||||
|
@tax_value.setter
|
||||||
|
def tax_value(self, value):
|
||||||
|
# ignore, tax value is always computed on the fly
|
||||||
|
pass
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def sort_key(self):
|
def sort_key(self):
|
||||||
@@ -3382,7 +3460,7 @@ class InvoiceAddress(models.Model):
|
|||||||
def state_name(self):
|
def state_name(self):
|
||||||
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
|
||||||
if sd:
|
if sd:
|
||||||
return sd.name
|
return _(sd.name)
|
||||||
return self.state
|
return self.state
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -3431,18 +3509,10 @@ class InvoiceAddress(models.Model):
|
|||||||
def describe_transmission(self):
|
def describe_transmission(self):
|
||||||
from pretix.base.invoicing.transmission import transmission_types
|
from pretix.base.invoicing.transmission import transmission_types
|
||||||
data = []
|
data = []
|
||||||
|
|
||||||
t, __ = transmission_types.get(identifier=self.transmission_type)
|
t, __ = transmission_types.get(identifier=self.transmission_type)
|
||||||
data.append((_("Transmission type"), t.public_name))
|
data.append((_("Transmission type"), t.public_name))
|
||||||
form_data = t.transmission_info_to_form_data(self.transmission_info or {})
|
if self.transmission_info:
|
||||||
for k, f in t.invoice_address_form_fields.items():
|
data += t.describe_info(self.transmission_info, self.country, self.is_business)
|
||||||
v = form_data.get(k)
|
|
||||||
if v is True:
|
|
||||||
v = _("Yes")
|
|
||||||
elif v is False:
|
|
||||||
v = _("No")
|
|
||||||
if v:
|
|
||||||
data.append((f.label, v))
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@
|
|||||||
import json
|
import json
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
import jsonschema
|
|
||||||
from django.contrib.staticfiles import finders
|
from django.contrib.staticfiles import finders
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
@@ -38,6 +37,8 @@ from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent
|
|||||||
@deconstructible
|
@deconstructible
|
||||||
class SeatingPlanLayoutValidator:
|
class SeatingPlanLayoutValidator:
|
||||||
def __call__(self, value):
|
def __call__(self, value):
|
||||||
|
import jsonschema
|
||||||
|
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
try:
|
try:
|
||||||
val = json.loads(value)
|
val = json.loads(value)
|
||||||
@@ -280,13 +281,13 @@ class Seat(models.Model):
|
|||||||
|
|
||||||
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None,
|
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None,
|
||||||
sales_channel='web',
|
sales_channel='web',
|
||||||
ignore_distancing=False, distance_ignore_cart_id=None):
|
ignore_distancing=False, distance_ignore_cart_id=None, always_allow_blocked=False):
|
||||||
from .orders import Order
|
from .orders import Order
|
||||||
from .organizer import SalesChannel
|
from .organizer import SalesChannel
|
||||||
|
|
||||||
if isinstance(sales_channel, SalesChannel):
|
if isinstance(sales_channel, SalesChannel):
|
||||||
sales_channel = sales_channel.identifier
|
sales_channel = sales_channel.identifier
|
||||||
if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
|
if not always_allow_blocked and self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
|
||||||
return False
|
return False
|
||||||
opqs = self.orderposition_set.filter(
|
opqs = self.orderposition_set.filter(
|
||||||
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID],
|
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID],
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import json
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import jsonschema
|
|
||||||
from django.contrib.staticfiles import finders
|
from django.contrib.staticfiles import finders
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||||
@@ -298,6 +297,8 @@ def cc_to_vat_prefix(country_code):
|
|||||||
@deconstructible
|
@deconstructible
|
||||||
class CustomRulesValidator:
|
class CustomRulesValidator:
|
||||||
def __call__(self, value):
|
def __call__(self, value):
|
||||||
|
import jsonschema
|
||||||
|
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
try:
|
try:
|
||||||
val = json.loads(value)
|
val = json.loads(value)
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ class Voucher(LoggedModel):
|
|||||||
return max(1, self.min_usages - self.redeemed)
|
return max(1, self.min_usages - self.redeemed)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def annotate_budget_used_orders(cls, qs):
|
def annotate_budget_used(cls, qs):
|
||||||
opq = OrderPosition.objects.filter(
|
opq = OrderPosition.objects.filter(
|
||||||
voucher_id=OuterRef('pk'),
|
voucher_id=OuterRef('pk'),
|
||||||
voucher_budget_use__isnull=False,
|
voucher_budget_use__isnull=False,
|
||||||
@@ -632,7 +632,7 @@ class Voucher(LoggedModel):
|
|||||||
Order.STATUS_PENDING
|
Order.STATUS_PENDING
|
||||||
]
|
]
|
||||||
).order_by().values('voucher_id').annotate(s=Sum('voucher_budget_use')).values('s')
|
).order_by().values('voucher_id').annotate(s=Sum('voucher_budget_use')).values('s')
|
||||||
return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=13, decimal_places=2)), Decimal('0.00')))
|
return qs.annotate(budget_used=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=13, decimal_places=2)), Decimal('0.00')))
|
||||||
|
|
||||||
def budget_used(self):
|
def budget_used(self):
|
||||||
ops = OrderPosition.objects.filter(
|
ops = OrderPosition.objects.filter(
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ from phonenumber_field.modelfields import PhoneNumberField
|
|||||||
from pretix.base.email import get_email_context
|
from pretix.base.email import get_email_context
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import User, Voucher
|
from pretix.base.models import User, Voucher
|
||||||
from pretix.base.services.mail import SendMailException, mail, render_mail
|
from pretix.base.services.mail import mail, render_mail
|
||||||
|
from pretix.helpers import OF_SELF
|
||||||
|
|
||||||
from ...helpers.format import format_map
|
from ...helpers.format import format_map
|
||||||
from ...helpers.names import build_name
|
from ...helpers.names import build_name
|
||||||
@@ -158,6 +159,7 @@ class WaitingListEntry(LoggedModel):
|
|||||||
if availability[1] is None or availability[1] < 1:
|
if availability[1] is None or availability[1] < 1:
|
||||||
raise WaitingListException(_('This product is currently not available.'))
|
raise WaitingListException(_('This product is currently not available.'))
|
||||||
|
|
||||||
|
event = self.event
|
||||||
ev = self.subevent or self.event
|
ev = self.subevent or self.event
|
||||||
if ev.seat_category_mappings.filter(product=self.item).exists():
|
if ev.seat_category_mappings.filter(product=self.item).exists():
|
||||||
# Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous
|
# Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous
|
||||||
@@ -185,44 +187,49 @@ class WaitingListEntry(LoggedModel):
|
|||||||
if not free_seats:
|
if not free_seats:
|
||||||
raise WaitingListException(_('No seat with this product is currently available.'))
|
raise WaitingListException(_('No seat with this product is currently available.'))
|
||||||
|
|
||||||
if self.voucher:
|
|
||||||
raise WaitingListException(_('A voucher has already been sent to this person.'))
|
|
||||||
if '@' not in self.email:
|
if '@' not in self.email:
|
||||||
raise WaitingListException(_('This entry is anonymized and can no longer be used.'))
|
raise WaitingListException(_('This entry is anonymized and can no longer be used.'))
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
e = self.email
|
locked_wle = WaitingListEntry.objects.select_for_update(of=OF_SELF).get(pk=self.pk)
|
||||||
if self.name:
|
locked_wle.event = event
|
||||||
e += ' / ' + self.name
|
if locked_wle.voucher:
|
||||||
|
raise WaitingListException(_('A voucher has already been sent to this person.'))
|
||||||
|
e = locked_wle.email
|
||||||
|
if locked_wle.name:
|
||||||
|
e += ' / ' + locked_wle.name
|
||||||
v = Voucher.objects.create(
|
v = Voucher.objects.create(
|
||||||
event=self.event,
|
event=locked_wle.event,
|
||||||
max_usages=1,
|
max_usages=1,
|
||||||
valid_until=now() + timedelta(hours=self.event.settings.waiting_list_hours),
|
valid_until=now() + timedelta(hours=locked_wle.event.settings.waiting_list_hours),
|
||||||
item=self.item,
|
item=locked_wle.item,
|
||||||
variation=self.variation,
|
variation=locked_wle.variation,
|
||||||
tag='waiting-list',
|
tag='waiting-list',
|
||||||
comment=_('Automatically created from waiting list entry for {email}').format(
|
comment=_('Automatically created from waiting list entry for {email}').format(
|
||||||
email=e
|
email=e
|
||||||
),
|
),
|
||||||
block_quota=True,
|
block_quota=True,
|
||||||
subevent=self.subevent,
|
subevent=locked_wle.subevent,
|
||||||
)
|
)
|
||||||
v.log_action('pretix.voucher.added', {
|
v.log_action('pretix.voucher.added', {
|
||||||
'item': self.item.pk,
|
'item': locked_wle.item.pk,
|
||||||
'variation': self.variation.pk if self.variation else None,
|
'variation': locked_wle.variation.pk if locked_wle.variation else None,
|
||||||
'tag': 'waiting-list',
|
'tag': 'waiting-list',
|
||||||
'block_quota': True,
|
'block_quota': True,
|
||||||
'valid_until': v.valid_until.isoformat(),
|
'valid_until': v.valid_until.isoformat(),
|
||||||
'max_usages': 1,
|
'max_usages': 1,
|
||||||
'subevent': self.subevent.pk if self.subevent else None,
|
'subevent': locked_wle.subevent.pk if locked_wle.subevent else None,
|
||||||
'source': 'waitinglist',
|
'source': 'waitinglist',
|
||||||
}, user=user, auth=auth)
|
}, user=user, auth=auth)
|
||||||
v.log_action('pretix.voucher.added.waitinglist', {
|
v.log_action('pretix.voucher.added.waitinglist', {
|
||||||
'email': self.email,
|
'email': locked_wle.email,
|
||||||
'waitinglistentry': self.pk,
|
'waitinglistentry': locked_wle.pk,
|
||||||
}, user=user, auth=auth)
|
}, user=user, auth=auth)
|
||||||
self.voucher = v
|
locked_wle.voucher = v
|
||||||
self.save()
|
locked_wle.save()
|
||||||
|
|
||||||
|
self.refresh_from_db()
|
||||||
|
self.event = event
|
||||||
|
|
||||||
with language(self.locale, self.event.settings.region):
|
with language(self.locale, self.event.settings.region):
|
||||||
self.send_mail(
|
self.send_mail(
|
||||||
@@ -265,34 +272,30 @@ class WaitingListEntry(LoggedModel):
|
|||||||
with language(self.locale, self.event.settings.region):
|
with language(self.locale, self.event.settings.region):
|
||||||
recipient = self.email
|
recipient = self.email
|
||||||
|
|
||||||
try:
|
email_content = render_mail(template, context)
|
||||||
email_content = render_mail(template, context)
|
subject = format_map(subject, context)
|
||||||
subject = format_map(subject, context)
|
mail(
|
||||||
mail(
|
recipient, subject, template, context,
|
||||||
recipient, subject, template, context,
|
self.event,
|
||||||
self.event,
|
self.locale,
|
||||||
self.locale,
|
headers=headers,
|
||||||
headers=headers,
|
sender=sender,
|
||||||
sender=sender,
|
auto_email=auto_email,
|
||||||
auto_email=auto_email,
|
attach_other_files=attach_other_files,
|
||||||
attach_other_files=attach_other_files,
|
attach_cached_files=attach_cached_files,
|
||||||
attach_cached_files=attach_cached_files,
|
)
|
||||||
)
|
self.log_action(
|
||||||
except SendMailException:
|
log_entry_type,
|
||||||
raise
|
user=user,
|
||||||
else:
|
auth=auth,
|
||||||
self.log_action(
|
data={
|
||||||
log_entry_type,
|
'subject': subject,
|
||||||
user=user,
|
'message': email_content,
|
||||||
auth=auth,
|
'recipient': recipient,
|
||||||
data={
|
'attach_other_files': attach_other_files,
|
||||||
'subject': subject,
|
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
|
||||||
'message': email_content,
|
}
|
||||||
'recipient': recipient,
|
)
|
||||||
'attach_other_files': attach_other_files,
|
|
||||||
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def clean_itemvar(event, item, variation):
|
def clean_itemvar(event, item, variation):
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries
|
|||||||
from pretix.helpers.format import format_map
|
from pretix.helpers.format import format_map
|
||||||
from pretix.helpers.money import DecimalTextInput
|
from pretix.helpers.money import DecimalTextInput
|
||||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||||
from pretix.presale.views import get_cart, get_cart_total
|
from pretix.presale.views import get_cart
|
||||||
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
|
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -1149,12 +1149,16 @@ class FreeOrderProvider(BasePaymentProvider):
|
|||||||
from .services.cart import get_fees
|
from .services.cart import get_fees
|
||||||
|
|
||||||
cart = get_cart(request)
|
cart = get_cart(request)
|
||||||
total = get_cart_total(request)
|
|
||||||
try:
|
try:
|
||||||
total += sum([f.value for f in get_fees(self.event, request, total, None, None, cart)])
|
fees = get_fees(event=request.event, request=request,
|
||||||
|
invoice_address=None,
|
||||||
|
payments=None, positions=cart)
|
||||||
except TaxRule.SaleNotAllowed:
|
except TaxRule.SaleNotAllowed:
|
||||||
# ignore for now, will fail on order creation
|
# ignore for now, will fail on order creation
|
||||||
pass
|
fees = []
|
||||||
|
total = sum([c.price for c in cart]) + sum([f.value for f in fees])
|
||||||
|
|
||||||
return total == 0
|
return total == 0
|
||||||
|
|
||||||
def order_change_allowed(self, order: Order) -> bool:
|
def order_change_allowed(self, order: Order) -> bool:
|
||||||
@@ -1227,8 +1231,8 @@ class ManualPayment(BasePaymentProvider):
|
|||||||
def is_allowed(self, request: HttpRequest, total: Decimal=None):
|
def is_allowed(self, request: HttpRequest, total: Decimal=None):
|
||||||
return 'pretix.plugins.manualpayment' in self.event.plugins and super().is_allowed(request, total)
|
return 'pretix.plugins.manualpayment' in self.event.plugins and super().is_allowed(request, total)
|
||||||
|
|
||||||
def order_change_allowed(self, order: Order):
|
def order_change_allowed(self, order: Order, request=None):
|
||||||
return 'pretix.plugins.manualpayment' in self.event.plugins and super().order_change_allowed(order)
|
return 'pretix.plugins.manualpayment' in self.event.plugins and super().order_change_allowed(order, request)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def public_name(self):
|
def public_name(self):
|
||||||
@@ -1373,7 +1377,7 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
execute_payment_needs_user = False
|
execute_payment_needs_user = False
|
||||||
verbose_name = _("Gift card")
|
verbose_name = _("Gift card")
|
||||||
payment_form_class = GiftCardPaymentForm
|
payment_form_class = GiftCardPaymentForm
|
||||||
payment_form_template_name = 'pretixcontrol/giftcards/checkout.html'
|
payment_form_template_name = 'pretixpresale/giftcard/checkout.html'
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def customer_gift_cards(self):
|
def customer_gift_cards(self):
|
||||||
@@ -1500,7 +1504,7 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
return super().order_change_allowed(order) and self.event.organizer.has_gift_cards
|
return super().order_change_allowed(order) and self.event.organizer.has_gift_cards
|
||||||
|
|
||||||
def checkout_confirm_render(self, request, order=None, info_data=None) -> str:
|
def checkout_confirm_render(self, request, order=None, info_data=None) -> str:
|
||||||
return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({
|
return get_template('pretixpresale/giftcard/checkout_confirm.html').render({
|
||||||
'info_data': info_data,
|
'info_data': info_data,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1642,6 +1646,13 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
'transaction_id': trans.pk,
|
'transaction_id': trans.pk,
|
||||||
}
|
}
|
||||||
payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case)
|
payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case)
|
||||||
|
gc.log_action(
|
||||||
|
action='pretix.giftcards.transaction.payment',
|
||||||
|
data={
|
||||||
|
'value': trans.value,
|
||||||
|
'acceptor_id': self.event.organizer.id
|
||||||
|
}
|
||||||
|
)
|
||||||
except PaymentException as e:
|
except PaymentException as e:
|
||||||
payment.fail(info={'error': str(e)})
|
payment.fail(info={'error': str(e)})
|
||||||
raise e
|
raise e
|
||||||
@@ -1666,6 +1677,14 @@ class GiftCardPayment(BasePaymentProvider):
|
|||||||
'transaction_id': trans.pk,
|
'transaction_id': trans.pk,
|
||||||
}
|
}
|
||||||
refund.done()
|
refund.done()
|
||||||
|
gc.log_action(
|
||||||
|
action='pretix.giftcards.transaction.refund',
|
||||||
|
data={
|
||||||
|
'value': refund.amount,
|
||||||
|
'acceptor_id': self.event.organizer.id,
|
||||||
|
'text': refund.comment,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@receiver(register_payment_providers, dispatch_uid="payment_free")
|
@receiver(register_payment_providers, dispatch_uid="payment_free")
|
||||||
|
|||||||
+30
-12
@@ -47,7 +47,6 @@ from collections import OrderedDict, defaultdict
|
|||||||
from functools import partial
|
from functools import partial
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
|
|
||||||
import jsonschema
|
|
||||||
import pypdf
|
import pypdf
|
||||||
import pypdf.generic
|
import pypdf.generic
|
||||||
import reportlab.rl_config
|
import reportlab.rl_config
|
||||||
@@ -72,9 +71,7 @@ from reportlab.lib.colors import Color
|
|||||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
||||||
from reportlab.lib.styles import ParagraphStyle
|
from reportlab.lib.styles import ParagraphStyle
|
||||||
from reportlab.lib.units import mm
|
from reportlab.lib.units import mm
|
||||||
from reportlab.pdfbase import pdfmetrics
|
|
||||||
from reportlab.pdfbase.pdfmetrics import getAscentDescent
|
from reportlab.pdfbase.pdfmetrics import getAscentDescent
|
||||||
from reportlab.pdfbase.ttfonts import TTFont
|
|
||||||
from reportlab.pdfgen.canvas import Canvas
|
from reportlab.pdfgen.canvas import Canvas
|
||||||
from reportlab.platypus import Paragraph
|
from reportlab.platypus import Paragraph
|
||||||
|
|
||||||
@@ -84,7 +81,10 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
|
|||||||
from pretix.base.signals import layout_image_variables, layout_text_variables
|
from pretix.base.signals import layout_image_variables, layout_text_variables
|
||||||
from pretix.base.templatetags.money import money_filter
|
from pretix.base.templatetags.money import money_filter
|
||||||
from pretix.base.templatetags.phone_format import phone_format
|
from pretix.base.templatetags.phone_format import phone_format
|
||||||
from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper
|
from pretix.helpers.daterange import datetimerange
|
||||||
|
from pretix.helpers.reportlab import (
|
||||||
|
ThumbnailingImageReader, register_ttf_font_if_new, reshaper,
|
||||||
|
)
|
||||||
from pretix.presale.style import get_fonts
|
from pretix.presale.style import get_fonts
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -490,6 +490,12 @@ DEFAULT_VARIABLES = OrderedDict((
|
|||||||
"TIME_FORMAT"
|
"TIME_FORMAT"
|
||||||
) if op.valid_until else ""
|
) if op.valid_until else ""
|
||||||
}),
|
}),
|
||||||
|
("program_times", {
|
||||||
|
"label": _("Program times: date and time"),
|
||||||
|
"editor_sample": _(
|
||||||
|
"2017-05-31 10:00 – 12:00\n2017-05-31 14:00 – 16:00\n2017-05-31 14:00 – 2017-06-01 14:00"),
|
||||||
|
"evaluate": lambda op, order, ev: get_program_times(op, ev)
|
||||||
|
}),
|
||||||
("medium_identifier", {
|
("medium_identifier", {
|
||||||
"label": _("Reusable Medium ID"),
|
"label": _("Reusable Medium ID"),
|
||||||
"editor_sample": "ABC1234DEF4567",
|
"editor_sample": "ABC1234DEF4567",
|
||||||
@@ -734,6 +740,16 @@ def get_seat(op: OrderPosition):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_program_times(op: OrderPosition, ev: Event):
|
||||||
|
return '\n'.join([
|
||||||
|
datetimerange(
|
||||||
|
pt.start.astimezone(ev.timezone),
|
||||||
|
pt.end.astimezone(ev.timezone),
|
||||||
|
as_html=False
|
||||||
|
) for pt in op.item.program_times.all()
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
def generate_compressed_addon_list(op, order, event):
|
def generate_compressed_addon_list(op, order, event):
|
||||||
itemcount = defaultdict(int)
|
itemcount = defaultdict(int)
|
||||||
addons = [p for p in (
|
addons = [p for p in (
|
||||||
@@ -778,19 +794,19 @@ class Renderer:
|
|||||||
def _register_fonts(cls, event: Event = None):
|
def _register_fonts(cls, event: Event = None):
|
||||||
if hasattr(cls, '_fonts_registered'):
|
if hasattr(cls, '_fonts_registered'):
|
||||||
return
|
return
|
||||||
pdfmetrics.registerFont(TTFont('Open Sans', finders.find('fonts/OpenSans-Regular.ttf')))
|
register_ttf_font_if_new('Open Sans', finders.find('fonts/OpenSans-Regular.ttf'))
|
||||||
pdfmetrics.registerFont(TTFont('Open Sans I', finders.find('fonts/OpenSans-Italic.ttf')))
|
register_ttf_font_if_new('Open Sans I', finders.find('fonts/OpenSans-Italic.ttf'))
|
||||||
pdfmetrics.registerFont(TTFont('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf')))
|
register_ttf_font_if_new('Open Sans B', finders.find('fonts/OpenSans-Bold.ttf'))
|
||||||
pdfmetrics.registerFont(TTFont('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf')))
|
register_ttf_font_if_new('Open Sans B I', finders.find('fonts/OpenSans-BoldItalic.ttf'))
|
||||||
|
|
||||||
for family, styles in get_fonts(event, pdf_support_required=True).items():
|
for family, styles in get_fonts(event, pdf_support_required=True).items():
|
||||||
pdfmetrics.registerFont(TTFont(family, finders.find(styles['regular']['truetype'])))
|
register_ttf_font_if_new(family, finders.find(styles['regular']['truetype']))
|
||||||
if 'italic' in styles:
|
if 'italic' in styles:
|
||||||
pdfmetrics.registerFont(TTFont(family + ' I', finders.find(styles['italic']['truetype'])))
|
register_ttf_font_if_new(family + ' I', finders.find(styles['italic']['truetype']))
|
||||||
if 'bold' in styles:
|
if 'bold' in styles:
|
||||||
pdfmetrics.registerFont(TTFont(family + ' B', finders.find(styles['bold']['truetype'])))
|
register_ttf_font_if_new(family + ' B', finders.find(styles['bold']['truetype']))
|
||||||
if 'bolditalic' in styles:
|
if 'bolditalic' in styles:
|
||||||
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
|
register_ttf_font_if_new(family + ' B I', finders.find(styles['bolditalic']['truetype']))
|
||||||
|
|
||||||
cls._fonts_registered = True
|
cls._fonts_registered = True
|
||||||
|
|
||||||
@@ -1294,6 +1310,8 @@ def _correct_page_media_box(page: pypdf.PageObject):
|
|||||||
@deconstructible
|
@deconstructible
|
||||||
class PdfLayoutValidator:
|
class PdfLayoutValidator:
|
||||||
def __call__(self, value):
|
def __call__(self, value):
|
||||||
|
import jsonschema
|
||||||
|
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
try:
|
try:
|
||||||
val = json.loads(value)
|
val = json.loads(value)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ def get_all_plugins(*, event=None, organizer=None) -> List[type]:
|
|||||||
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
|
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
level = getattr(app, "level", PLUGIN_LEVEL_EVENT)
|
level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
|
||||||
if level == PLUGIN_LEVEL_EVENT:
|
if level == PLUGIN_LEVEL_EVENT:
|
||||||
if event and hasattr(app, 'is_available'):
|
if event and hasattr(app, 'is_available'):
|
||||||
if not app.is_available(event):
|
if not app.is_available(event):
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ from pretix.base.models import (
|
|||||||
SubEvent, TaxRule, User, WaitingListEntry,
|
SubEvent, TaxRule, User, WaitingListEntry,
|
||||||
)
|
)
|
||||||
from pretix.base.services.locking import LockTimeoutException
|
from pretix.base.services.locking import LockTimeoutException
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import mail
|
||||||
from pretix.base.services.orders import (
|
from pretix.base.services.orders import (
|
||||||
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
|
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
|
||||||
)
|
)
|
||||||
@@ -53,17 +53,14 @@ logger = logging.getLogger(__name__)
|
|||||||
def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent):
|
def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent):
|
||||||
with language(wle.locale, wle.event.settings.region):
|
with language(wle.locale, wle.event.settings.region):
|
||||||
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
|
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
|
||||||
try:
|
mail(
|
||||||
mail(
|
wle.email,
|
||||||
wle.email,
|
format_map(subject, email_context),
|
||||||
format_map(subject, email_context),
|
message,
|
||||||
message,
|
email_context,
|
||||||
email_context,
|
wle.event,
|
||||||
wle.event,
|
locale=wle.locale
|
||||||
locale=wle.locale
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Waiting list canceled email could not be sent')
|
|
||||||
|
|
||||||
|
|
||||||
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
|
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
|
||||||
@@ -77,14 +74,11 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
|
|||||||
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
|
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
|
||||||
order=order, position_or_address=ia, event=order.event)
|
order=order, position_or_address=ia, event=order.event)
|
||||||
real_subject = format_map(subject, email_context)
|
real_subject = format_map(subject, email_context)
|
||||||
try:
|
order.send_mail(
|
||||||
order.send_mail(
|
real_subject, message, email_context,
|
||||||
real_subject, message, email_context,
|
'pretix.event.order.email.event_canceled',
|
||||||
'pretix.event.order.email.event_canceled',
|
user,
|
||||||
user,
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order canceled email could not be sent')
|
|
||||||
|
|
||||||
for p in positions:
|
for p in positions:
|
||||||
if subevent and p.subevent_id != subevent.id:
|
if subevent and p.subevent_id != subevent.id:
|
||||||
@@ -97,15 +91,12 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
|
|||||||
refund_amount=refund_amount,
|
refund_amount=refund_amount,
|
||||||
position_or_address=p,
|
position_or_address=p,
|
||||||
order=order, position=p)
|
order=order, position=p)
|
||||||
try:
|
order.send_mail(
|
||||||
order.send_mail(
|
real_subject, message, email_context,
|
||||||
real_subject, message, email_context,
|
'pretix.event.order.email.event_canceled',
|
||||||
'pretix.event.order.email.event_canceled',
|
position=p,
|
||||||
position=p,
|
user=user
|
||||||
user=user
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
logger.exception('Order canceled email could not be sent to attendee')
|
|
||||||
|
|
||||||
|
|
||||||
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
|
||||||
@@ -350,7 +341,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
|||||||
ocm.add_fee(f)
|
ocm.add_fee(f)
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff), Decimal("0.00"))
|
refund_total += max(payment_refund_sum - (o.total + ocm._totaldiff_guesstimate), Decimal("0.00"))
|
||||||
else:
|
else:
|
||||||
ocm.commit()
|
ocm.commit()
|
||||||
refund_amount = payment_refund_sum - o.total
|
refund_amount = payment_refund_sum - o.total
|
||||||
|
|||||||
+258
-112
@@ -66,8 +66,8 @@ from pretix.base.reldate import RelativeDateWrapper
|
|||||||
from pretix.base.services.checkin import _save_answers
|
from pretix.base.services.checkin import _save_answers
|
||||||
from pretix.base.services.locking import LockTimeoutException, lock_objects
|
from pretix.base.services.locking import LockTimeoutException, lock_objects
|
||||||
from pretix.base.services.pricing import (
|
from pretix.base.services.pricing import (
|
||||||
apply_discounts, get_line_price, get_listed_price, get_price,
|
apply_discounts, apply_rounding, get_line_price, get_listed_price,
|
||||||
is_included_for_free,
|
get_price, is_included_for_free,
|
||||||
)
|
)
|
||||||
from pretix.base.services.quotas import QuotaAvailability
|
from pretix.base.services.quotas import QuotaAvailability
|
||||||
from pretix.base.services.tasks import ProfiledEventTask
|
from pretix.base.services.tasks import ProfiledEventTask
|
||||||
@@ -97,6 +97,10 @@ class CartError(Exception):
|
|||||||
super().__init__(msg)
|
super().__init__(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class CartPositionError(CartError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
error_messages = {
|
error_messages = {
|
||||||
'busy': gettext_lazy(
|
'busy': gettext_lazy(
|
||||||
'We were not able to process your request completely as the '
|
'We were not able to process your request completely as the '
|
||||||
@@ -106,6 +110,9 @@ error_messages = {
|
|||||||
'unknown_position': gettext_lazy('Unknown cart position.'),
|
'unknown_position': gettext_lazy('Unknown cart position.'),
|
||||||
'subevent_required': pgettext_lazy('subevent', 'No date was specified.'),
|
'subevent_required': pgettext_lazy('subevent', 'No date was specified.'),
|
||||||
'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'),
|
'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'),
|
||||||
|
'positions_removed': gettext_lazy(
|
||||||
|
'Some products can no longer be purchased and have been removed from your cart for the following reason: %s'
|
||||||
|
),
|
||||||
'unavailable': gettext_lazy(
|
'unavailable': gettext_lazy(
|
||||||
'Some of the products you selected are no longer available. '
|
'Some of the products you selected are no longer available. '
|
||||||
'Please see below for details.'
|
'Please see below for details.'
|
||||||
@@ -258,6 +265,138 @@ def _get_voucher_availability(event, voucher_use_diff, now_dt, exclude_position_
|
|||||||
return vouchers_ok, _voucher_depend_on_cart
|
return vouchers_ok, _voucher_depend_on_cart
|
||||||
|
|
||||||
|
|
||||||
|
def _check_position_constraints(
|
||||||
|
event: Event, item: Item, variation: ItemVariation, voucher: Voucher, subevent: SubEvent,
|
||||||
|
seat: Seat, sales_channel: SalesChannel, already_in_cart: bool, cart_is_expired: bool, real_now_dt: datetime,
|
||||||
|
item_requires_seat: bool, is_addon: bool, is_bundled: bool,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Checks if a cart position with the given constraints can still be sold. This checks configuration and time-based
|
||||||
|
constraints of item, subevent, and voucher.
|
||||||
|
|
||||||
|
It does NOT
|
||||||
|
- check if quota/voucher/seat are still available
|
||||||
|
- check prices
|
||||||
|
- check memberships
|
||||||
|
- perform any checks that go beyond the single line (like item.max_per_order)
|
||||||
|
"""
|
||||||
|
time_machine_now_dt = time_machine_now(real_now_dt)
|
||||||
|
# Item or variation disabled
|
||||||
|
# Item disabled or unavailable by time
|
||||||
|
if not item.is_available(time_machine_now_dt) or (variation and not variation.is_available(time_machine_now_dt)):
|
||||||
|
raise CartPositionError(error_messages['unavailable'])
|
||||||
|
|
||||||
|
# Invalid media policy for online sale
|
||||||
|
if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
||||||
|
mt = MEDIA_TYPES[item.media_type]
|
||||||
|
if not mt.medium_created_by_server:
|
||||||
|
raise CartPositionError(error_messages['media_usage_not_implemented'])
|
||||||
|
elif item.media_policy == Item.MEDIA_POLICY_REUSE:
|
||||||
|
raise CartPositionError(error_messages['media_usage_not_implemented'])
|
||||||
|
|
||||||
|
# Item removed from sales channel
|
||||||
|
if not item.all_sales_channels:
|
||||||
|
if sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()):
|
||||||
|
raise CartPositionError(error_messages['unavailable'])
|
||||||
|
|
||||||
|
# Variation removed from sales channel
|
||||||
|
if variation and not variation.all_sales_channels:
|
||||||
|
if sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()):
|
||||||
|
raise CartPositionError(error_messages['unavailable'])
|
||||||
|
|
||||||
|
# Item disabled or unavailable by time in subevent
|
||||||
|
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available(time_machine_now_dt):
|
||||||
|
raise CartPositionError(error_messages['not_for_sale'])
|
||||||
|
|
||||||
|
# Variation disabled or unavailable by time in subevent
|
||||||
|
if subevent and variation and variation.pk in subevent.var_overrides and \
|
||||||
|
not subevent.var_overrides[variation.pk].is_available(time_machine_now_dt):
|
||||||
|
raise CartPositionError(error_messages['not_for_sale'])
|
||||||
|
|
||||||
|
# Item requires a variation (should never happen)
|
||||||
|
if item.has_variations and not variation:
|
||||||
|
raise CartPositionError(error_messages['not_for_sale'])
|
||||||
|
|
||||||
|
# Variation belongs to wrong item (should never happen)
|
||||||
|
if variation and variation.item_id != item.pk:
|
||||||
|
raise CartPositionError(error_messages['not_for_sale'])
|
||||||
|
|
||||||
|
# Voucher does not apply to product
|
||||||
|
if voucher and not voucher.applies_to(item, variation):
|
||||||
|
raise CartPositionError(error_messages['voucher_invalid_item'])
|
||||||
|
|
||||||
|
# Voucher does not apply to seat
|
||||||
|
if voucher and voucher.seat and voucher.seat != seat:
|
||||||
|
raise CartPositionError(error_messages['voucher_invalid_seat'])
|
||||||
|
|
||||||
|
# Voucher does not apply to subevent
|
||||||
|
if voucher and voucher.subevent_id and voucher.subevent_id != subevent.pk:
|
||||||
|
raise CartPositionError(error_messages['voucher_invalid_subevent'])
|
||||||
|
|
||||||
|
# Voucher expired
|
||||||
|
if voucher and voucher.valid_until and voucher.valid_until < time_machine_now_dt:
|
||||||
|
raise CartPositionError(error_messages['voucher_expired'])
|
||||||
|
|
||||||
|
# Subevent has been disabled
|
||||||
|
if subevent and not subevent.active:
|
||||||
|
raise CartPositionError(error_messages['inactive_subevent'])
|
||||||
|
|
||||||
|
# Subevent sale not started
|
||||||
|
if subevent and subevent.effective_presale_start and time_machine_now_dt < subevent.effective_presale_start:
|
||||||
|
raise CartPositionError(error_messages['not_started'])
|
||||||
|
|
||||||
|
# Subevent sale has ended
|
||||||
|
if subevent and subevent.presale_has_ended:
|
||||||
|
raise CartPositionError(error_messages['ended'])
|
||||||
|
|
||||||
|
# Payment for subevent no longer possible
|
||||||
|
if subevent:
|
||||||
|
tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||||
|
if tlv:
|
||||||
|
term_last = make_aware(datetime.combine(
|
||||||
|
tlv.datetime(subevent).date(),
|
||||||
|
time(hour=23, minute=59, second=59)
|
||||||
|
), event.timezone)
|
||||||
|
if term_last < time_machine_now_dt:
|
||||||
|
raise CartPositionError(error_messages['payment_ended'])
|
||||||
|
|
||||||
|
# Seat required but no seat given
|
||||||
|
if item_requires_seat and not seat:
|
||||||
|
raise CartPositionError(error_messages['seat_invalid'])
|
||||||
|
|
||||||
|
# Seat given but no seat required
|
||||||
|
if seat and not item_requires_seat:
|
||||||
|
raise CartPositionError(error_messages['seat_forbidden'])
|
||||||
|
|
||||||
|
# Item requires to be add-on but is top-level position
|
||||||
|
if item.category and item.category.is_addon and not is_addon:
|
||||||
|
raise CartPositionError(error_messages['addon_only'])
|
||||||
|
|
||||||
|
# Item requires bundling but is top-level position
|
||||||
|
if item.require_bundling and not is_bundled:
|
||||||
|
raise CartPositionError(error_messages['bundled_only'])
|
||||||
|
|
||||||
|
# Seat for wrong product
|
||||||
|
if seat and seat.product != item:
|
||||||
|
raise CartPositionError(error_messages['seat_invalid'])
|
||||||
|
|
||||||
|
# Seat blocked
|
||||||
|
if seat and seat.blocked and sales_channel.identifier not in event.settings.seating_allow_blocked_seats_for_channel:
|
||||||
|
raise CartPositionError(error_messages['seat_invalid'])
|
||||||
|
|
||||||
|
# Item requires voucher but no voucher given
|
||||||
|
if item.require_voucher and voucher is None and not is_bundled:
|
||||||
|
raise CartPositionError(error_messages['voucher_required'])
|
||||||
|
|
||||||
|
# Item or variation is hidden without voucher but no voucher is given
|
||||||
|
if (
|
||||||
|
(item.hide_without_voucher or (variation and variation.hide_without_voucher)) and
|
||||||
|
(voucher is None or not voucher.show_hidden_items) and
|
||||||
|
not is_bundled
|
||||||
|
):
|
||||||
|
raise CartPositionError(error_messages['voucher_required'])
|
||||||
|
|
||||||
|
|
||||||
class CartManager:
|
class CartManager:
|
||||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
|
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
|
||||||
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
|
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
|
||||||
@@ -294,6 +433,7 @@ class CartManager:
|
|||||||
self._widget_data = widget_data or {}
|
self._widget_data = widget_data or {}
|
||||||
self._sales_channel = sales_channel
|
self._sales_channel = sales_channel
|
||||||
self.num_extended_positions = 0
|
self.num_extended_positions = 0
|
||||||
|
self.price_change_for_extended = False
|
||||||
|
|
||||||
if reservation_time:
|
if reservation_time:
|
||||||
self._reservation_time = reservation_time
|
self._reservation_time = reservation_time
|
||||||
@@ -421,14 +561,14 @@ class CartManager:
|
|||||||
if cartsize > limit:
|
if cartsize > limit:
|
||||||
raise CartError(error_messages['max_items'] % limit)
|
raise CartError(error_messages['max_items'] % limit)
|
||||||
|
|
||||||
def _check_item_constraints(self, op, current_ops=[]):
|
def _check_item_constraints(self, op):
|
||||||
if isinstance(op, (self.AddOperation, self.ExtendOperation)):
|
if isinstance(op, (self.AddOperation, self.ExtendOperation)):
|
||||||
if not (
|
if not (
|
||||||
(isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or
|
(isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or
|
||||||
(isinstance(op, self.ExtendOperation) and op.position.is_bundled)
|
(isinstance(op, self.ExtendOperation) and op.position.is_bundled)
|
||||||
):
|
):
|
||||||
if op.item.require_voucher and op.voucher is None:
|
if op.item.require_voucher and op.voucher is None:
|
||||||
if getattr(op, 'voucher_ignored', False):
|
if getattr(op, 'voucher_ignored', False): # todo??
|
||||||
raise CartError(error_messages['voucher_redeemed'])
|
raise CartError(error_messages['voucher_redeemed'])
|
||||||
raise CartError(error_messages['voucher_required'])
|
raise CartError(error_messages['voucher_required'])
|
||||||
|
|
||||||
@@ -440,88 +580,39 @@ class CartManager:
|
|||||||
raise CartError(error_messages['voucher_redeemed'])
|
raise CartError(error_messages['voucher_redeemed'])
|
||||||
raise CartError(error_messages['voucher_required'])
|
raise CartError(error_messages['voucher_required'])
|
||||||
|
|
||||||
if not op.item.is_available() or (op.variation and not op.variation.is_available()):
|
if op.seat and op.count > 1:
|
||||||
raise CartError(error_messages['unavailable'])
|
|
||||||
|
|
||||||
if op.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
|
||||||
mt = MEDIA_TYPES[op.item.media_type]
|
|
||||||
if not mt.medium_created_by_server:
|
|
||||||
raise CartError(error_messages['media_usage_not_implemented'])
|
|
||||||
elif op.item.media_policy == Item.MEDIA_POLICY_REUSE:
|
|
||||||
raise CartError(error_messages['media_usage_not_implemented'])
|
|
||||||
|
|
||||||
if not op.item.all_sales_channels:
|
|
||||||
if self._sales_channel.identifier not in (s.identifier for s in op.item.limit_sales_channels.all()):
|
|
||||||
raise CartError(error_messages['unavailable'])
|
|
||||||
|
|
||||||
if op.variation and not op.variation.all_sales_channels:
|
|
||||||
if self._sales_channel.identifier not in (s.identifier for s in op.variation.limit_sales_channels.all()):
|
|
||||||
raise CartError(error_messages['unavailable'])
|
|
||||||
|
|
||||||
if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
|
|
||||||
raise CartError(error_messages['not_for_sale'])
|
|
||||||
|
|
||||||
if op.subevent and op.variation and op.variation.pk in op.subevent.var_overrides and \
|
|
||||||
not op.subevent.var_overrides[op.variation.pk].is_available():
|
|
||||||
raise CartError(error_messages['not_for_sale'])
|
|
||||||
|
|
||||||
if op.item.has_variations and not op.variation:
|
|
||||||
raise CartError(error_messages['not_for_sale'])
|
|
||||||
|
|
||||||
if op.variation and op.variation.item_id != op.item.pk:
|
|
||||||
raise CartError(error_messages['not_for_sale'])
|
|
||||||
|
|
||||||
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
|
|
||||||
raise CartError(error_messages['voucher_invalid_item'])
|
|
||||||
|
|
||||||
if op.voucher and op.voucher.seat and op.voucher.seat != op.seat:
|
|
||||||
raise CartError(error_messages['voucher_invalid_seat'])
|
|
||||||
|
|
||||||
if op.voucher and op.voucher.subevent_id and op.voucher.subevent_id != op.subevent.pk:
|
|
||||||
raise CartError(error_messages['voucher_invalid_subevent'])
|
|
||||||
|
|
||||||
if op.subevent and not op.subevent.active:
|
|
||||||
raise CartError(error_messages['inactive_subevent'])
|
|
||||||
|
|
||||||
if op.subevent and op.subevent.presale_start and time_machine_now(self.real_now_dt) < op.subevent.presale_start:
|
|
||||||
raise CartError(error_messages['not_started'])
|
|
||||||
|
|
||||||
if op.subevent and op.subevent.presale_has_ended:
|
|
||||||
raise CartError(error_messages['ended'])
|
|
||||||
|
|
||||||
seated = self._is_seated(op.item, op.subevent)
|
|
||||||
if (
|
|
||||||
seated and (
|
|
||||||
not op.seat or (
|
|
||||||
op.seat.blocked and
|
|
||||||
self._sales_channel.identifier not in self.event.settings.seating_allow_blocked_seats_for_channel
|
|
||||||
)
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise CartError(error_messages['seat_invalid'])
|
|
||||||
elif op.seat and not seated:
|
|
||||||
raise CartError(error_messages['seat_forbidden'])
|
|
||||||
elif op.seat and op.seat.product != op.item:
|
|
||||||
raise CartError(error_messages['seat_invalid'])
|
|
||||||
elif op.seat and op.count > 1:
|
|
||||||
raise CartError('Invalid request: A seat can only be bought once.')
|
raise CartError('Invalid request: A seat can only be bought once.')
|
||||||
|
|
||||||
if op.subevent:
|
if isinstance(op, self.AddOperation):
|
||||||
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
is_addon = op.addon_to
|
||||||
if tlv:
|
is_bundled = op.addon_to == "FAKE"
|
||||||
term_last = make_aware(datetime.combine(
|
else:
|
||||||
tlv.datetime(op.subevent).date(),
|
is_addon = op.position.addon_to
|
||||||
time(hour=23, minute=59, second=59)
|
is_bundled = op.position.is_bundled
|
||||||
), self.event.timezone)
|
|
||||||
if term_last < time_machine_now(self.real_now_dt):
|
|
||||||
raise CartError(error_messages['payment_ended'])
|
|
||||||
|
|
||||||
if isinstance(op, self.AddOperation):
|
try:
|
||||||
if op.item.category and op.item.category.is_addon and not (op.addon_to and op.addon_to != 'FAKE'):
|
_check_position_constraints(
|
||||||
raise CartError(error_messages['addon_only'])
|
event=self.event,
|
||||||
|
item=op.item,
|
||||||
if op.item.require_bundling and not op.addon_to == 'FAKE':
|
variation=op.variation,
|
||||||
raise CartError(error_messages['bundled_only'])
|
voucher=op.voucher,
|
||||||
|
subevent=op.subevent,
|
||||||
|
seat=op.seat,
|
||||||
|
sales_channel=self._sales_channel,
|
||||||
|
already_in_cart=isinstance(op, self.ExtendOperation),
|
||||||
|
cart_is_expired=isinstance(op, self.ExtendOperation),
|
||||||
|
real_now_dt=self.real_now_dt,
|
||||||
|
item_requires_seat=self._is_seated(op.item, op.subevent),
|
||||||
|
is_addon=is_addon,
|
||||||
|
is_bundled=is_bundled,
|
||||||
|
)
|
||||||
|
# Quota, seat, and voucher availability is checked for in perform_operations
|
||||||
|
# Price changes are checked for in extend_expired_positions
|
||||||
|
except CartPositionError as e:
|
||||||
|
if e.args[0] == error_messages['voucher_required'] and getattr(op, 'voucher_ignored', False):
|
||||||
|
# This is the case where someone clicks +1 on a voucher-only item with a fully redeemed voucher:
|
||||||
|
raise CartPositionError(error_messages['voucher_redeemed'])
|
||||||
|
raise
|
||||||
|
|
||||||
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
||||||
voucher: Optional[Voucher], custom_price: Optional[Decimal],
|
voucher: Optional[Voucher], custom_price: Optional[Decimal],
|
||||||
@@ -541,7 +632,7 @@ class CartManager:
|
|||||||
else:
|
else:
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
def extend_expired_positions(self):
|
def _extend_expired_positions(self):
|
||||||
requires_seat = Exists(
|
requires_seat = Exists(
|
||||||
SeatCategoryMapping.objects.filter(
|
SeatCategoryMapping.objects.filter(
|
||||||
Q(product=OuterRef('item'))
|
Q(product=OuterRef('item'))
|
||||||
@@ -604,10 +695,14 @@ class CartManager:
|
|||||||
quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price,
|
quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price,
|
||||||
price_after_voucher=price_after_voucher,
|
price_after_voucher=price_after_voucher,
|
||||||
)
|
)
|
||||||
self._check_item_constraints(op)
|
try:
|
||||||
|
self._check_item_constraints(op)
|
||||||
|
except CartPositionError as e:
|
||||||
|
self._operations.append(self.RemoveOperation(position=cp))
|
||||||
|
err = error_messages['positions_removed'] % str(e)
|
||||||
|
|
||||||
if cp.voucher:
|
if cp.voucher:
|
||||||
self._voucher_use_diff[cp.voucher] += 2
|
self._voucher_use_diff[cp.voucher] += 1
|
||||||
|
|
||||||
self._operations.append(op)
|
self._operations.append(op)
|
||||||
return err
|
return err
|
||||||
@@ -797,7 +892,7 @@ class CartManager:
|
|||||||
custom_price_input_is_net=False,
|
custom_price_input_is_net=False,
|
||||||
voucher_ignored=False,
|
voucher_ignored=False,
|
||||||
)
|
)
|
||||||
self._check_item_constraints(bop, operations)
|
self._check_item_constraints(bop)
|
||||||
bundled.append(bop)
|
bundled.append(bop)
|
||||||
|
|
||||||
listed_price = get_listed_price(item, variation, subevent)
|
listed_price = get_listed_price(item, variation, subevent)
|
||||||
@@ -836,7 +931,7 @@ class CartManager:
|
|||||||
custom_price_input_is_net=self.event.settings.display_net_prices,
|
custom_price_input_is_net=self.event.settings.display_net_prices,
|
||||||
voucher_ignored=voucher_ignored,
|
voucher_ignored=voucher_ignored,
|
||||||
)
|
)
|
||||||
self._check_item_constraints(op, operations)
|
self._check_item_constraints(op)
|
||||||
operations.append(op)
|
operations.append(op)
|
||||||
|
|
||||||
self._quota_diff.update(quota_diff)
|
self._quota_diff.update(quota_diff)
|
||||||
@@ -975,7 +1070,7 @@ class CartManager:
|
|||||||
custom_price_input_is_net=self.event.settings.display_net_prices,
|
custom_price_input_is_net=self.event.settings.display_net_prices,
|
||||||
voucher_ignored=False,
|
voucher_ignored=False,
|
||||||
)
|
)
|
||||||
self._check_item_constraints(op, operations)
|
self._check_item_constraints(op)
|
||||||
operations.append(op)
|
operations.append(op)
|
||||||
|
|
||||||
# Check constraints on the add-on combinations
|
# Check constraints on the add-on combinations
|
||||||
@@ -1172,7 +1267,9 @@ class CartManager:
|
|||||||
op.position.delete()
|
op.position.delete()
|
||||||
|
|
||||||
elif isinstance(op, (self.AddOperation, self.ExtendOperation)):
|
elif isinstance(op, (self.AddOperation, self.ExtendOperation)):
|
||||||
# Create a CartPosition for as much items as we can
|
if isinstance(op, self.ExtendOperation) and (op.position.pk in deleted_positions or not op.position.pk):
|
||||||
|
continue # Already deleted in other operation
|
||||||
|
# Create a CartPosition for as many items as we can
|
||||||
requested_count = quota_available_count = voucher_available_count = op.count
|
requested_count = quota_available_count = voucher_available_count = op.count
|
||||||
|
|
||||||
if op.seat:
|
if op.seat:
|
||||||
@@ -1343,6 +1440,8 @@ class CartManager:
|
|||||||
addons.delete()
|
addons.delete()
|
||||||
op.position.delete()
|
op.position.delete()
|
||||||
elif available_count == 1:
|
elif available_count == 1:
|
||||||
|
if op.price_after_voucher != op.position.price_after_voucher:
|
||||||
|
self.price_change_for_extended = True
|
||||||
op.position.expires = self._expiry
|
op.position.expires = self._expiry
|
||||||
op.position.max_extend = self._max_expiry_extend
|
op.position.max_extend = self._max_expiry_extend
|
||||||
op.position.listed_price = op.listed_price
|
op.position.listed_price = op.listed_price
|
||||||
@@ -1361,6 +1460,11 @@ class CartManager:
|
|||||||
deleted_positions.add(op.position.pk)
|
deleted_positions.add(op.position.pk)
|
||||||
addons.delete()
|
addons.delete()
|
||||||
op.position.delete()
|
op.position.delete()
|
||||||
|
if op.position.is_bundled:
|
||||||
|
deleted_positions |= {a.pk for a in op.position.addon_to.addons.all()}
|
||||||
|
deleted_positions.add(op.position.addon_to.pk)
|
||||||
|
op.position.addon_to.addons.all().delete()
|
||||||
|
op.position.addon_to.delete()
|
||||||
else:
|
else:
|
||||||
raise AssertionError("ExtendOperation cannot affect more than one item")
|
raise AssertionError("ExtendOperation cannot affect more than one item")
|
||||||
elif isinstance(op, self.VoucherOperation):
|
elif isinstance(op, self.VoucherOperation):
|
||||||
@@ -1424,29 +1528,39 @@ class CartManager:
|
|||||||
self._sales_channel.identifier,
|
self._sales_channel.identifier,
|
||||||
[
|
[
|
||||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
||||||
bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
|
||||||
for cp in positions
|
for cp in positions
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
for cp, (new_price, discount) in zip(positions, discount_results):
|
for cp, (new_price, discount) in zip(positions, discount_results):
|
||||||
if cp.price != new_price or cp.discount_id != (discount.pk if discount else None):
|
if cp.gross_price_before_rounding != new_price or cp.discount_id != (discount.pk if discount else None):
|
||||||
diff += new_price - cp.price
|
diff += new_price - cp.gross_price_before_rounding
|
||||||
cp.price = new_price
|
cp.price = new_price
|
||||||
|
cp.price_includes_rounding_correction = Decimal("0.00")
|
||||||
cp.discount = discount
|
cp.discount = discount
|
||||||
cp.save(update_fields=['price', 'discount'])
|
cp.save(update_fields=['price', 'price_includes_rounding_correction', 'discount'])
|
||||||
|
|
||||||
return diff
|
return diff
|
||||||
|
|
||||||
|
def _remove_parents_if_bundles_are_removed(self):
|
||||||
|
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
|
||||||
|
for op in self._operations:
|
||||||
|
if isinstance(op, self.RemoveOperation):
|
||||||
|
if op.position.is_bundled and op.position.addon_to_id not in removed_positions:
|
||||||
|
self._operations.append(self.RemoveOperation(position=op.position.addon_to))
|
||||||
|
removed_positions.add(op.position.addon_to_id)
|
||||||
|
|
||||||
def commit(self):
|
def commit(self):
|
||||||
self._check_presale_dates()
|
self._check_presale_dates()
|
||||||
self._check_max_cart_size()
|
self._check_max_cart_size()
|
||||||
|
|
||||||
err = self._delete_out_of_timeframe()
|
err = self._delete_out_of_timeframe()
|
||||||
err = self.extend_expired_positions() or err
|
err = self._extend_expired_positions() or err
|
||||||
err = err or self._check_min_per_voucher()
|
err = err or self._check_min_per_voucher()
|
||||||
|
|
||||||
self._extend_expiry_of_valid_existing_positions()
|
self._extend_expiry_of_valid_existing_positions()
|
||||||
|
self._remove_parents_if_bundles_are_removed()
|
||||||
err = self._perform_operations() or err
|
err = self._perform_operations() or err
|
||||||
self.recompute_final_prices_and_taxes()
|
self.recompute_final_prices_and_taxes()
|
||||||
|
|
||||||
@@ -1493,30 +1607,53 @@ def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: D
|
|||||||
add_payment_to_cart_session(cs, provider, min_value, max_value, info_data)
|
add_payment_to_cart_session(cs, provider, min_value, max_value, info_data)
|
||||||
|
|
||||||
|
|
||||||
def get_fees(event, request, total, invoice_address, payments, positions):
|
def get_fees(event, request, _total_ignored_=None, invoice_address=None, payments=None, positions=None):
|
||||||
|
"""
|
||||||
|
Return all fees that would be created for the current cart. Also implicitly applies rounding on the order
|
||||||
|
positions. A recommended usage pattern to compute the total looks like this::
|
||||||
|
|
||||||
|
cart = get_cart(request)
|
||||||
|
fees = get_fees(
|
||||||
|
event=request.event,
|
||||||
|
request=request,
|
||||||
|
invoice_address=cached_invoice_address(request),
|
||||||
|
payments=None,
|
||||||
|
positions=cart,
|
||||||
|
)
|
||||||
|
total = sum([c.price for c in cart]) + sum([f.value for f in fees])
|
||||||
|
"""
|
||||||
if payments and not isinstance(payments, list):
|
if payments and not isinstance(payments, list):
|
||||||
raise TypeError("payments must now be a list")
|
raise TypeError("payments must now be a list")
|
||||||
|
if positions is None:
|
||||||
|
raise TypeError("Must pass positions, parameter is only optional for backwards-compat reasons")
|
||||||
|
|
||||||
fees = []
|
fees = []
|
||||||
|
total = sum([c.gross_price_before_rounding for c in positions])
|
||||||
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
|
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
|
||||||
total=total, positions=positions, payment_requests=payments):
|
positions=positions, total=total, payment_requests=payments):
|
||||||
if resp:
|
if resp:
|
||||||
fees += resp
|
fees += resp
|
||||||
|
|
||||||
total = total + sum(f.value for f in fees)
|
for fee in fees:
|
||||||
|
fee._calculate_tax(invoice_address=invoice_address, event=event)
|
||||||
|
if fee.tax_rule and not fee.tax_rule.pk:
|
||||||
|
fee.tax_rule = None # TODO: deprecate
|
||||||
|
|
||||||
|
apply_rounding(event.settings.tax_rounding, invoice_address, event.currency, [*positions, *fees])
|
||||||
|
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
||||||
|
|
||||||
if total != 0 and payments:
|
if total != 0 and payments:
|
||||||
total_remaining = total
|
payments_assigned = Decimal("0.00")
|
||||||
for p in payments:
|
for p in payments:
|
||||||
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
# This algorithm of treating min/max values and fees needs to stay in sync between the following
|
||||||
# places in the code base:
|
# places in the code base:
|
||||||
# - pretix.base.services.cart.get_fees
|
# - pretix.base.services.cart.get_fees
|
||||||
# - pretix.base.services.orders._get_fees
|
# - pretix.base.services.orders._get_fees
|
||||||
# - pretix.presale.views.CartMixin.current_selected_payments
|
# - pretix.presale.views.CartMixin.current_selected_payments
|
||||||
if p.get('min_value') and total_remaining < Decimal(p['min_value']):
|
if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
to_pay = total_remaining
|
to_pay = max(total - payments_assigned, Decimal("0.00"))
|
||||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||||
|
|
||||||
@@ -1525,28 +1662,32 @@ def get_fees(event, request, total, invoice_address, payments, positions):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
payment_fee = pprov.calculate_fee(to_pay)
|
payment_fee = pprov.calculate_fee(to_pay)
|
||||||
total_remaining += payment_fee
|
|
||||||
to_pay += payment_fee
|
|
||||||
|
|
||||||
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
|
||||||
to_pay = min(to_pay, Decimal(p['max_value']))
|
|
||||||
|
|
||||||
total_remaining -= to_pay
|
|
||||||
|
|
||||||
if payment_fee:
|
if payment_fee:
|
||||||
if event.settings.tax_rule_payment == "default":
|
if event.settings.tax_rule_payment == "default":
|
||||||
payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero()
|
payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero()
|
||||||
else:
|
else:
|
||||||
payment_fee_tax_rule = TaxRule.zero()
|
payment_fee_tax_rule = TaxRule.zero()
|
||||||
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address)
|
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address)
|
||||||
fees.append(OrderFee(
|
pf = OrderFee(
|
||||||
fee_type=OrderFee.FEE_TYPE_PAYMENT,
|
fee_type=OrderFee.FEE_TYPE_PAYMENT,
|
||||||
value=payment_fee,
|
value=payment_fee,
|
||||||
tax_rate=payment_fee_tax.rate,
|
tax_rate=payment_fee_tax.rate,
|
||||||
tax_value=payment_fee_tax.tax,
|
tax_value=payment_fee_tax.tax,
|
||||||
tax_code=payment_fee_tax.code,
|
tax_code=payment_fee_tax.code,
|
||||||
tax_rule=payment_fee_tax_rule
|
tax_rule=payment_fee_tax_rule
|
||||||
))
|
)
|
||||||
|
fees.append(pf)
|
||||||
|
|
||||||
|
# Re-apply rounding as grand total has changed
|
||||||
|
apply_rounding(event.settings.tax_rounding, invoice_address, event.currency, [*positions, *fees])
|
||||||
|
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
|
||||||
|
|
||||||
|
# Re-calculate to_pay as grand total has changed
|
||||||
|
to_pay = max(total - payments_assigned, Decimal("0.00"))
|
||||||
|
if p.get('max_value') and to_pay > Decimal(p['max_value']):
|
||||||
|
to_pay = min(to_pay, Decimal(p['max_value']))
|
||||||
|
|
||||||
|
payments_assigned += to_pay
|
||||||
|
|
||||||
return fees
|
return fees
|
||||||
|
|
||||||
@@ -1675,7 +1816,12 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en',
|
|||||||
try:
|
try:
|
||||||
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
|
||||||
cm.commit()
|
cm.commit()
|
||||||
return {"success": cm.num_extended_positions, "expiry": cm._expiry, "max_expiry_extend": cm._max_expiry_extend}
|
return {
|
||||||
|
"success": cm.num_extended_positions,
|
||||||
|
"expiry": cm._expiry,
|
||||||
|
"max_expiry_extend": cm._max_expiry_extend,
|
||||||
|
"price_changed": cm.price_change_for_extended,
|
||||||
|
}
|
||||||
except LockTimeoutException:
|
except LockTimeoutException:
|
||||||
self.retry()
|
self.retry()
|
||||||
except (MaxRetriesExceededError, LockTimeoutException):
|
except (MaxRetriesExceededError, LockTimeoutException):
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
|
from django.db.models import Exists, OuterRef
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
from pretix.base.models import CachedCombinedTicket, CachedTicket
|
from pretix.base.models import CachedCombinedTicket, CachedTicket, OutgoingMail
|
||||||
from pretix.base.models.customers import CustomerSSOGrant
|
from pretix.base.models.customers import CustomerSSOGrant
|
||||||
|
|
||||||
from ..models import CachedFile, CartPosition, InvoiceAddress
|
from ..models import CachedFile, CartPosition, InvoiceAddress
|
||||||
@@ -49,7 +50,18 @@ def clean_cart_positions(sender, **kwargs):
|
|||||||
@receiver(signal=periodic_task)
|
@receiver(signal=periodic_task)
|
||||||
@scopes_disabled()
|
@scopes_disabled()
|
||||||
def clean_cached_files(sender, **kwargs):
|
def clean_cached_files(sender, **kwargs):
|
||||||
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()):
|
has_queued_email = Exists(
|
||||||
|
OutgoingMail.objects.filter(
|
||||||
|
should_attach_cached_files__pk=OuterRef("pk"),
|
||||||
|
status__in=(
|
||||||
|
OutgoingMail.STATUS_QUEUED,
|
||||||
|
OutgoingMail.STATUS_INFLIGHT,
|
||||||
|
OutgoingMail.STATUS_AWAITING_RETRY,
|
||||||
|
OutgoingMail.STATUS_FAILED,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()).exclude(has_queued_email):
|
||||||
cf.delete()
|
cf.delete()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class CrossSellingService:
|
|||||||
self.sales_channel,
|
self.sales_channel,
|
||||||
[
|
[
|
||||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
||||||
bool(cp.addon_to), cp.is_bundled,
|
cp.addon_to, cp.is_bundled,
|
||||||
cp.listed_price - cp.price_after_voucher)
|
cp.listed_price - cp.price_after_voucher)
|
||||||
for cp in self.cartpositions
|
for cp in self.cartpositions
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
|||||||
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
||||||
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
||||||
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
|
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
|
||||||
|
invoice.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
|
||||||
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
|
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
|
||||||
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
|
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
|
||||||
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
|
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
|
||||||
@@ -258,9 +259,15 @@ def build_invoice(invoice: Invoice) -> Invoice:
|
|||||||
if resp:
|
if resp:
|
||||||
desc += "<br/>" + resp
|
desc += "<br/>" + resp
|
||||||
|
|
||||||
for answ in p.answers.all():
|
answers_qs = p.answers.filter(
|
||||||
if not answ.question.print_on_invoice:
|
question__print_on_invoice=True
|
||||||
continue
|
).select_related(
|
||||||
|
'question'
|
||||||
|
).order_by(
|
||||||
|
'question__position',
|
||||||
|
'question__id'
|
||||||
|
)
|
||||||
|
for answ in answers_qs:
|
||||||
desc += "<br />{}{} {}".format(
|
desc += "<br />{}{} {}".format(
|
||||||
answ.question.question,
|
answ.question.question,
|
||||||
"" if str(answ.question.question).endswith("?") else ":",
|
"" if str(answ.question.question).endswith("?") else ":",
|
||||||
@@ -453,6 +460,7 @@ def generate_cancellation(invoice: Invoice, trigger_pdf=True):
|
|||||||
cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
cancellation.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
||||||
cancellation.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
cancellation.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
||||||
cancellation.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
|
cancellation.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
|
||||||
|
cancellation.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
|
||||||
cancellation.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
|
cancellation.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
|
||||||
cancellation.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
|
cancellation.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
|
||||||
cancellation.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
|
cancellation.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
|
||||||
@@ -513,9 +521,20 @@ def invoice_pdf_task(invoice: int):
|
|||||||
|
|
||||||
|
|
||||||
def invoice_qualified(order: Order):
|
def invoice_qualified(order: Order):
|
||||||
if order.total == Decimal('0.00') or order.require_approval or \
|
if order.total == Decimal('0.00'):
|
||||||
order.sales_channel.identifier not in order.event.settings.get('invoice_generate_sales_channels'):
|
|
||||||
return False
|
return False
|
||||||
|
if order.require_approval:
|
||||||
|
return False
|
||||||
|
if order.sales_channel.identifier not in order.event.settings.invoice_generate_sales_channels:
|
||||||
|
return False
|
||||||
|
if order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
|
||||||
|
return False
|
||||||
|
if order.event.settings.invoice_generate_only_business:
|
||||||
|
try:
|
||||||
|
ia = order.invoice_address
|
||||||
|
return ia.is_business
|
||||||
|
except InvoiceAddress.DoesNotExist:
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -556,6 +575,7 @@ def build_preview_invoice_pdf(event):
|
|||||||
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
invoice.invoice_from_name = invoice.event.settings.get('invoice_address_from_name')
|
||||||
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
invoice.invoice_from_zipcode = invoice.event.settings.get('invoice_address_from_zipcode')
|
||||||
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
|
invoice.invoice_from_city = invoice.event.settings.get('invoice_address_from_city')
|
||||||
|
invoice.invoice_from_state = invoice.event.settings.get('invoice_address_from_state')
|
||||||
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
|
invoice.invoice_from_country = invoice.event.settings.get('invoice_address_from_country')
|
||||||
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
|
invoice.invoice_from_tax_id = invoice.event.settings.get('invoice_address_from_tax_id')
|
||||||
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
|
invoice.invoice_from_vat_id = invoice.event.settings.get('invoice_address_from_vat_id')
|
||||||
@@ -687,7 +707,7 @@ def retry_stuck_invoices(sender, **kwargs):
|
|||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
qs = Invoice.objects.filter(
|
qs = Invoice.objects.filter(
|
||||||
transmission_status=Invoice.TRANSMISSION_STATUS_INFLIGHT,
|
transmission_status=Invoice.TRANSMISSION_STATUS_INFLIGHT,
|
||||||
transmission_date__lte=now() - timedelta(hours=24),
|
transmission_date__lte=now() - timedelta(hours=48),
|
||||||
).select_for_update(
|
).select_for_update(
|
||||||
of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked
|
of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked
|
||||||
)
|
)
|
||||||
|
|||||||
+657
-410
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,8 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
# <https://www.gnu.org/licenses/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import uuid
|
||||||
|
|
||||||
import css_inline
|
import css_inline
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
@@ -26,12 +28,15 @@ from django.utils.timezone import override
|
|||||||
from django_scopes import scope, scopes_disabled
|
from django_scopes import scope, scopes_disabled
|
||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import LogEntry, NotificationSetting, User
|
from pretix.base.models import (
|
||||||
|
LogEntry, NotificationSetting, OutgoingMail, User,
|
||||||
|
)
|
||||||
from pretix.base.notifications import Notification, get_all_notification_types
|
from pretix.base.notifications import Notification, get_all_notification_types
|
||||||
from pretix.base.services.mail import mail_send_task
|
from pretix.base.services.mail import mail_send_task
|
||||||
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
|
||||||
from pretix.base.signals import notification
|
from pretix.base.signals import notification
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
|
from pretix.helpers.celery import get_task_priority
|
||||||
from pretix.helpers.urls import build_absolute_uri
|
from pretix.helpers.urls import build_absolute_uri
|
||||||
|
|
||||||
|
|
||||||
@@ -88,12 +93,18 @@ def notify(logentry_ids: list):
|
|||||||
for um, enabled in notify_specific.items():
|
for um, enabled in notify_specific.items():
|
||||||
user, method = um
|
user, method = um
|
||||||
if enabled:
|
if enabled:
|
||||||
send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method))
|
send_notification.apply_async(
|
||||||
|
args=(logentry.id, notification_type.action_type, user.pk, method),
|
||||||
|
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||||
|
)
|
||||||
|
|
||||||
for um, enabled in notify_global.items():
|
for um, enabled in notify_global.items():
|
||||||
user, method = um
|
user, method = um
|
||||||
if enabled and um not in notify_specific:
|
if enabled and um not in notify_specific:
|
||||||
send_notification.apply_async(args=(logentry.id, notification_type.action_type, user.pk, method))
|
send_notification.apply_async(
|
||||||
|
args=(logentry.id, notification_type.action_type, user.pk, method),
|
||||||
|
priority=get_task_priority("notifications", logentry.organizer_id),
|
||||||
|
)
|
||||||
|
|
||||||
notification.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type)
|
notification.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type)
|
||||||
|
|
||||||
@@ -146,16 +157,26 @@ def send_notification_mail(notification: Notification, user: User):
|
|||||||
tpl_plain = get_template('pretixbase/email/notification.txt')
|
tpl_plain = get_template('pretixbase/email/notification.txt')
|
||||||
body_plain = tpl_plain.render(ctx)
|
body_plain = tpl_plain.render(ctx)
|
||||||
|
|
||||||
mail_send_task.apply_async(kwargs={
|
guid = uuid.uuid4()
|
||||||
'to': [user.email],
|
m = OutgoingMail.objects.create(
|
||||||
'subject': '[{}] {}: {}'.format(
|
guid=guid,
|
||||||
|
user=user,
|
||||||
|
to=[user.email],
|
||||||
|
subject='[{}] {}: {}'.format(
|
||||||
settings.PRETIX_INSTANCE_NAME,
|
settings.PRETIX_INSTANCE_NAME,
|
||||||
notification.event.settings.mail_prefix or notification.event.slug.upper(),
|
notification.event.settings.mail_prefix or notification.event.slug.upper(),
|
||||||
notification.title
|
notification.title
|
||||||
),
|
),
|
||||||
'body': body_plain,
|
body_plain=body_plain,
|
||||||
'html': body_html,
|
body_html=body_html,
|
||||||
'sender': settings.MAIL_FROM_NOTIFICATIONS,
|
sender=settings.MAIL_FROM_NOTIFICATIONS,
|
||||||
'headers': {},
|
headers={
|
||||||
'user': user.pk
|
'X-Auto-Response-Suppress': 'OOF, NRN, AutoReply, RN',
|
||||||
|
'Auto-Submitted': 'auto-generated',
|
||||||
|
'X-Mailer': 'pretix',
|
||||||
|
'X-PX-Correlation': str(guid),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
mail_send_task.apply_async(kwargs={
|
||||||
|
'outgoing_mail': m.pk,
|
||||||
})
|
})
|
||||||
|
|||||||
+407
-298
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.utils.formats import date_format
|
from django.utils.formats import date_format
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape, mark_safe
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@@ -123,6 +123,10 @@ class BaseRichTextPlaceholder(BaseTextPlaceholder):
|
|||||||
def identifier(self):
|
def identifier(self):
|
||||||
return self._identifier
|
return self._identifier
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allowed_in_plain_content(self):
|
||||||
|
return False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def required_context(self):
|
def required_context(self):
|
||||||
return self._args
|
return self._args
|
||||||
@@ -194,6 +198,33 @@ class SimpleButtonPlaceholder(BaseRichTextPlaceholder):
|
|||||||
return f'{text}: {url}'
|
return f'{text}: {url}'
|
||||||
|
|
||||||
|
|
||||||
|
class MarkdownTextPlaceholder(BaseRichTextPlaceholder):
|
||||||
|
def __init__(self, identifier, args, func, sample, inline):
|
||||||
|
super().__init__(identifier, args)
|
||||||
|
self._func = func
|
||||||
|
self._sample = sample
|
||||||
|
self._snippet = inline
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allowed_in_plain_content(self):
|
||||||
|
return self._snippet
|
||||||
|
|
||||||
|
def render_plain(self, **context):
|
||||||
|
return self._func(**{k: context[k] for k in self._args})
|
||||||
|
|
||||||
|
def render_html(self, **context):
|
||||||
|
return mark_safe(markdown_compile_email(self.render_plain(**context), snippet=self._snippet))
|
||||||
|
|
||||||
|
def render_sample_plain(self, event):
|
||||||
|
if callable(self._sample):
|
||||||
|
return self._sample(event)
|
||||||
|
else:
|
||||||
|
return self._sample
|
||||||
|
|
||||||
|
def render_sample_html(self, event):
|
||||||
|
return mark_safe(markdown_compile_email(self.render_sample_plain(event), snippet=self._snippet))
|
||||||
|
|
||||||
|
|
||||||
class PlaceholderContext(SafeFormatter):
|
class PlaceholderContext(SafeFormatter):
|
||||||
"""
|
"""
|
||||||
Holds the contextual arguments and corresponding list of available placeholders for formatting
|
Holds the contextual arguments and corresponding list of available placeholders for formatting
|
||||||
@@ -574,7 +605,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
|
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
|
||||||
_('Sample Corporation')
|
_('Sample Corporation')
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
MarkdownTextPlaceholder(
|
||||||
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
||||||
'* {} - {}'.format(
|
'* {} - {}'.format(
|
||||||
order.full_code,
|
order.full_code,
|
||||||
@@ -604,6 +635,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
|
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
|
inline=False,
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
|
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
|
||||||
@@ -618,12 +650,13 @@ def base_placeholders(sender, **kwargs):
|
|||||||
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
|
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
|
||||||
'68CYU2H6ZTP3WLK5'
|
'68CYU2H6ZTP3WLK5'
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
MarkdownTextPlaceholder(
|
||||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||||
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
|
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
|
||||||
' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
|
'68CYU2H6ZTP3WLK5 \n7MB94KKPVEPSMVF2',
|
||||||
|
inline=False,
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
MarkdownTextPlaceholder(
|
||||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||||
'voucher_url_list', ['event', 'voucher_list'],
|
'voucher_url_list', ['event', 'voucher_list'],
|
||||||
lambda event, voucher_list: ' \n'.join([
|
lambda event, voucher_list: ' \n'.join([
|
||||||
@@ -638,6 +671,7 @@ def base_placeholders(sender, **kwargs):
|
|||||||
) + '?voucher=' + c
|
) + '?voucher=' + c
|
||||||
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
|
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
|
||||||
]),
|
]),
|
||||||
|
inline=False,
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
|
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||||
@@ -656,13 +690,13 @@ def base_placeholders(sender, **kwargs):
|
|||||||
'comment', ['comment'], lambda comment: comment,
|
'comment', ['comment'], lambda comment: comment,
|
||||||
_('An individual text with a reason can be inserted here.'),
|
_('An individual text with a reason can be inserted here.'),
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
MarkdownTextPlaceholder(
|
||||||
'payment_info', ['order', 'payments'], _placeholder_payments,
|
'payment_info', ['order', 'payments'], _placeholder_payments,
|
||||||
_('The amount has been charged to your card.'),
|
_('The amount has been charged to your card.'), inline=False,
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
MarkdownTextPlaceholder(
|
||||||
'payment_info', ['payment_info'], lambda payment_info: payment_info,
|
'payment_info', ['payment_info'], lambda payment_info: payment_info,
|
||||||
_('Please transfer money to this bank account: 9999-9999-9999-9999'),
|
_('Please transfer money to this bank account: 9999-9999-9999-9999'), inline=False,
|
||||||
),
|
),
|
||||||
SimpleFunctionalTextPlaceholder(
|
SimpleFunctionalTextPlaceholder(
|
||||||
'attendee_name', ['position'], lambda position: position.attendee_name,
|
'attendee_name', ['position'], lambda position: position.attendee_name,
|
||||||
@@ -719,13 +753,13 @@ def base_placeholders(sender, **kwargs):
|
|||||||
))
|
))
|
||||||
|
|
||||||
for k, v in sender.meta_data.items():
|
for k, v in sender.meta_data.items():
|
||||||
ph.append(SimpleFunctionalTextPlaceholder(
|
ph.append(MarkdownTextPlaceholder(
|
||||||
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
|
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
|
||||||
v
|
v, inline=True,
|
||||||
))
|
))
|
||||||
ph.append(SimpleFunctionalTextPlaceholder(
|
ph.append(MarkdownTextPlaceholder(
|
||||||
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
|
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
|
||||||
v
|
v, inline=True,
|
||||||
))
|
))
|
||||||
|
|
||||||
return ph
|
return ph
|
||||||
@@ -753,7 +787,7 @@ def get_available_placeholders(event, base_parameters, rich=False):
|
|||||||
if not isinstance(val, (list, tuple)):
|
if not isinstance(val, (list, tuple)):
|
||||||
val = [val]
|
val = [val]
|
||||||
for v in val:
|
for v in val:
|
||||||
if isinstance(v, BaseRichTextPlaceholder) and not rich:
|
if isinstance(v, BaseRichTextPlaceholder) and not rich and not v.allowed_in_plain_content:
|
||||||
continue
|
continue
|
||||||
if all(rp in base_parameters for rp in v.required_context):
|
if all(rp in base_parameters for rp in v.required_context):
|
||||||
params[v.identifier] = v
|
params[v.identifier] = v
|
||||||
@@ -767,7 +801,11 @@ def get_sample_context(event, context_parameters, rich=True):
|
|||||||
sample = v.render_sample(event)
|
sample = v.render_sample(event)
|
||||||
if isinstance(sample, PlainHtmlAlternativeString):
|
if isinstance(sample, PlainHtmlAlternativeString):
|
||||||
context_dict[k] = PlainHtmlAlternativeString(
|
context_dict[k] = PlainHtmlAlternativeString(
|
||||||
sample.plain,
|
'<{el} class="placeholder" title="{title}">{plain}</{el}>'.format(
|
||||||
|
el='span',
|
||||||
|
title=lbl,
|
||||||
|
plain=escape(sample.plain),
|
||||||
|
),
|
||||||
'<{el} class="placeholder placeholder-html" title="{title}">{html}</{el}>'.format(
|
'<{el} class="placeholder placeholder-html" title="{title}">{html}</{el}>'.format(
|
||||||
el='div' if sample.is_block else 'span',
|
el='div' if sample.is_block else 'span',
|
||||||
title=lbl,
|
title=lbl,
|
||||||
@@ -775,13 +813,13 @@ def get_sample_context(event, context_parameters, rich=True):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif str(sample).strip().startswith('* ') or str(sample).startswith(' '):
|
elif str(sample).strip().startswith('* ') or str(sample).startswith(' '):
|
||||||
context_dict[k] = '<div class="placeholder" title="{}">{}</div>'.format(
|
context_dict[k] = mark_safe('<div class="placeholder" title="{}">{}</div>'.format(
|
||||||
lbl,
|
lbl,
|
||||||
markdown_compile_email(str(sample))
|
markdown_compile_email(str(sample))
|
||||||
)
|
))
|
||||||
else:
|
else:
|
||||||
context_dict[k] = '<span class="placeholder" title="{}">{}</span>'.format(
|
context_dict[k] = mark_safe('<span class="placeholder" title="{}">{}</span>'.format(
|
||||||
lbl,
|
lbl,
|
||||||
escape(sample)
|
escape(sample)
|
||||||
)
|
))
|
||||||
return context_dict
|
return context_dict
|
||||||
|
|||||||
@@ -23,15 +23,17 @@ import re
|
|||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import List, Optional, Tuple, Union
|
from itertools import groupby
|
||||||
|
from typing import List, Literal, Optional, Tuple, Union
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from pretix.base.decimal import round_decimal
|
from pretix.base.decimal import round_decimal
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
|
AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
|
||||||
SalesChannel, Voucher,
|
ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
|
||||||
)
|
)
|
||||||
from pretix.base.models.discount import Discount, PositionInfo
|
from pretix.base.models.discount import Discount, PositionInfo
|
||||||
from pretix.base.models.event import Event, SubEvent
|
from pretix.base.models.event import Event, SubEvent
|
||||||
@@ -136,7 +138,8 @@ def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubE
|
|||||||
|
|
||||||
|
|
||||||
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
|
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
|
||||||
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal, is_bundled=False) -> TaxedPrice:
|
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal,
|
||||||
|
is_bundled=False) -> TaxedPrice:
|
||||||
if not tax_rule:
|
if not tax_rule:
|
||||||
tax_rule = TaxRule(
|
tax_rule = TaxRule(
|
||||||
name='',
|
name='',
|
||||||
@@ -152,7 +155,8 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
|
|||||||
override_tax_code=price.code, invoice_address=invoice_address,
|
override_tax_code=price.code, invoice_address=invoice_address,
|
||||||
subtract_from_gross=bundled_sum)
|
subtract_from_gross=bundled_sum)
|
||||||
else:
|
else:
|
||||||
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate,
|
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross',
|
||||||
|
override_tax_rate=price.rate,
|
||||||
override_tax_code=price.code, invoice_address=invoice_address,
|
override_tax_code=price.code, invoice_address=invoice_address,
|
||||||
subtract_from_gross=bundled_sum)
|
subtract_from_gross=bundled_sum)
|
||||||
else:
|
else:
|
||||||
@@ -164,13 +168,15 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
|
|||||||
|
|
||||||
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
||||||
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
|
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
|
||||||
collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]:
|
collect_potential_discounts: Optional[defaultdict] = None) -> List[Tuple[Decimal, Optional[Discount]]]:
|
||||||
"""
|
"""
|
||||||
Applies any dynamic discounts to a cart
|
Applies any dynamic discounts to a cart
|
||||||
|
|
||||||
:param event: Event the cart belongs to
|
:param event: Event the cart belongs to
|
||||||
:param sales_channel: Sales channel the cart was created with
|
:param sales_channel: Sales channel the cart was created with
|
||||||
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
|
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled, voucher_discount)``
|
||||||
|
``addon_to_id`` does not have to be the proper ID, any identifier is okay, even ``True``/``False`` are accepted, but
|
||||||
|
a better result may be given if addons to the same main product have the same distinct value.
|
||||||
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
|
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
|
||||||
based on the "consumed" items, but lack matching "benefitting" items will be collected therein.
|
based on the "consumed" items, but lack matching "benefitting" items will be collected therein.
|
||||||
The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list
|
The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list
|
||||||
@@ -192,9 +198,9 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
|||||||
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
|
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
|
||||||
for discount in discount_qs:
|
for discount in discount_qs:
|
||||||
result = discount.apply({
|
result = discount.apply({
|
||||||
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount)
|
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount)
|
||||||
for
|
for
|
||||||
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)
|
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount)
|
||||||
in enumerate(positions)
|
in enumerate(positions)
|
||||||
if not is_bundled and idx not in new_prices
|
if not is_bundled and idx not in new_prices
|
||||||
}, collect_potential_discounts)
|
}, collect_potential_discounts)
|
||||||
@@ -203,3 +209,128 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
|
|||||||
new_prices.update(result)
|
new_prices.update(result)
|
||||||
|
|
||||||
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
|
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_only_business", "sum_by_net_keep_gross"],
|
||||||
|
invoice_address: Optional[InvoiceAddress], currency: str,
|
||||||
|
lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
|
||||||
|
"""
|
||||||
|
Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode
|
||||||
|
and mutates the ``price``, ``price_includes_rounding_correction``, ``tax_value``, and
|
||||||
|
``tax_value_includes_rounding_correction`` attributes.
|
||||||
|
|
||||||
|
When rounding mode is set to ``"line"``, the tax will be computed and rounded individually for every line.
|
||||||
|
|
||||||
|
When rounding mode is set to ``"sum_by_net_keep_gross"``, the tax values of the individual lines will be adjusted
|
||||||
|
such that the per-taxrate/taxcode subtotal is rounded correctly. The gross prices will stay constant.
|
||||||
|
|
||||||
|
When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be
|
||||||
|
adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant.
|
||||||
|
|
||||||
|
:param rounding_mode: One of ``"line"``, ``"sum_by_net"``, ``"sum_by_net_only_business"``, or ``"sum_by_net_keep_gross"``.
|
||||||
|
:param invoice_address: The invoice address, or ``None``
|
||||||
|
:param currency: Currency that will be used to determine rounding precision
|
||||||
|
:param lines: List of order/cart contents
|
||||||
|
:return: Collection of ``lines`` members that have been changed and may need to be persisted to the database.
|
||||||
|
"""
|
||||||
|
if rounding_mode == "sum_by_net_only_business":
|
||||||
|
if invoice_address and invoice_address.is_business:
|
||||||
|
rounding_mode = "sum_by_net"
|
||||||
|
else:
|
||||||
|
rounding_mode = "line"
|
||||||
|
|
||||||
|
def _key(line):
|
||||||
|
return (line.tax_rate, line.tax_code or "")
|
||||||
|
|
||||||
|
places = settings.CURRENCY_PLACES.get(currency, 2)
|
||||||
|
minimum_unit = Decimal('1') / 10 ** places
|
||||||
|
changed = []
|
||||||
|
|
||||||
|
if rounding_mode == "sum_by_net":
|
||||||
|
for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
|
||||||
|
lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding))
|
||||||
|
|
||||||
|
# Compute the net and gross total of the line-based computation method
|
||||||
|
net_total = sum(l.net_price_before_rounding for l in lines)
|
||||||
|
gross_total = sum(l.gross_price_before_rounding for l in lines)
|
||||||
|
|
||||||
|
# Compute the gross total we need to achieve based on the net total
|
||||||
|
target_gross_total = round_decimal((net_total * (1 + tax_rate / 100)), currency)
|
||||||
|
|
||||||
|
# Add/subtract the smallest possible from both gross prices and tax values (so net values stay the same)
|
||||||
|
# until the values align
|
||||||
|
diff = target_gross_total - gross_total
|
||||||
|
diff_sgn = -1 if diff < 0 else 1
|
||||||
|
for l in lines:
|
||||||
|
if diff:
|
||||||
|
apply_diff = diff_sgn * minimum_unit
|
||||||
|
l.price = l.gross_price_before_rounding + apply_diff
|
||||||
|
l.price_includes_rounding_correction = apply_diff
|
||||||
|
l.tax_value = l.tax_value_before_rounding + apply_diff
|
||||||
|
l.tax_value_includes_rounding_correction = apply_diff
|
||||||
|
diff -= apply_diff
|
||||||
|
changed.append(l)
|
||||||
|
elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
|
||||||
|
l.price = l.gross_price_before_rounding
|
||||||
|
l.price_includes_rounding_correction = Decimal("0.00")
|
||||||
|
l.tax_value = l.tax_value_before_rounding
|
||||||
|
l.tax_value_includes_rounding_correction = Decimal("0.00")
|
||||||
|
changed.append(l)
|
||||||
|
|
||||||
|
elif rounding_mode == "sum_by_net_keep_gross":
|
||||||
|
for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
|
||||||
|
lines = list(sorted(lines, key=lambda l: -l.gross_price_before_rounding))
|
||||||
|
|
||||||
|
# Compute the net and gross total of the line-based computation method
|
||||||
|
net_total = sum(l.net_price_before_rounding for l in lines)
|
||||||
|
gross_total = sum(l.gross_price_before_rounding for l in lines)
|
||||||
|
|
||||||
|
# Compute the net total that would yield the correct gross total (if possible)
|
||||||
|
target_net_total = round_decimal(gross_total - (gross_total * (1 - 100 / (100 + tax_rate))), currency)
|
||||||
|
|
||||||
|
# Compute the gross total that would be computed from that net total – this will be different than
|
||||||
|
# gross_total when there is no possible net value for the gross total
|
||||||
|
# e.g. 99.99 at 19% is impossible since 84.03 + 19% = 100.00 and 84.02 + 19% = 99.98
|
||||||
|
target_gross_total = round_decimal((target_net_total * (1 + tax_rate / 100)), currency)
|
||||||
|
|
||||||
|
diff_gross = target_gross_total - gross_total
|
||||||
|
diff_net = target_net_total - net_total
|
||||||
|
diff_gross_sgn = -1 if diff_gross < 0 else 1
|
||||||
|
diff_net_sgn = -1 if diff_net < 0 else 1
|
||||||
|
for l in lines:
|
||||||
|
if diff_gross:
|
||||||
|
apply_diff = diff_gross_sgn * minimum_unit
|
||||||
|
l.price = l.gross_price_before_rounding + apply_diff
|
||||||
|
l.price_includes_rounding_correction = apply_diff
|
||||||
|
l.tax_value = l.tax_value_before_rounding + apply_diff
|
||||||
|
l.tax_value_includes_rounding_correction = apply_diff
|
||||||
|
changed.append(l)
|
||||||
|
diff_gross -= apply_diff
|
||||||
|
elif diff_net:
|
||||||
|
apply_diff = diff_net_sgn * minimum_unit
|
||||||
|
l.price = l.gross_price_before_rounding
|
||||||
|
l.price_includes_rounding_correction = Decimal("0.00")
|
||||||
|
l.tax_value = l.tax_value_before_rounding - apply_diff
|
||||||
|
l.tax_value_includes_rounding_correction = -apply_diff
|
||||||
|
changed.append(l)
|
||||||
|
diff_net -= apply_diff
|
||||||
|
elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
|
||||||
|
l.price = l.gross_price_before_rounding
|
||||||
|
l.price_includes_rounding_correction = Decimal("0.00")
|
||||||
|
l.tax_value = l.tax_value_before_rounding
|
||||||
|
l.tax_value_includes_rounding_correction = Decimal("0.00")
|
||||||
|
changed.append(l)
|
||||||
|
|
||||||
|
elif rounding_mode == "line":
|
||||||
|
for l in lines:
|
||||||
|
if l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
|
||||||
|
l.price = l.gross_price_before_rounding
|
||||||
|
l.price_includes_rounding_correction = Decimal("0.00")
|
||||||
|
l.tax_value = l.tax_value_before_rounding
|
||||||
|
l.tax_value_includes_rounding_correction = Decimal("0.00")
|
||||||
|
changed.append(l)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown rounding_mode")
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from pretix.base.i18n import language
|
from pretix.base.i18n import language
|
||||||
from pretix.base.models import CachedFile, Event, User, cachedfile_name
|
from pretix.base.models import CachedFile, Event, User, cachedfile_name
|
||||||
from pretix.base.services.mail import SendMailException, mail
|
from pretix.base.services.mail import mail
|
||||||
from pretix.base.services.tasks import ProfiledEventTask
|
from pretix.base.services.tasks import ProfiledEventTask
|
||||||
from pretix.base.shredder import ShredError
|
from pretix.base.shredder import ShredError
|
||||||
from pretix.celery_app import app
|
from pretix.celery_app import app
|
||||||
@@ -171,21 +171,18 @@ def shred(self, event: Event, fileid: str, confirm_code: str, user: int=None, lo
|
|||||||
|
|
||||||
if user:
|
if user:
|
||||||
with language(user.locale):
|
with language(user.locale):
|
||||||
try:
|
mail(
|
||||||
mail(
|
user.email,
|
||||||
user.email,
|
_('Data shredding completed'),
|
||||||
_('Data shredding completed'),
|
'pretixbase/email/shred_completed.txt',
|
||||||
'pretixbase/email/shred_completed.txt',
|
{
|
||||||
{
|
'user': user,
|
||||||
'user': user,
|
'organizer': event.organizer.name,
|
||||||
'organizer': event.organizer.name,
|
'event': str(event.name),
|
||||||
'event': str(event.name),
|
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
|
||||||
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
|
'shredders': ', '.join([str(s.verbose_name) for s in shredders])
|
||||||
'shredders': ', '.join([str(s.verbose_name) for s in shredders])
|
},
|
||||||
},
|
event=None,
|
||||||
event=None,
|
user=user,
|
||||||
user=user,
|
locale=user.locale,
|
||||||
locale=user.locale,
|
)
|
||||||
)
|
|
||||||
except SendMailException:
|
|
||||||
pass # Already logged
|
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ def dictsum(*dicts) -> dict:
|
|||||||
|
|
||||||
def order_overview(
|
def order_overview(
|
||||||
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False,
|
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False,
|
||||||
admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None
|
admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None,
|
||||||
|
skip_empty_lines=False,
|
||||||
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
|
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
|
||||||
items = event.items.all().select_related(
|
items = event.items.all().select_related(
|
||||||
'category', # for re-grouping
|
'category', # for re-grouping
|
||||||
@@ -205,13 +206,21 @@ def order_overview(
|
|||||||
for l in states.keys():
|
for l in states.keys():
|
||||||
var.num[l] = num[l].get((item.id, variid), (0, 0, 0))
|
var.num[l] = num[l].get((item.id, variid), (0, 0, 0))
|
||||||
var.num['total'] = num['total'].get((item.id, variid), (0, 0, 0))
|
var.num['total'] = num['total'].get((item.id, variid), (0, 0, 0))
|
||||||
|
var._skip = all(v[0] == 0 for v in var.num.values())
|
||||||
for l in states.keys():
|
for l in states.keys():
|
||||||
item.num[l] = tuplesum(var.num[l] for var in item.all_variations)
|
item.num[l] = tuplesum(var.num[l] for var in item.all_variations)
|
||||||
item.num['total'] = tuplesum(var.num['total'] for var in item.all_variations)
|
item.num['total'] = tuplesum(var.num['total'] for var in item.all_variations)
|
||||||
|
if skip_empty_lines:
|
||||||
|
item.all_variations = [v for v in item.all_variations if not v._skip]
|
||||||
|
item._skip = not item.all_variations
|
||||||
else:
|
else:
|
||||||
for l in states.keys():
|
for l in states.keys():
|
||||||
item.num[l] = num[l].get((item.id, None), (0, 0, 0))
|
item.num[l] = num[l].get((item.id, None), (0, 0, 0))
|
||||||
item.num['total'] = num['total'].get((item.id, None), (0, 0, 0))
|
item.num['total'] = num['total'].get((item.id, None), (0, 0, 0))
|
||||||
|
item._skip = all(v[0] == 0 for v in item.num.values())
|
||||||
|
|
||||||
|
if skip_empty_lines:
|
||||||
|
items = [i for i in items if not i._skip]
|
||||||
|
|
||||||
nonecat = ItemCategory(name=_('Uncategorized'))
|
nonecat = ItemCategory(name=_('Uncategorized'))
|
||||||
# Regroup those by category
|
# Regroup those by category
|
||||||
|
|||||||
+186
-13
@@ -27,7 +27,6 @@ from decimal import Decimal
|
|||||||
from xml.etree import ElementTree
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import vat_moss.id
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from zeep import Client, Transport
|
from zeep import Client, Transport
|
||||||
@@ -42,14 +41,142 @@ logger = logging.getLogger(__name__)
|
|||||||
error_messages = {
|
error_messages = {
|
||||||
'unavailable': _(
|
'unavailable': _(
|
||||||
'Your VAT ID could not be checked, as the VAT checking service of '
|
'Your VAT ID could not be checked, as the VAT checking service of '
|
||||||
'your country is currently not available. We will therefore '
|
'your country is currently not available. We will therefore need to '
|
||||||
'need to charge VAT on your invoice. You can get the tax amount '
|
'charge you the same tax rate as if you did not enter a VAT ID.'
|
||||||
'back via the VAT reimbursement process.'
|
|
||||||
),
|
),
|
||||||
'invalid': _('This VAT ID is not valid. Please re-check your input.'),
|
'invalid': _('This VAT ID is not valid. Please re-check your input.'),
|
||||||
'country_mismatch': _('Your VAT ID does not match the selected country.'),
|
'country_mismatch': _('Your VAT ID does not match the selected country.'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VAT_ID_PATTERNS = {
|
||||||
|
# Patterns generated by consulting the following URLs:
|
||||||
|
#
|
||||||
|
# - http://en.wikipedia.org/wiki/VAT_identification_number
|
||||||
|
# - http://ec.europa.eu/taxation_customs/vies/faq.html
|
||||||
|
# - https://euipo.europa.eu/tunnel-web/secure/webdav/guest/document_library/Documents/COSME/VAT%20numbers%20EU.pdf
|
||||||
|
# - http://www.skatteetaten.no/en/International-pages/Felles-innhold-benyttes-i-flere-malgrupper/Brochure/Guide-to-value-added-tax-in-Norway/?chapter=7159
|
||||||
|
'AT': { # Austria
|
||||||
|
'regex': '^U\\d{8}$',
|
||||||
|
'country_code': 'AT'
|
||||||
|
},
|
||||||
|
'BE': { # Belgium
|
||||||
|
'regex': '^(1|0?)\\d{9}$',
|
||||||
|
'country_code': 'BE'
|
||||||
|
},
|
||||||
|
'BG': { # Bulgaria
|
||||||
|
'regex': '^\\d{9,10}$',
|
||||||
|
'country_code': 'BG'
|
||||||
|
},
|
||||||
|
'CH': { # Switzerland
|
||||||
|
'regex': '^\\dE{9}$',
|
||||||
|
'country_code': 'CH'
|
||||||
|
},
|
||||||
|
'CY': { # Cyprus
|
||||||
|
'regex': '^\\d{8}[A-Z]$',
|
||||||
|
'country_code': 'CY'
|
||||||
|
},
|
||||||
|
'CZ': { # Czech Republic
|
||||||
|
'regex': '^\\d{8,10}$',
|
||||||
|
'country_code': 'CZ'
|
||||||
|
},
|
||||||
|
'DE': { # Germany
|
||||||
|
'regex': '^\\d{9}$',
|
||||||
|
'country_code': 'DE'
|
||||||
|
},
|
||||||
|
'DK': { # Denmark
|
||||||
|
'regex': '^\\d{8}$',
|
||||||
|
'country_code': 'DK'
|
||||||
|
},
|
||||||
|
'EE': { # Estonia
|
||||||
|
'regex': '^\\d{9}$',
|
||||||
|
'country_code': 'EE'
|
||||||
|
},
|
||||||
|
'EL': { # Greece
|
||||||
|
'regex': '^\\d{9}$',
|
||||||
|
'country_code': 'GR'
|
||||||
|
},
|
||||||
|
'ES': { # Spain
|
||||||
|
'regex': '^[A-Z0-9]\\d{7}[A-Z0-9]$',
|
||||||
|
'country_code': 'ES'
|
||||||
|
},
|
||||||
|
'FI': { # Finland
|
||||||
|
'regex': '^\\d{8}$',
|
||||||
|
'country_code': 'FI'
|
||||||
|
},
|
||||||
|
'FR': { # France
|
||||||
|
'regex': '^[A-Z0-9]{2}\\d{9}$',
|
||||||
|
'country_code': 'FR'
|
||||||
|
},
|
||||||
|
'GB': { # United Kingdom
|
||||||
|
'regex': '^(GD\\d{3}|HA\\d{3}|\\d{9}|\\d{12})$',
|
||||||
|
'country_code': 'GB'
|
||||||
|
},
|
||||||
|
'HR': { # Croatia
|
||||||
|
'regex': '^\\d{11}$',
|
||||||
|
'country_code': 'HR'
|
||||||
|
},
|
||||||
|
'HU': { # Hungary
|
||||||
|
'regex': '^\\d{8}$',
|
||||||
|
'country_code': 'HU'
|
||||||
|
},
|
||||||
|
'IE': { # Ireland
|
||||||
|
'regex': '^(\\d{7}[A-Z]{1,2}|\\d[A-Z+*]\\d{5}[A-Z])$',
|
||||||
|
'country_code': 'IE'
|
||||||
|
},
|
||||||
|
'IT': { # Italy
|
||||||
|
'regex': '^\\d{11}$',
|
||||||
|
'country_code': 'IT'
|
||||||
|
},
|
||||||
|
'LT': { # Lithuania
|
||||||
|
'regex': '^(\\d{9}|\\d{12})$',
|
||||||
|
'country_code': 'LT'
|
||||||
|
},
|
||||||
|
'LU': { # Luxembourg
|
||||||
|
'regex': '^\\d{8}$',
|
||||||
|
'country_code': 'LU'
|
||||||
|
},
|
||||||
|
'LV': { # Latvia
|
||||||
|
'regex': '^\\d{11}$',
|
||||||
|
'country_code': 'LV'
|
||||||
|
},
|
||||||
|
'MT': { # Malta
|
||||||
|
'regex': '^\\d{8}$',
|
||||||
|
'country_code': 'MT'
|
||||||
|
},
|
||||||
|
'NL': { # Netherlands
|
||||||
|
'regex': '^\\d{9}B\\d{2}$',
|
||||||
|
'country_code': 'NL'
|
||||||
|
},
|
||||||
|
'NO': { # Norway
|
||||||
|
'regex': '^\\d{9}MVA$',
|
||||||
|
'country_code': 'NO'
|
||||||
|
},
|
||||||
|
'PL': { # Poland
|
||||||
|
'regex': '^\\d{10}$',
|
||||||
|
'country_code': 'PL'
|
||||||
|
},
|
||||||
|
'PT': { # Portugal
|
||||||
|
'regex': '^\\d{9}$',
|
||||||
|
'country_code': 'PT'
|
||||||
|
},
|
||||||
|
'RO': { # Romania
|
||||||
|
'regex': '^\\d{2,10}$',
|
||||||
|
'country_code': 'RO'
|
||||||
|
},
|
||||||
|
'SE': { # Sweden
|
||||||
|
'regex': '^\\d{12}$',
|
||||||
|
'country_code': 'SE'
|
||||||
|
},
|
||||||
|
'SI': { # Slovenia
|
||||||
|
'regex': '^\\d{8}$',
|
||||||
|
'country_code': 'SI'
|
||||||
|
},
|
||||||
|
'SK': { # Slovakia
|
||||||
|
'regex': '^\\d{10}$',
|
||||||
|
'country_code': 'SK'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class VATIDError(Exception):
|
class VATIDError(Exception):
|
||||||
def __init__(self, message):
|
def __init__(self, message):
|
||||||
@@ -64,13 +191,57 @@ class VATIDTemporaryError(VATIDError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_vat_id(vat_id, country_code):
|
||||||
|
"""
|
||||||
|
Accepts a VAT ID and normaizes it, getting rid of spaces, periods, dashes
|
||||||
|
etc and converting it to upper case.
|
||||||
|
|
||||||
|
Original function from https://github.com/wbond/vat_moss-python
|
||||||
|
Copyright (c) 2015 Will Bond <will@wbond.net>
|
||||||
|
MIT License
|
||||||
|
"""
|
||||||
|
if not vat_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(vat_id, str):
|
||||||
|
raise TypeError('VAT ID is not a string')
|
||||||
|
|
||||||
|
if len(vat_id) < 3:
|
||||||
|
raise ValueError('VAT ID must be at least three character long')
|
||||||
|
|
||||||
|
# Normalize the ID for simpler regexes
|
||||||
|
vat_id = re.sub('\\s+', '', vat_id)
|
||||||
|
vat_id = vat_id.replace('-', '')
|
||||||
|
vat_id = vat_id.replace('.', '')
|
||||||
|
vat_id = vat_id.upper()
|
||||||
|
|
||||||
|
# Clean the different shapes a number can take in Switzerland depending on purpse
|
||||||
|
if country_code == "CH":
|
||||||
|
vat_id = re.sub('[^A-Z0-9]', '', vat_id.replace('HR', '').replace('MWST', ''))
|
||||||
|
|
||||||
|
# Fix people using GR prefix for Greece
|
||||||
|
if vat_id[0:2] == "GR" and country_code == "GR":
|
||||||
|
vat_id = "EL" + vat_id[2:]
|
||||||
|
|
||||||
|
# Check if we already have a valid country prefix. If not, we try to figure out if we can
|
||||||
|
# add one, since in some countries (e.g. Italy) it's very custom to enter it without the prefix
|
||||||
|
if vat_id[:2] in VAT_ID_PATTERNS and re.match(VAT_ID_PATTERNS[vat_id[0:2]]['regex'], vat_id[2:]):
|
||||||
|
# Prefix set and prefix matches pattern, nothing to do
|
||||||
|
pass
|
||||||
|
elif re.match(VAT_ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], vat_id):
|
||||||
|
# Prefix not set but adding it fixes pattern
|
||||||
|
vat_id = cc_to_vat_prefix(country_code) + vat_id
|
||||||
|
else:
|
||||||
|
# We have no idea what this is
|
||||||
|
pass
|
||||||
|
|
||||||
|
return vat_id
|
||||||
|
|
||||||
|
|
||||||
def _validate_vat_id_NO(vat_id, country_code):
|
def _validate_vat_id_NO(vat_id, country_code):
|
||||||
# Inspired by vat_moss library
|
# Inspired by vat_moss library
|
||||||
if not vat_id.startswith("NO"):
|
|
||||||
# prefix is not usually used in Norway, but expected by vat_moss library
|
|
||||||
vat_id = "NO" + vat_id
|
|
||||||
try:
|
try:
|
||||||
vat_id = vat_moss.id.normalize(vat_id)
|
vat_id = normalize_vat_id(vat_id, country_code)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise VATIDFinalError(error_messages['invalid'])
|
raise VATIDFinalError(error_messages['invalid'])
|
||||||
|
|
||||||
@@ -104,7 +275,7 @@ def _validate_vat_id_NO(vat_id, country_code):
|
|||||||
def _validate_vat_id_EU(vat_id, country_code):
|
def _validate_vat_id_EU(vat_id, country_code):
|
||||||
# Inspired by vat_moss library
|
# Inspired by vat_moss library
|
||||||
try:
|
try:
|
||||||
vat_id = vat_moss.id.normalize(vat_id)
|
vat_id = normalize_vat_id(vat_id, country_code)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise VATIDFinalError(error_messages['invalid'])
|
raise VATIDFinalError(error_messages['invalid'])
|
||||||
|
|
||||||
@@ -112,11 +283,10 @@ def _validate_vat_id_EU(vat_id, country_code):
|
|||||||
raise VATIDFinalError(error_messages['invalid'])
|
raise VATIDFinalError(error_messages['invalid'])
|
||||||
|
|
||||||
number = vat_id[2:]
|
number = vat_id[2:]
|
||||||
|
|
||||||
if vat_id[:2] != cc_to_vat_prefix(country_code):
|
if vat_id[:2] != cc_to_vat_prefix(country_code):
|
||||||
raise VATIDFinalError(error_messages['country_mismatch'])
|
raise VATIDFinalError(error_messages['country_mismatch'])
|
||||||
|
|
||||||
if not re.match(vat_moss.id.ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], number):
|
if not re.match(VAT_ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], number):
|
||||||
raise VATIDFinalError(error_messages['invalid'])
|
raise VATIDFinalError(error_messages['invalid'])
|
||||||
|
|
||||||
# We are relying on the country code of the normalized VAT-ID and not the user/InvoiceAddress-provided
|
# We are relying on the country code of the normalized VAT-ID and not the user/InvoiceAddress-provided
|
||||||
@@ -175,9 +345,12 @@ def _validate_vat_id_EU(vat_id, country_code):
|
|||||||
|
|
||||||
def _validate_vat_id_CH(vat_id, country_code):
|
def _validate_vat_id_CH(vat_id, country_code):
|
||||||
if vat_id[:3] != 'CHE':
|
if vat_id[:3] != 'CHE':
|
||||||
raise VATIDFinalError(_('Your VAT ID does not match the selected country.'))
|
raise VATIDFinalError(error_messages['country_mismatch'])
|
||||||
|
|
||||||
vat_id = re.sub('[^A-Z0-9]', '', vat_id.replace('HR', '').replace('MWST', ''))
|
try:
|
||||||
|
vat_id = normalize_vat_id(vat_id, country_code)
|
||||||
|
except ValueError:
|
||||||
|
raise VATIDFinalError(error_messages['invalid'])
|
||||||
try:
|
try:
|
||||||
transport = Transport(
|
transport = Transport(
|
||||||
cache=SqliteCache(os.path.join(settings.CACHE_DIR, "validate_vat_id_ch_zeep_cache.db")),
|
cache=SqliteCache(os.path.join(settings.CACHE_DIR, "validate_vat_id_ch_zeep_cache.db")),
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None)
|
|||||||
|
|
||||||
lock_objects(quotas, shared_lock_objects=[event])
|
lock_objects(quotas, shared_lock_objects=[event])
|
||||||
for wle in qs:
|
for wle in qs:
|
||||||
|
# add this event to wle.item as it is not yet cached and is needed in check_quotas
|
||||||
|
wle.item.event = event
|
||||||
|
if wle.variation:
|
||||||
|
wle.variation.item = wle.item
|
||||||
|
|
||||||
if (wle.item_id, wle.variation_id, wle.subevent_id) in gone:
|
if (wle.item_id, wle.variation_id, wle.subevent_id) in gone:
|
||||||
continue
|
continue
|
||||||
ev = (wle.subevent or event)
|
ev = (wle.subevent or event)
|
||||||
|
|||||||
+151
-9
@@ -40,6 +40,7 @@ from datetime import datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import pycountry
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@@ -75,7 +76,15 @@ from pretix.base.validators import multimail_validate
|
|||||||
from pretix.control.forms import (
|
from pretix.control.forms import (
|
||||||
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
|
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
|
||||||
)
|
)
|
||||||
from pretix.helpers.countries import CachedCountries
|
from pretix.helpers.countries import CachedCountries, pycountry_add
|
||||||
|
|
||||||
|
ROUNDING_MODES = (
|
||||||
|
('line', _('Compute taxes for every line individually')),
|
||||||
|
('sum_by_net', _('Compute taxes based on net total')),
|
||||||
|
('sum_by_net_only_business', _('For business customers, compute taxes based on net total. For individuals, use line-based rounding')),
|
||||||
|
('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')),
|
||||||
|
# We could also have sum_by_gross, but we're not aware of any use-cases for it
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def country_choice_kwargs():
|
def country_choice_kwargs():
|
||||||
@@ -172,6 +181,19 @@ DEFAULTS = {
|
|||||||
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
|
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'customer_accounts_require_login_for_order_access': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Require login to access order confirmation pages"),
|
||||||
|
help_text=_("If enabled, users who were logged in at the time of purchase must also log in to access their order information. "
|
||||||
|
"If a customer account is created while placing an order, the restriction only becomes active after the customer "
|
||||||
|
"account is activated."),
|
||||||
|
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
|
||||||
|
)
|
||||||
|
},
|
||||||
'customer_accounts_link_by_email': {
|
'customer_accounts_link_by_email': {
|
||||||
'default': 'False',
|
'default': 'False',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
@@ -324,7 +346,7 @@ DEFAULTS = {
|
|||||||
'form_class': forms.BooleanField,
|
'form_class': forms.BooleanField,
|
||||||
'serializer_class': serializers.BooleanField,
|
'serializer_class': serializers.BooleanField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Show net prices instead of gross prices in the product list (not recommended!)"),
|
label=_("Show net prices instead of gross prices in the product list"),
|
||||||
help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be "
|
help_text=_("Independent of your choice, the cart will show gross prices as this is the price that needs to be "
|
||||||
"paid."),
|
"paid."),
|
||||||
|
|
||||||
@@ -465,6 +487,25 @@ DEFAULTS = {
|
|||||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}),
|
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'tax_rounding': {
|
||||||
|
'default': 'line',
|
||||||
|
'type': str,
|
||||||
|
'form_class': forms.ChoiceField,
|
||||||
|
'serializer_class': serializers.ChoiceField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Rounding of taxes"),
|
||||||
|
widget=forms.RadioSelect,
|
||||||
|
choices=ROUNDING_MODES,
|
||||||
|
help_text=_(
|
||||||
|
"Note that if you transfer your sales data from pretix to an external system for tax reporting, you "
|
||||||
|
"need to make sure to account for possible rounding differences if your external system rounds "
|
||||||
|
"differently than pretix."
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
choices=ROUNDING_MODES,
|
||||||
|
),
|
||||||
|
},
|
||||||
'invoice_address_asked': {
|
'invoice_address_asked': {
|
||||||
'default': 'True',
|
'default': 'True',
|
||||||
'type': bool,
|
'type': bool,
|
||||||
@@ -602,13 +643,40 @@ DEFAULTS = {
|
|||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Ask for VAT ID"),
|
label=_("Ask for VAT ID"),
|
||||||
help_text=format_lazy(
|
help_text=format_lazy(
|
||||||
_("Only works if an invoice address is asked for. VAT ID is never required and only requested from "
|
_("Only works if an invoice address is asked for. VAT ID is only requested from business customers "
|
||||||
"business customers in the following countries: {countries}"),
|
"in the following countries: {countries}."),
|
||||||
countries=lazy(lambda *args: ', '.join(sorted(gettext(Country(cc).name) for cc in VAT_ID_COUNTRIES)), str)()
|
countries=lazy(lambda *args: ', '.join(sorted(gettext(Country(cc).name) for cc in VAT_ID_COUNTRIES)), str)()
|
||||||
),
|
),
|
||||||
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'invoice_address_vatid_required_countries': {
|
||||||
|
'default': ['IT', 'GR'],
|
||||||
|
'type': list,
|
||||||
|
'form_class': forms.MultipleChoiceField,
|
||||||
|
'serializer_class': serializers.MultipleChoiceField,
|
||||||
|
'serializer_kwargs': dict(
|
||||||
|
choices=lazy(
|
||||||
|
lambda *args: sorted([(cc, gettext(Country(cc).name)) for cc in VAT_ID_COUNTRIES], key=lambda c: c[1]),
|
||||||
|
list
|
||||||
|
)(),
|
||||||
|
),
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Require VAT ID in"),
|
||||||
|
choices=lazy(
|
||||||
|
lambda *args: sorted([(cc, gettext(Country(cc).name)) for cc in VAT_ID_COUNTRIES], key=lambda c: c[1]),
|
||||||
|
list
|
||||||
|
)(),
|
||||||
|
help_text=format_lazy(
|
||||||
|
_("VAT ID is optional by default, because not all businesses are assigned a VAT ID in all countries. "
|
||||||
|
"VAT ID will be required for all business addresses in the selected countries."),
|
||||||
|
),
|
||||||
|
widget=forms.CheckboxSelectMultiple(attrs={
|
||||||
|
"class": "scrolling-multiple-choice",
|
||||||
|
'data-display-dependency': '#id_invoice_address_vatid'
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
'invoice_address_explanation_text': {
|
'invoice_address_explanation_text': {
|
||||||
'default': '',
|
'default': '',
|
||||||
'type': LazyI18nString,
|
'type': LazyI18nString,
|
||||||
@@ -663,6 +731,7 @@ DEFAULTS = {
|
|||||||
label=_("Minimum length of invoice number after prefix"),
|
label=_("Minimum length of invoice number after prefix"),
|
||||||
help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."),
|
help_text=_("The part of your invoice number after your prefix will be filled up with leading zeros up to this length, e.g. INV-001 or INV-00001."),
|
||||||
max_value=12,
|
max_value=12,
|
||||||
|
min_value=1,
|
||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -698,8 +767,9 @@ DEFAULTS = {
|
|||||||
message=lazy(lambda *args: _('Please only use the characters {allowed} in this field.').format(
|
message=lazy(lambda *args: _('Please only use the characters {allowed} in this field.').format(
|
||||||
allowed='A-Z, a-z, 0-9, -./:#'
|
allowed='A-Z, a-z, 0-9, -./:#'
|
||||||
), str)()
|
), str)()
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
|
max_length=155,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'invoice_numbers_prefix_cancellations': {
|
'invoice_numbers_prefix_cancellations': {
|
||||||
@@ -720,8 +790,9 @@ DEFAULTS = {
|
|||||||
message=lazy(lambda *args: _('Please only use the characters {allowed} in this field.').format(
|
message=lazy(lambda *args: _('Please only use the characters {allowed} in this field.').format(
|
||||||
allowed='A-Z, a-z, 0-9, -./:#'
|
allowed='A-Z, a-z, 0-9, -./:#'
|
||||||
), str)()
|
), str)()
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
|
max_length=155,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'invoice_renderer_highlight_order_code': {
|
'invoice_renderer_highlight_order_code': {
|
||||||
@@ -1155,6 +1226,15 @@ DEFAULTS = {
|
|||||||
'default': json.dumps(['web']),
|
'default': json.dumps(['web']),
|
||||||
'type': list
|
'type': list
|
||||||
},
|
},
|
||||||
|
'invoice_generate_only_business': {
|
||||||
|
'default': 'False',
|
||||||
|
'type': bool,
|
||||||
|
'form_class': forms.BooleanField,
|
||||||
|
'serializer_class': serializers.BooleanField,
|
||||||
|
'form_kwargs': dict(
|
||||||
|
label=_("Only issue invoices to business customers"),
|
||||||
|
)
|
||||||
|
},
|
||||||
'invoice_address_from': {
|
'invoice_address_from': {
|
||||||
'default': '',
|
'default': '',
|
||||||
'type': str,
|
'type': str,
|
||||||
@@ -1176,6 +1256,7 @@ DEFAULTS = {
|
|||||||
'form_class': forms.CharField,
|
'form_class': forms.CharField,
|
||||||
'serializer_class': serializers.CharField,
|
'serializer_class': serializers.CharField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
|
max_length=190,
|
||||||
label=_("Company name"),
|
label=_("Company name"),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1189,6 +1270,7 @@ DEFAULTS = {
|
|||||||
'placeholder': '12345'
|
'placeholder': '12345'
|
||||||
}),
|
}),
|
||||||
label=_("ZIP code"),
|
label=_("ZIP code"),
|
||||||
|
max_length=190,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'invoice_address_from_city': {
|
'invoice_address_from_city': {
|
||||||
@@ -1201,15 +1283,35 @@ DEFAULTS = {
|
|||||||
'placeholder': _('Random City')
|
'placeholder': _('Random City')
|
||||||
}),
|
}),
|
||||||
label=_("City"),
|
label=_("City"),
|
||||||
|
max_length=190,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
'invoice_address_from_state': {
|
||||||
|
'default': '',
|
||||||
|
'type': str,
|
||||||
|
'form_class': forms.ChoiceField,
|
||||||
|
'serializer_class': serializers.ChoiceField,
|
||||||
|
'serializer_kwargs': {
|
||||||
|
'choices': [('', '')],
|
||||||
|
},
|
||||||
|
'form_kwargs': {
|
||||||
|
"label": pgettext_lazy('address', 'State'),
|
||||||
|
'choices': [('', '')],
|
||||||
|
},
|
||||||
|
},
|
||||||
'invoice_address_from_country': {
|
'invoice_address_from_country': {
|
||||||
'default': '',
|
'default': '',
|
||||||
'type': str,
|
'type': str,
|
||||||
'form_class': forms.ChoiceField,
|
'form_class': forms.ChoiceField,
|
||||||
'serializer_class': serializers.ChoiceField,
|
'serializer_class': serializers.ChoiceField,
|
||||||
'serializer_kwargs': lambda: dict(**country_choice_kwargs()),
|
'serializer_kwargs': lambda: dict(**country_choice_kwargs()),
|
||||||
'form_kwargs': lambda: dict(label=_('Country'), **country_choice_kwargs()),
|
'form_kwargs': lambda: dict(
|
||||||
|
label=_('Country'),
|
||||||
|
widget=forms.Select(attrs={
|
||||||
|
'data-trigger-address-info': 'on',
|
||||||
|
}),
|
||||||
|
**country_choice_kwargs()
|
||||||
|
),
|
||||||
},
|
},
|
||||||
'invoice_address_from_tax_id': {
|
'invoice_address_from_tax_id': {
|
||||||
'default': '',
|
'default': '',
|
||||||
@@ -1218,7 +1320,8 @@ DEFAULTS = {
|
|||||||
'serializer_class': serializers.CharField,
|
'serializer_class': serializers.CharField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("Domestic tax ID"),
|
label=_("Domestic tax ID"),
|
||||||
help_text=_("e.g. tax number in Germany, ABN in Australia, …")
|
help_text=_("e.g. tax number in Germany, ABN in Australia, …"),
|
||||||
|
max_length=190,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'invoice_address_from_vat_id': {
|
'invoice_address_from_vat_id': {
|
||||||
@@ -1228,6 +1331,7 @@ DEFAULTS = {
|
|||||||
'serializer_class': serializers.CharField,
|
'serializer_class': serializers.CharField,
|
||||||
'form_kwargs': dict(
|
'form_kwargs': dict(
|
||||||
label=_("EU VAT ID"),
|
label=_("EU VAT ID"),
|
||||||
|
max_length=190,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
'invoice_introductory_text': {
|
'invoice_introductory_text': {
|
||||||
@@ -2844,6 +2948,28 @@ If you did not request a new password, please ignore this email.
|
|||||||
|
|
||||||
Best regards,
|
Best regards,
|
||||||
|
|
||||||
|
Your {organizer} team""")) # noqa: W291
|
||||||
|
},
|
||||||
|
'mail_subject_customer_security_notice': {
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'default': LazyI18nString.from_gettext(gettext_noop("Changes to your account at {organizer}")),
|
||||||
|
},
|
||||||
|
'mail_text_customer_security_notice': {
|
||||||
|
'type': LazyI18nString,
|
||||||
|
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
|
||||||
|
|
||||||
|
the following change has been made to your account at {organizer}:
|
||||||
|
|
||||||
|
{message}
|
||||||
|
|
||||||
|
You can review and change your account settings here:
|
||||||
|
|
||||||
|
{url}
|
||||||
|
|
||||||
|
If this change was not performed by you, please contact us immediately.
|
||||||
|
|
||||||
|
Best regards,
|
||||||
|
|
||||||
Your {organizer} team""")) # noqa: W291
|
Your {organizer} team""")) # noqa: W291
|
||||||
},
|
},
|
||||||
'smtp_use_custom': {
|
'smtp_use_custom': {
|
||||||
@@ -3833,7 +3959,7 @@ COUNTRIES_WITH_STATE_IN_ADDRESS = {
|
|||||||
'MX': (['State', 'Federal district', 'Federal entity'], 'short'),
|
'MX': (['State', 'Federal district', 'Federal entity'], 'short'),
|
||||||
'US': (['State', 'Outlying area', 'District'], 'short'),
|
'US': (['State', 'Outlying area', 'District'], 'short'),
|
||||||
'IT': (['Province', 'Free municipal consortium', 'Metropolitan city', 'Autonomous province',
|
'IT': (['Province', 'Free municipal consortium', 'Metropolitan city', 'Autonomous province',
|
||||||
'Free municipal consortium', 'Decentralized regional entity'], 'short'),
|
'Decentralized regional entity'], 'short'),
|
||||||
}
|
}
|
||||||
COUNTRY_STATE_LABEL = {
|
COUNTRY_STATE_LABEL = {
|
||||||
# Countries in which the "State" field should not be called "State"
|
# Countries in which the "State" field should not be called "State"
|
||||||
@@ -3841,6 +3967,8 @@ COUNTRY_STATE_LABEL = {
|
|||||||
'JP': pgettext_lazy('address', 'Prefecture'),
|
'JP': pgettext_lazy('address', 'Prefecture'),
|
||||||
'IT': pgettext_lazy('address', 'Province'),
|
'IT': pgettext_lazy('address', 'Province'),
|
||||||
}
|
}
|
||||||
|
# Workaround for https://github.com/pretix/pretix/issues/5796
|
||||||
|
pycountry_add(pycountry.subdivisions, code="IT-AO", country_code="IT", name="Valle d'Aosta", parent="23", parent_code="IT-23", type="Province")
|
||||||
|
|
||||||
settings_hierarkey = Hierarkey(attribute_name='settings')
|
settings_hierarkey = Hierarkey(attribute_name='settings')
|
||||||
|
|
||||||
@@ -3945,6 +4073,20 @@ def validate_event_settings(event, settings_dict):
|
|||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'invoice_address_company_required': _('You have to require invoice addresses to require for company names.')
|
'invoice_address_company_required': _('You have to require invoice addresses to require for company names.')
|
||||||
})
|
})
|
||||||
|
if settings_dict.get('invoice_address_from_state') and settings_dict.get('invoice_address_from_country'):
|
||||||
|
cc = str(settings_dict.get('invoice_address_from_country'))
|
||||||
|
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
|
||||||
|
raise ValidationError(
|
||||||
|
{'invoice_address_from_state': ['States are not supported in country "{}".'.format(cc)]}
|
||||||
|
)
|
||||||
|
if not pycountry.subdivisions.get(code=cc + '-' + settings_dict.get('invoice_address_from_state')):
|
||||||
|
raise ValidationError(
|
||||||
|
{'invoice_address_from_state': [
|
||||||
|
'"{}" is not a known subdivision of the country "{}".'.format(
|
||||||
|
settings_dict.get('invoice_address_from_state'), cc
|
||||||
|
)
|
||||||
|
]}
|
||||||
|
)
|
||||||
|
|
||||||
payment_term_last = settings_dict.get('payment_term_last')
|
payment_term_last = settings_dict.get('payment_term_last')
|
||||||
if payment_term_last and event.presale_end:
|
if payment_term_last and event.presale_end:
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ from pretix.api.serializers.waitinglist import WaitingListSerializer
|
|||||||
from pretix.base.i18n import LazyLocaleException
|
from pretix.base.i18n import LazyLocaleException
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
|
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
|
||||||
OrderPosition, OrderRefund, QuestionAnswer,
|
OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
|
||||||
)
|
)
|
||||||
from pretix.base.services.invoices import invoice_pdf_task
|
from pretix.base.services.invoices import invoice_pdf_task
|
||||||
from pretix.base.signals import register_data_shredders
|
from pretix.base.signals import register_data_shredders
|
||||||
@@ -329,6 +329,10 @@ class EmailAddressShredder(BaseDataShredder):
|
|||||||
sleep_time=2,
|
sleep_time=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
slow_delete(
|
||||||
|
OutgoingMail.objects.filter(event=self.event)
|
||||||
|
)
|
||||||
|
|
||||||
for o in _progress_helper(qs_orders, progress_callback, qs_op_cnt, total):
|
for o in _progress_helper(qs_orders, progress_callback, qs_op_cnt, total):
|
||||||
changed = bool(o.email) or bool(o.customer)
|
changed = bool(o.email) or bool(o.customer)
|
||||||
o.email = None
|
o.email = None
|
||||||
|
|||||||
@@ -944,32 +944,40 @@ As with all event-plugin signals, the ``sender`` keyword argument will contain t
|
|||||||
|
|
||||||
email_filter = EventPluginSignal()
|
email_filter = EventPluginSignal()
|
||||||
"""
|
"""
|
||||||
Arguments: ``message``, ``order``, ``user``
|
Arguments: ``message``, ``order``, ``user``, ``outgoing_mail``
|
||||||
|
|
||||||
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
||||||
return a (possibly modified) copy of the message object passed to you.
|
return a (possibly modified) copy of the message object passed to you.
|
||||||
|
|
||||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||||
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
||||||
|
The ``outgoing_mail`` argument will contain the ``OutgoingMail`` model instance. Note that the ``message`` object
|
||||||
|
might have newer information if a previous plugin already modified the email.
|
||||||
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
||||||
it will be ``None``.
|
it will be ``None``.
|
||||||
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
|
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
|
||||||
well, otherwise it will be ``None``.
|
well, otherwise it will be ``None``.
|
||||||
|
|
||||||
|
You can raise ``WithholdMailException`` to prevent the email from being sent, e.g. when implementing rate limiting.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
global_email_filter = GlobalSignal()
|
global_email_filter = GlobalSignal()
|
||||||
"""
|
"""
|
||||||
Arguments: ``message``, ``order``, ``user``, ``customer``, ``organizer``
|
Arguments: ``message``, ``order``, ``user``, ``customer``, ``organizer``, ``outgoing_mail``
|
||||||
|
|
||||||
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
|
||||||
return a (possibly modified) copy of the message object passed to you.
|
return a (possibly modified) copy of the message object passed to you.
|
||||||
|
|
||||||
This signal is called on all events and even if there is no known event. ``sender`` is an event or None.
|
This signal is called on all events and even if there is no known event. ``sender`` is an event or None.
|
||||||
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
|
||||||
|
The ``outgoing_mail`` argument will contain the ``OutgoingMail`` model instance. Note that the ``message`` object
|
||||||
|
might have newer information if a previous plugin already modified the email.
|
||||||
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
|
||||||
it will be ``None``.
|
it will be ``None``.
|
||||||
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
|
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
|
||||||
well, otherwise it will be ``None``.
|
well, otherwise it will be ``None``.
|
||||||
|
|
||||||
|
You can raise ``WithholdMailException`` to prevent the email from being sent, e.g. when implementing rate limiting.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,6 @@
|
|||||||
<h1>{% trans "Not found" %}</h1>
|
<h1>{% trans "Not found" %}</h1>
|
||||||
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
|
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
|
||||||
<p>{{ exception }}</p>
|
<p>{{ exception }}</p>
|
||||||
<p class="links">
|
|
||||||
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
|
|
||||||
</p>
|
|
||||||
{% if request.user.is_staff and not staff_session %}
|
{% if request.user.is_staff and not staff_session %}
|
||||||
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
|
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#
|
||||||
|
# This file is part of pretix (Community Edition).
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||||
|
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||||
|
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||||
|
#
|
||||||
|
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||||
|
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||||
|
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||||
|
# this file, see <https://pretix.eu/about/en/license>.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||||
|
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||||
|
# details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||||
|
# <https://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from django.utils.timezone import get_current_timezone
|
||||||
|
|
||||||
|
from pretix.base.i18n import LazyExpiresDate
|
||||||
|
from pretix.helpers.templatetags.date_fast import date_fast
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def html_time(value: datetime, dt_format: str = "SHORT_DATE_FORMAT", **kwargs):
|
||||||
|
"""
|
||||||
|
Building a <time datetime='{html-datetime}'>{human-readable datetime}</time> html string,
|
||||||
|
where the html-datetime as well as the human-readable datetime can be set
|
||||||
|
to a value from django's FORMAT_SETTINGS or "format_expires".
|
||||||
|
|
||||||
|
If attr_fmt isn’t provided, it will be set to isoformat.
|
||||||
|
|
||||||
|
Usage example:
|
||||||
|
{% html_time event_start "SHORT_DATETIME_FORMAT" %}
|
||||||
|
or
|
||||||
|
{% html_time event_start "TIME_FORMAT" attr_fmt="H:i" %}
|
||||||
|
"""
|
||||||
|
if value in (None, ''):
|
||||||
|
return ''
|
||||||
|
value = value.astimezone(get_current_timezone())
|
||||||
|
attr_fmt = kwargs["attr_fmt"] if kwargs else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not attr_fmt:
|
||||||
|
date_html = value.isoformat()
|
||||||
|
else:
|
||||||
|
date_html = date_fast(value, attr_fmt)
|
||||||
|
|
||||||
|
if dt_format == "format_expires":
|
||||||
|
date_human = LazyExpiresDate(value)
|
||||||
|
else:
|
||||||
|
date_human = date_fast(value, dt_format)
|
||||||
|
return format_html("<time datetime='{}'>{}</time>", date_html, date_human)
|
||||||
|
except AttributeError:
|
||||||
|
return ''
|
||||||
@@ -26,7 +26,8 @@ from babel.numbers import format_currency
|
|||||||
from django import template
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.template.defaultfilters import floatformat
|
from django.template.defaultfilters import floatformat
|
||||||
from django.utils import translation
|
|
||||||
|
from pretix.base.i18n import get_babel_locale
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
@@ -59,13 +60,10 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
|
|||||||
if hide_currency:
|
if hide_currency:
|
||||||
return floatformat(value, f"{places}g")
|
return floatformat(value, f"{places}g")
|
||||||
|
|
||||||
locale_parts = translation.get_language().split("-", 1)
|
try:
|
||||||
locale = locale_parts[0]
|
locale = Locale(get_babel_locale())
|
||||||
if len(locale_parts) > 1 and len(locale_parts[1]) == 2:
|
except UnknownLocaleError:
|
||||||
try:
|
locale = "en"
|
||||||
locale = Locale(locale_parts[0], locale_parts[1].upper())
|
|
||||||
except UnknownLocaleError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return format_currency(value, arg, locale=locale)
|
return format_currency(value, arg, locale=locale)
|
||||||
|
|||||||
@@ -32,18 +32,20 @@
|
|||||||
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations under the License.
|
# License for the specific language governing permissions and limitations under the License.
|
||||||
|
|
||||||
|
import html
|
||||||
import re
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
import bleach
|
import bleach
|
||||||
import markdown
|
import markdown
|
||||||
from bleach import DEFAULT_CALLBACKS
|
from bleach import DEFAULT_CALLBACKS, html5lib_shim
|
||||||
from bleach.linkifier import build_email_re, build_url_re
|
from bleach.linkifier import build_email_re
|
||||||
from django import template
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.functional import SimpleLazyObject
|
from django.utils.functional import SimpleLazyObject
|
||||||
|
from django.utils.html import escape
|
||||||
from django.utils.http import url_has_allowed_host_and_scheme
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from markdown import Extension
|
from markdown import Extension
|
||||||
@@ -52,6 +54,8 @@ from markdown.postprocessors import Postprocessor
|
|||||||
from markdown.treeprocessors import UnescapeTreeprocessor
|
from markdown.treeprocessors import UnescapeTreeprocessor
|
||||||
from tlds import tld_set
|
from tlds import tld_set
|
||||||
|
|
||||||
|
from pretix.helpers.format import SafeFormatter, format_map
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
@@ -121,6 +125,23 @@ ALLOWED_ATTRIBUTES = {
|
|||||||
|
|
||||||
ALLOWED_PROTOCOLS = {'http', 'https', 'mailto', 'tel'}
|
ALLOWED_PROTOCOLS = {'http', 'https', 'mailto', 'tel'}
|
||||||
|
|
||||||
|
|
||||||
|
def build_url_re(tlds=tld_set, protocols=html5lib_shim.allowed_protocols):
|
||||||
|
# Differs from bleach regex by allowing { and } in URL to allow placeholders in URL parameters
|
||||||
|
return re.compile(
|
||||||
|
r"""\(* # Match any opening parentheses.
|
||||||
|
\b(?<![@.])(?:(?:{0}):/{{0,3}}(?:(?:\w+:)?\w+@)?)? # http://
|
||||||
|
([\w-]+\.)+(?:{1})(?:\:[0-9]+)?(?!\.\w)\b # xx.yy.tld(:##)?
|
||||||
|
(?:[/?][^\s\|\\\^`<>"]*)?
|
||||||
|
# /path/zz (excluding "unsafe" chars from RFC 3986,
|
||||||
|
# except for # and ~, which happen in practice)
|
||||||
|
""".format(
|
||||||
|
"|".join(sorted(protocols)), "|".join(sorted(tlds))
|
||||||
|
),
|
||||||
|
re.IGNORECASE | re.VERBOSE | re.UNICODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
URL_RE = SimpleLazyObject(lambda: build_url_re(tlds=sorted(tld_set, key=len, reverse=True)))
|
URL_RE = SimpleLazyObject(lambda: build_url_re(tlds=sorted(tld_set, key=len, reverse=True)))
|
||||||
|
|
||||||
EMAIL_RE = SimpleLazyObject(lambda: build_email_re(tlds=sorted(tld_set, key=len, reverse=True)))
|
EMAIL_RE = SimpleLazyObject(lambda: build_email_re(tlds=sorted(tld_set, key=len, reverse=True)))
|
||||||
@@ -135,7 +156,7 @@ def safelink_callback(attrs, new=False):
|
|||||||
Makes sure that all links to a different domain are passed through a redirection handler
|
Makes sure that all links to a different domain are passed through a redirection handler
|
||||||
to ensure there's no passing of referers with secrets inside them.
|
to ensure there's no passing of referers with secrets inside them.
|
||||||
"""
|
"""
|
||||||
url = attrs.get((None, 'href'), '/')
|
url = html.unescape(attrs.get((None, 'href'), '/'))
|
||||||
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
|
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
|
||||||
signer = signing.Signer(salt='safe-redirect')
|
signer = signing.Signer(salt='safe-redirect')
|
||||||
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
|
||||||
@@ -321,27 +342,50 @@ class LinkifyAndCleanExtension(Extension):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes=ALLOWED_ATTRIBUTES):
|
def markdown_compile_email(source, allowed_tags=None, allowed_attributes=ALLOWED_ATTRIBUTES, snippet=False, context=None):
|
||||||
|
if allowed_tags is None:
|
||||||
|
allowed_tags = ALLOWED_TAGS_SNIPPET if snippet else ALLOWED_TAGS
|
||||||
|
|
||||||
|
context_callbacks = []
|
||||||
|
if context:
|
||||||
|
# This is a workaround to fix placeholders in URL targets
|
||||||
|
def context_callback(attrs, new=False):
|
||||||
|
if (None, "href") in attrs and "{" in attrs[None, "href"]:
|
||||||
|
# Do not use MODE_RICH_TO_HTML to avoid recursive linkification.
|
||||||
|
# We want to esacpe the end result, however, we need to unescape the input to prevent & being turned
|
||||||
|
# to &amp; because the input is already escaped by the markdown parser.
|
||||||
|
attrs[None, "href"] = escape(format_map(
|
||||||
|
html.unescape(attrs[None, "href"]),
|
||||||
|
context=context,
|
||||||
|
mode=SafeFormatter.MODE_RICH_TO_PLAIN
|
||||||
|
))
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
context_callbacks.append(context_callback)
|
||||||
|
|
||||||
linker = bleach.Linker(
|
linker = bleach.Linker(
|
||||||
url_re=URL_RE,
|
url_re=URL_RE,
|
||||||
email_re=EMAIL_RE,
|
email_re=EMAIL_RE,
|
||||||
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
callbacks=context_callbacks + DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||||
parse_email=True
|
parse_email=True
|
||||||
)
|
)
|
||||||
|
exts = [
|
||||||
|
'markdown.extensions.sane_lists',
|
||||||
|
'markdown.extensions.tables',
|
||||||
|
EmailNl2BrExtension(),
|
||||||
|
LinkifyAndCleanExtension(
|
||||||
|
linker,
|
||||||
|
tags=set(allowed_tags),
|
||||||
|
attributes=allowed_attributes,
|
||||||
|
protocols=ALLOWED_PROTOCOLS,
|
||||||
|
strip=snippet,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if snippet:
|
||||||
|
exts.append(SnippetExtension())
|
||||||
return markdown.markdown(
|
return markdown.markdown(
|
||||||
source,
|
source,
|
||||||
extensions=[
|
extensions=exts
|
||||||
'markdown.extensions.sane_lists',
|
|
||||||
'markdown.extensions.tables',
|
|
||||||
EmailNl2BrExtension(),
|
|
||||||
LinkifyAndCleanExtension(
|
|
||||||
linker,
|
|
||||||
tags=set(allowed_tags),
|
|
||||||
attributes=allowed_attributes,
|
|
||||||
protocols=ALLOWED_PROTOCOLS,
|
|
||||||
strip=False,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,9 @@ def timeline_for_event(event, subevent=None):
|
|||||||
description=format_lazy(
|
description=format_lazy(
|
||||||
'{} ({})',
|
'{} ({})',
|
||||||
pgettext_lazy('timeline', 'End of ticket sales'),
|
pgettext_lazy('timeline', 'End of ticket sales'),
|
||||||
pgettext_lazy('timeline', 'automatically because the event is over and no end of presale has been configured') if not ev.presale_end else ""
|
pgettext_lazy('timeline', 'automatically because the event is over and no end of presale has been configured')
|
||||||
|
) if not ev.presale_end else (
|
||||||
|
pgettext_lazy('timeline', 'End of ticket sales')
|
||||||
),
|
),
|
||||||
edit_url=ev_edit_url + '#id_presale_end_0'
|
edit_url=ev_edit_url + '#id_presale_end_0'
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ class OrganizerSlugBanlistValidator(BanlistValidator):
|
|||||||
'csp_report',
|
'csp_report',
|
||||||
'widget',
|
'widget',
|
||||||
'lead',
|
'lead',
|
||||||
|
'scheduling',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user