From bffdc5e62df398afae9ea33d9abc5679229d48f5 Mon Sep 17 00:00:00 2001 From: carla Date: Mon, 27 Apr 2026 09:25:29 +0200 Subject: [PATCH] chore: updates plugins o v2 --- README.md | 26 +- plugins/attendance_confirm_plugin/MANIFEST.in | 6 +- plugins/attendance_confirm_plugin/README.md | 19 ++ .../PKG-INFO | 28 -- .../SOURCES.txt | 15 - .../dependency_links.txt | 1 - .../entry_points.txt | 2 - .../requires.txt | 1 - .../top_level.txt | 1 - .../__pycache__/__init__.cpython-313.pyc | Bin 266 -> 0 bytes .../__pycache__/apps.cpython-313.pyc | Bin 1453 -> 0 bytes .../__pycache__/forms.cpython-313.pyc | Bin 1902 -> 0 bytes .../__pycache__/signals.cpython-313.pyc | Bin 2281 -> 0 bytes .../__pycache__/urls.cpython-313.pyc | Bin 543 -> 0 bytes .../__pycache__/views.cpython-313.pyc | Bin 12255 -> 0 bytes .../pretix_attendance_confirm/views.py | 127 +++++++-- plugins/attendance_confirm_plugin/setup.cfg | 10 +- .../tests/test_recipient_grouping.py | 123 ++++++++ plugins/selective_export_plugin/MANIFEST.in | 6 +- plugins/selective_export_plugin/README.md | 21 +- .../pretix_selective_export.egg-info/PKG-INFO | 63 ----- .../SOURCES.txt | 13 - .../dependency_links.txt | 1 - .../entry_points.txt | 2 - .../requires.txt | 1 - .../top_level.txt | 1 - .../__pycache__/__init__.cpython-313.pyc | Bin 234 -> 0 bytes .../__pycache__/apps.cpython-313.pyc | Bin 1379 -> 0 bytes .../__pycache__/exporter.cpython-313.pyc | Bin 17966 -> 0 bytes .../__pycache__/signals.cpython-313.pyc | Bin 4018 -> 0 bytes .../pretix_selective_export/exporter.py | 253 +++++++++++++---- .../pretix_selective_export/presets.py | 78 ++++++ .../pretix_selective_export/signals.py | 47 +++- .../pretixplugins/selective_export/presets.js | 226 +++++++++++++-- .../selective_export/control_preset.html | 14 +- .../pretix_selective_export/urls.py | 18 ++ .../pretix_selective_export/views.py | 50 ++++ plugins/selective_export_plugin/setup.cfg | 10 +- .../tests/test_exporter_grouping.py | 264 ++++++++++++++++++ .../tests/test_presets.py | 116 ++++++++ 40 files changed, 1269 insertions(+), 274 deletions(-) delete mode 100644 plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/PKG-INFO delete mode 100644 plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/SOURCES.txt delete mode 100644 plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/dependency_links.txt delete mode 100644 plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/entry_points.txt delete mode 100644 plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/requires.txt delete mode 100644 plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/top_level.txt delete mode 100644 plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/__init__.cpython-313.pyc delete mode 100644 plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/apps.cpython-313.pyc delete mode 100644 plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/forms.cpython-313.pyc delete mode 100644 plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/signals.cpython-313.pyc delete mode 100644 plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/urls.cpython-313.pyc delete mode 100644 plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/views.cpython-313.pyc create mode 100644 plugins/attendance_confirm_plugin/tests/test_recipient_grouping.py delete mode 100644 plugins/selective_export_plugin/pretix_selective_export.egg-info/PKG-INFO delete mode 100644 plugins/selective_export_plugin/pretix_selective_export.egg-info/SOURCES.txt delete mode 100644 plugins/selective_export_plugin/pretix_selective_export.egg-info/dependency_links.txt delete mode 100644 plugins/selective_export_plugin/pretix_selective_export.egg-info/entry_points.txt delete mode 100644 plugins/selective_export_plugin/pretix_selective_export.egg-info/requires.txt delete mode 100644 plugins/selective_export_plugin/pretix_selective_export.egg-info/top_level.txt delete mode 100644 plugins/selective_export_plugin/pretix_selective_export/__pycache__/__init__.cpython-313.pyc delete mode 100644 plugins/selective_export_plugin/pretix_selective_export/__pycache__/apps.cpython-313.pyc delete mode 100644 plugins/selective_export_plugin/pretix_selective_export/__pycache__/exporter.cpython-313.pyc delete mode 100644 plugins/selective_export_plugin/pretix_selective_export/__pycache__/signals.cpython-313.pyc create mode 100644 plugins/selective_export_plugin/pretix_selective_export/presets.py create mode 100644 plugins/selective_export_plugin/pretix_selective_export/urls.py create mode 100644 plugins/selective_export_plugin/pretix_selective_export/views.py create mode 100644 plugins/selective_export_plugin/tests/test_exporter_grouping.py create mode 100644 plugins/selective_export_plugin/tests/test_presets.py diff --git a/README.md b/README.md index e5228a3..728e103 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ In your app `.env` file (managed by abra): ```bash COMPOSE_FILE=compose.yml:compose.plugin.yml -PRETIX_PLUGIN_IMAGE=git.coopcloud.tech//pretix-plugins:1.0.0 +PRETIX_PLUGIN_IMAGE=git.coopcloud.tech/coop-cloud/pretix-plugins:1.0.0 ``` If `PRETIX_PLUGIN_IMAGE` is not set, the standard `pretix/standalone` image @@ -64,24 +64,12 @@ Comment out or remove a line to exclude a plugin from the image. ## Building and publishing the image -### Automatic (recommended) - -Push a Git tag to trigger the Gitea Actions workflow: - -```bash -git tag 1.0.0 -git push origin 1.0.0 -``` - -The CI pipeline will build and push: -- `git.coopcloud.tech//pretix-plugins:1.0.0` -- `git.coopcloud.tech//pretix-plugins:latest` ### Manual ```bash -docker build -t git.coopcloud.tech//pretix-plugins:1.0.0 . -docker push git.coopcloud.tech//pretix-plugins:1.0.0 +docker build -t git.coopcloud.tech/coop-cloud/pretix-plugins:1.0.0 . +docker push git.coopcloud.tech/coop-cloud/pretix-plugins:1.0.0 ``` --- @@ -111,14 +99,6 @@ plugins/my-new-plugin/ See `plugins/attendance_confirm_plugin/` for a fully working reference implementation. -### Plugin checklist before opening a PR - -- [ ] `setup.py` contains a valid `pretix.plugin` entry point -- [ ] `apps.py` contains `AppConfig` with a `PretixPluginMeta` inner class -- [ ] Plugin installs cleanly with `pip install ./plugins/my-new-plugin/` -- [ ] No hard-coded credentials or environment-specific configuration - ---- ## Repository structure diff --git a/plugins/attendance_confirm_plugin/MANIFEST.in b/plugins/attendance_confirm_plugin/MANIFEST.in index 863544d..c63813b 100644 --- a/plugins/attendance_confirm_plugin/MANIFEST.in +++ b/plugins/attendance_confirm_plugin/MANIFEST.in @@ -1,4 +1,2 @@ -recursive-include -pretix_attendance_confirm/templates * -recursive-include -pretix_attendance_confirm/static * \ No newline at end of file +recursive-include pretix_attendance_confirm/templates * +recursive-include pretix_attendance_confirm/static * diff --git a/plugins/attendance_confirm_plugin/README.md b/plugins/attendance_confirm_plugin/README.md index f11afc2..5ecf7a1 100644 --- a/plugins/attendance_confirm_plugin/README.md +++ b/plugins/attendance_confirm_plugin/README.md @@ -12,8 +12,27 @@ Features: - Placeholders: {attendee_name} and {event_name}. - Sending is disabled until the event has ended. +Participant grouping: +- Recipients are grouped per participant within an order instead of strictly per + `OrderPosition`. +- This avoids duplicate confirmations when one participant is represented by + multiple positions, e.g. because of multiple modules/products or add-ons. +- Distinct participants in the same order remain separate if their attendee + identity differs. +- Known limitation: if two different people in the same order have the exact + same full attendee name and no attendee email, they are currently treated as + one recipient group. + ## Install ``` pip install -e /path/to/attendance_confirm_plugin ``` + +Docker note: the path must be the container path, not your host path. If you +mount this repo into the container, you can run either of these: +``` +pip install -e /pretix/src/attendance_confirm_plugin +pip install -e ./attendance_confirm_plugin +``` +Use the one that matches where the repo is mounted in your image. diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/PKG-INFO b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/PKG-INFO deleted file mode 100644 index 842f146..0000000 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/PKG-INFO +++ /dev/null @@ -1,28 +0,0 @@ -Metadata-Version: 2.4 -Name: pretix-attendance-confirm -Version: 0.1.0 -Summary: Attendance confirmation email plugin for pretix -Author: Ez for mITs -License: AGPL-3.0-or-later -Classifier: Development Status :: 3 - Alpha -Classifier: Environment :: Plugins -Classifier: Framework :: Django -Classifier: License :: OSI Approved :: GNU Affero General Public License v3 -Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3 :: Only -Classifier: Topic :: Office/Business -Requires-Python: >=3.10 -Description-Content-Type: text/markdown -Requires-Dist: pretix>=2024.3 - -# pretix-attendance-confirm (draft) - -pretix plugin to send attendance confirmation emails to checked-in attendees. -Emails are sent per attendee (attendee email first, order email as fallback) and can -be customized per event. - -## Install - -``` -pip install -e /path/to/attendance_confirm_plugin -``` diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/SOURCES.txt b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/SOURCES.txt deleted file mode 100644 index 82fddab..0000000 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/SOURCES.txt +++ /dev/null @@ -1,15 +0,0 @@ -README.md -pyproject.toml -setup.cfg -pretix_attendance_confirm/__init__.py -pretix_attendance_confirm/apps.py -pretix_attendance_confirm/forms.py -pretix_attendance_confirm/signals.py -pretix_attendance_confirm/urls.py -pretix_attendance_confirm/views.py -pretix_attendance_confirm.egg-info/PKG-INFO -pretix_attendance_confirm.egg-info/SOURCES.txt -pretix_attendance_confirm.egg-info/dependency_links.txt -pretix_attendance_confirm.egg-info/entry_points.txt -pretix_attendance_confirm.egg-info/requires.txt -pretix_attendance_confirm.egg-info/top_level.txt \ No newline at end of file diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/dependency_links.txt b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/entry_points.txt b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/entry_points.txt deleted file mode 100644 index b8d0423..0000000 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[pretix.plugin] -attendance_confirm = pretix_attendance_confirm diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/requires.txt b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/requires.txt deleted file mode 100644 index 213e748..0000000 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/requires.txt +++ /dev/null @@ -1 +0,0 @@ -pretix>=2024.3 diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/top_level.txt b/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/top_level.txt deleted file mode 100644 index 49828ba..0000000 --- a/plugins/attendance_confirm_plugin/pretix_attendance_confirm.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -pretix_attendance_confirm diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/__init__.cpython-313.pyc b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 3f52e75b958ebacb5796fdb78c4a381e585f0a3f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 266 zcmey&%ge<81dOVlnSnt1F^B^Lj8MjB4j^MHLoh=TLpq}-QoI{HSV_PI?irps2OXmLl0% zDu{IQDG<<0FS+;Hf6$*{z!!Oe^iuTTTMIQP&{Jkf#e(}FppP@Vv%~pjztN;r$`gE? zPjWv9*@6 zl}*os(ba3lrF3eFnA?e>Ps8BY>!MTavN&eXW9mD@0s7^`gD0QwHt9j*Y2$z*G>_ag zNs8*!nLI}0C20{*YyufufvlYY)y{&}(q#o(N*W19Qjvxg>08K8K9IAGRRVK%4)Wc( zT3)Ok0*k%pJ6KB4aRQem^+69Sy)^KESc#GV>Rd%oS5knUDF1uy@9Hd*IgN8-FAVrK zF4OLi$q|0yI+vD;95PoNw8YO|`MRxwVQY{}*7d9PIop7Is{lo0R!W%N|9@syx^?LH zyI$}Gi=E4&>`eh-SZEB*;}Fc=?hzkq8`g)`hAp7m! z%{VmO6Xt%)I_q9A4Q7m6!y9QUw;ye8Y<_Hg^b={;ZipPEfzxA@iae#gu+#UYU8MB8 zzT;1K<|)O7^Vs(S7KFI1P@&2_p*#KfBm`kRW5BV^BIi0W>xM88g%0B`c#-VBD7j%T z!i#8oz84Qbs#|D2d&YdRL@AGP%avCQUb`PNPAM#4$OL~xd|5vUd#uie;M7wF>znmi z58ldwMoBxTA^)M5x)Vj*iUwlgpY4Yden(ybe}M3UT+A<=E#5z$|6rtEES7(GbiR0d zq>sxhBV$~7ca*zWe&?+E^nCg2QDIzte^eST-1%OEH9QI?f;-henuJK*OSMfTADm+a zXBX!NvOd+ z8F)*A%#`MoMS+~v1f;=b*b6c)+&lejTr^J`7nPf5OZD^0#_7Rds-fNegCM*vE81F` zLLO??a;TYaoS++8@}@wxO;o2V{TQWf#oz?oPX-#~|4~|JB~^vhb~v+A74Sr2X;s%N s6}gA0%C})1GsyPyRfONOilV$Cw_lM5zmd&9^?7CO$CcLvUrC8?0B8Y(D*ylh diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/forms.cpython-313.pyc b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/forms.cpython-313.pyc deleted file mode 100644 index eac239285555ea19884d721d0840ad8f30e53399..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1902 zcmaJ>&2Q936d&8WUT3o(X%dnYQe>jg#7R zZo;A>PN;A}J)owia^OJ!2X49aNWy_-6i$^Y<+doT%B_84@4~8$I+o|XnR)Ncd-Hzp z?PjxS1Y>4n)&4z!&~JRv8Ob)YI}gkQq$6EeK<5Q2sGJuU#PbqOsAvR@Azj*!^u)5T z6j|#Si0Mfwrc}0)n!Ljon!u73kJfx(G8PE}vKC;+XxuPk(Rfa^7rRe@`35{#MpV!d z6?K71x(J_C5R2%9peOdD6=g-FiRI){rrSi!kD=uh_?SE?#A{vFQ_~_<{#RP*NP|@W zUN-d}J$+Qrd#8oSdZr_zeItFRxBCAq`BrDNzSw;K&zcoUCU6uR{G$Z(gI^hPT&4SwvE z4L$h*+)xxt;QtkMDc8QRi^LKZ;-4ljhJA2$wpOofJaR2UkJzqD-1)E$yO%v`guX>q zHy%};K=p06tP&ff%qlUj1w_3>Y{wXZ%tG|G^}hqv4~sHk}T;MCE_rJlIx*ONtvZODU(<2GJu)&REbl^9MdfQCb7Pz zu%sW>31te#Jf#>@UKwoQLg)l`-66A4>gR3Zl$n6p&@7Lkz#8QVWC?>>K9jGlMzjZG(=mJ>W1lBo<>NNHI92s(WQsJXF;%A|u-7J! zO&L&%ZrN~6g3TDPxbB3O?Y2eme+8zZLN3&AP+leMy}f{5=tRu#_g7(heH(2n*}JJN zWn_K)siHl6e@oGty`!7G6WbG$&nM1oO`O>t8h$!*3wIaRGn<*itwGdxXfyv&b8_a#%nzCM<*mLWEeZ8a zwuVtg+l8x9qyI}1P*SO=JmY64c{%R<4F{S?dx5ZY%~-<@ajl@*$X|jyb_ucs^;Z2I zUIo01!Fy1@VO?~LpiwoPfY8(G&G?ZB!LCsw->DOeKq3=C7aCplBB8$LYHA3ey-B|1 z)&FmW%Y+&LS1=s9mR8WD$U(|$hVt_N1!-B1C$5JvOJiK~%Av#g493?(!)c%7F^2Zz z2aXN3;{o(|vSR%8%vKE=p^gs1CQJEYX$1hs6dt-kiNR7e;`@e0*gn8e06om)K%jOh z3<$uAW!nfdiQ{0b3G@IM@_E1df{yZwc;2IlcRarg)9X8^dGy4uiN8=T`FSgvRFogQ z@7?oW)uK0EX*H`WB%Tla1~y!?3S;a8jak6-i;04TB( z0E&V1t5hm4-#JGQcCKC2QcNzd7_Q~TL65|XLKxVNUkLasLkgicQ{Exthe+@q9lJix xHzR929Hx94Kl2Pd21_2N&m(S0f*`y^BQMdZKjcwCUO)Ns>7P#jiGYoi{{f8a@jCzj diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/signals.cpython-313.pyc b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/signals.cpython-313.pyc deleted file mode 100644 index de9a9559be0b7a8260458ca7833106da3708ea10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2281 zcmb_d&2Jk;6rb5If2$RC( zr){K&R6?RX6%G~j)C&jr56X#F9N++wtw_WgwSWW?;+DugapKK-H)#@A0*R44`#kSA zGjHDfes8+*coab!d3e=)TS4e|{wHX%Z*)Hf;||i0h8IvCQ#{UXVL`}?RP=0dLCVWi z&MQ>${!0s?e3(W&TV9CfRjTG=G&YVd;0bggrYTxzM$lqfxaU>1sP9#G zcn2!sO#Cm7&0rR$q(samLcxwzh^xD#*02nhWCRwX#Hm|Lgd2cxh>vl}HCMNcqh z2{Y1^KU{_wa%d8rj|VtS3}QH+*cy}iuhwth;&Z{Q?e8;Y_d#lMR?w6g;iU2ci()ak9eZGD;n}kYX%Q`&m2-F!Wz+&2EE%?5sv0(srcTSiEFc4L?87E;+-3@< zzE4ccHmWsp2}ZvA(lskh0Ncxl8&4tKJ|PnaP%MiC9c*Qi~}KgK~;1M#%;BFv*0a zGf~Qw<kSs&3ks>r84o5Xex~aC(~18-&(O$1&?Rlnlfq2ad-TR3jja z63IwR;m0DB4sh@w@AgCIaE^~dW5ryxUL!fOOpRQF64$(T{A6x#cJ$sEeTcpnkaNt6 zZCFmWah;8BiL?)pJ`Y<*cnm*5w~GFV4u1N=dh$p+d1Ng*x%y^DP2QfnHMg!FYpch; zI=6m&zI}ZDLFoJV!+7hxOD)T3smFd+-A-cgX5@*24y0PC6KnfldbICg>(E>$J$diq z-HYq#>2`X0Jw4M-&vZt{@1^gi*GG=FM~-gF;#+uN6Ny8KCt;KtYmH6+iz)MbMk=up zM#G0bU-@jMHFc()I@1{%+f2nG(G77Rrfi@AS?R)o5S!SW9DP@j+fk}SnBlnxg%v+xBk;<1yA7fXBXyC?2o#gP%&@XBd zSoLT(A4AbkR&K1chNss;)Az-1W8cJnP}W{4aA@LCYqYqQ&{ofO)P1*4-#Y!p$u)JX zC5_PuSUZCYnFLFeuRx)yXM?wj^8027{LvuNtW`I-Ouf^oz7x*u^b+BzdpH<&P0*Q3 z4cmc-mRAD)I?+*Y-Cnx0ep0i(Dk-npl;5c*KQ9qqBQHuXGb}t;tCyP=nWrZJ#9u?F z0NsX&F@B6DAEVr_XzDR~`A_ufrh(zkdM96AxPb_uCa>m+OxLcMsE4a9XvAPBKN5kZ6Jb&0>nS(T%*6X#{2nE zqg4;YXvF_NK((*c=vIS@p^h&DWBiJ;A1L#d&fbx48(C95a@Zqo gm*k#Fu1gBfr0_zDJva01n3nxz*wd-|G|VXW5917$>Hq)$ diff --git a/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/views.cpython-313.pyc b/plugins/attendance_confirm_plugin/pretix_attendance_confirm/__pycache__/views.cpython-313.pyc deleted file mode 100644 index 50aa19024ffefd3b83f5daa165590632847813ef..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12255 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.3 python_requires = >=3.10 +[options.package_data] +pretix_attendance_confirm = + templates/**/* + static/**/* + [options.packages.find] exclude = tests @@ -31,8 +36,3 @@ exclude = [options.entry_points] pretix.plugin = attendance_confirm = pretix_attendance_confirm - -[options.package_data] -pretix_attendance_confirm = - templates/**/* - static/**/* \ No newline at end of file diff --git a/plugins/attendance_confirm_plugin/tests/test_recipient_grouping.py b/plugins/attendance_confirm_plugin/tests/test_recipient_grouping.py new file mode 100644 index 0000000..f705327 --- /dev/null +++ b/plugins/attendance_confirm_plugin/tests/test_recipient_grouping.py @@ -0,0 +1,123 @@ +from types import SimpleNamespace + +from pretix_attendance_confirm.views import SendConfirmationsView + + +def make_view(): + return SendConfirmationsView() + + +def make_position( + pk, + order, + positionid, + attendee_name="", + attendee_email="", + addon_to_id=None, +): + return SimpleNamespace( + pk=pk, + order=order, + order_id=order.pk, + positionid=positionid, + attendee_name_cached=attendee_name, + attendee_email=attendee_email, + addon_to_id=addon_to_id, + ) + + +def test_collapses_same_participant_with_multiple_positions(): + view = make_view() + order = SimpleNamespace(pk=10, code="ORD1", email="order@example.org", invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org"), + make_position(2, order, 2, attendee_name="Alex Example", attendee_email="alex@example.org"), + ] + + groups = view.group_positions_by_participant(positions, {1}) + + assert len(groups) == 1 + assert [position.pk for position in groups[0].positions] == [1, 2] + assert groups[0].checked_in is True + + +def test_keeps_distinct_participants_in_same_order_separate(): + view = make_view() + order = SimpleNamespace(pk=10, code="ORD1", email="order@example.org", invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org"), + make_position(2, order, 2, attendee_name="Blair Example", attendee_email="blair@example.org"), + ] + + groups = view.group_positions_by_participant(positions, set()) + + assert len(groups) == 2 + assert [position.pk for position in groups[0].positions] == [1] + assert [position.pk for position in groups[1].positions] == [2] + + +def test_groups_addon_without_identity_into_parent_participant(): + view = make_view() + order = SimpleNamespace(pk=10, code="ORD1", email="order@example.org", invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org"), + make_position(2, order, 2, addon_to_id=1), + ] + + groups = view.group_positions_by_participant(positions, set()) + + assert len(groups) == 1 + assert [position.pk for position in groups[0].positions] == [1, 2] + + +def test_prefers_attendee_email_over_order_email_for_group_recipient(): + view = make_view() + order = SimpleNamespace(pk=10, code="ORD1", email="order@example.org", invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org"), + make_position(2, order, 2, attendee_name="Alex Example"), + ] + + group = view.group_positions_by_participant(positions, set())[0] + + assert view.get_group_recipient(group) == "alex@example.org" + + +def test_falls_back_to_order_email_when_group_has_no_attendee_email(): + view = make_view() + order = SimpleNamespace(pk=10, code="ORD1", email="order@example.org", invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example"), + make_position(2, order, 2, attendee_name="Alex Example"), + ] + + group = view.group_positions_by_participant(positions, set())[0] + + assert view.get_group_recipient(group) == "order@example.org" + + +def test_build_recipient_choices_handles_missing_invoice_address_relation(): + view = make_view() + + class MissingInvoiceDescriptor: + class RelatedObjectDoesNotExist(Exception): + pass + + def __get__(self, instance, owner): + raise self.RelatedObjectDoesNotExist() + + class MissingInvoiceOrder: + pk = 10 + code = "ORD1" + email = "order@example.org" + invoice_address = MissingInvoiceDescriptor() + + order = MissingInvoiceOrder() + positions = [ + make_position(1, order, 1, attendee_name="", attendee_email=""), + ] + + groups = view.group_positions_by_participant(positions, set()) + choices = view.build_recipient_choices(groups) + + assert choices == [("position:1", "order@example.org - order@example.org - ORD1 - nicht eingecheckt")] diff --git a/plugins/selective_export_plugin/MANIFEST.in b/plugins/selective_export_plugin/MANIFEST.in index fb93042..86f86e5 100644 --- a/plugins/selective_export_plugin/MANIFEST.in +++ b/plugins/selective_export_plugin/MANIFEST.in @@ -1,4 +1,2 @@ -recursive-include -pretix_selective_export/templates * -recursive-include -pretix_selective_export/static * \ No newline at end of file +recursive-include pretix_selective_export/templates * +recursive-include pretix_selective_export/static * diff --git a/plugins/selective_export_plugin/README.md b/plugins/selective_export_plugin/README.md index b8d3c05..3f53267 100644 --- a/plugins/selective_export_plugin/README.md +++ b/plugins/selective_export_plugin/README.md @@ -10,7 +10,26 @@ Notes: - Field selection includes model fields, related labels, invoice fields (when invoice addresses exist), and question answers (when answers exist). - Presets: save/load field selections directly on the export form without reloading. - Presets are stored in the browser (localStorage) per URL/event. + Presets are stored in pretix settings and shared with all users who can access + the same export page. + +Participant row grouping: +- Export rows are grouped per participant within an order, not emitted strictly per `OrderPosition`. +- This avoids duplicate-looking rows when one participant is represented by multiple positions, + e.g. one booking with two modules/products or a main ticket plus add-ons. +- Positions are collapsed when they clearly refer to the same participant in the same order. + The grouping primarily uses attendee email and attendee full name. +- Distinct participants in the same order remain separate if their attendee identity differs. +- Add-ons without their own attendee identity are grouped into the parent participant row. +- Position-level values that differ across grouped rows are preserved as joined values using ` | ` + instead of silently dropping one of them. +- Known limitation: if two different people in the same order have the exact same full attendee + name and no attendee email, the export currently treats them as one participant row. + +Column ordering: +- Selected fields can be reordered in the export form. +- The chosen order controls the exported column order. +- Presets store both the selected fields and their explicit order. ## Setup (pretix + plugin) diff --git a/plugins/selective_export_plugin/pretix_selective_export.egg-info/PKG-INFO b/plugins/selective_export_plugin/pretix_selective_export.egg-info/PKG-INFO deleted file mode 100644 index ee1c9dc..0000000 --- a/plugins/selective_export_plugin/pretix_selective_export.egg-info/PKG-INFO +++ /dev/null @@ -1,63 +0,0 @@ -Metadata-Version: 2.4 -Name: pretix-selective-export -Version: 0.1.0 -Summary: Selective field export plugin for pretix -Author: Ez for mITs -License: AGPL-3.0-or-later -Classifier: Development Status :: 3 - Alpha -Classifier: Environment :: Plugins -Classifier: Framework :: Django -Classifier: License :: OSI Approved :: GNU Affero General Public License v3 -Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3 :: Only -Classifier: Topic :: Office/Business -Requires-Python: >=3.10 -Description-Content-Type: text/markdown -Requires-Dist: pretix>=2024.3 - -# pretix-selective-export (draft) - -pretix exporter plugin that lets you pick exactly which fields to export. -It supports event-level exports as well as organizer-level exports that aggregate -all events into one file. - -Notes: -- Activate the plugin at the organizer level to use multi-event exports. -- Activate it for an event to use event-level exports. -- Field selection includes model fields, related labels, invoice fields (when - invoice addresses exist), and question answers (when answers exist). - -## Setup (pretix + plugin) - -These steps assume a local pretix checkout and Python 3.10+. - -1) Create and activate a virtualenv: -``` -python3 -m venv .venv -source .venv/bin/activate -``` - -2) Install pretix (editable) and its dev requirements: -``` -git clone https://github.com/pretix/pretix.git -cd pretix -pip install -U pip -pip install -e ".[dev]" -``` - -3) Install this plugin (editable): -``` -pip install -e /path/to/selective_export_plugin -``` - -4) Enable the plugin in pretix: -- Add `pretix_selective_export` to the `PLUGINS` list in your pretix config. - -5) Run pretix and use the exporter: -- Start pretix using the standard dev-server command for your checkout. Common options are: -``` -python src/manage.py runserver -``` -- If that doesn't work in your setup, follow the upstream pretix development setup instructions and use their start command. -- For event-level exports: enable the plugin on the event and go to the export page. -- For organizer-level exports: enable the plugin on the organizer and use the organizer export page (single aggregated file). diff --git a/plugins/selective_export_plugin/pretix_selective_export.egg-info/SOURCES.txt b/plugins/selective_export_plugin/pretix_selective_export.egg-info/SOURCES.txt deleted file mode 100644 index 6d08c8f..0000000 --- a/plugins/selective_export_plugin/pretix_selective_export.egg-info/SOURCES.txt +++ /dev/null @@ -1,13 +0,0 @@ -README.md -pyproject.toml -setup.cfg -pretix_selective_export/__init__.py -pretix_selective_export/apps.py -pretix_selective_export/exporter.py -pretix_selective_export/signals.py -pretix_selective_export.egg-info/PKG-INFO -pretix_selective_export.egg-info/SOURCES.txt -pretix_selective_export.egg-info/dependency_links.txt -pretix_selective_export.egg-info/entry_points.txt -pretix_selective_export.egg-info/requires.txt -pretix_selective_export.egg-info/top_level.txt \ No newline at end of file diff --git a/plugins/selective_export_plugin/pretix_selective_export.egg-info/dependency_links.txt b/plugins/selective_export_plugin/pretix_selective_export.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/plugins/selective_export_plugin/pretix_selective_export.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/plugins/selective_export_plugin/pretix_selective_export.egg-info/entry_points.txt b/plugins/selective_export_plugin/pretix_selective_export.egg-info/entry_points.txt deleted file mode 100644 index 971b755..0000000 --- a/plugins/selective_export_plugin/pretix_selective_export.egg-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[pretix.plugin] -selective_export = pretix_selective_export diff --git a/plugins/selective_export_plugin/pretix_selective_export.egg-info/requires.txt b/plugins/selective_export_plugin/pretix_selective_export.egg-info/requires.txt deleted file mode 100644 index 213e748..0000000 --- a/plugins/selective_export_plugin/pretix_selective_export.egg-info/requires.txt +++ /dev/null @@ -1 +0,0 @@ -pretix>=2024.3 diff --git a/plugins/selective_export_plugin/pretix_selective_export.egg-info/top_level.txt b/plugins/selective_export_plugin/pretix_selective_export.egg-info/top_level.txt deleted file mode 100644 index 64d650c..0000000 --- a/plugins/selective_export_plugin/pretix_selective_export.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -pretix_selective_export diff --git a/plugins/selective_export_plugin/pretix_selective_export/__pycache__/__init__.cpython-313.pyc b/plugins/selective_export_plugin/pretix_selective_export/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 674c1e18ccd1115f0567a6b0bf378e3fba457da0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 234 zcmey&%ge<81RdKwGJS#cV-N=h7@>^M96-iYhG2#whIB?vrYh5dqSTViiumHxoYdr! z%(B$@)QW=qq7uEtf`Veb0I*~*OwJW7Bq-s=4F<|$LkeT-r}&y%}*)KNwq6t1DXSJ Yb1^rN_`uA_$aq84=OTkp5etwH0K=I^cK`qY diff --git a/plugins/selective_export_plugin/pretix_selective_export/__pycache__/apps.cpython-313.pyc b/plugins/selective_export_plugin/pretix_selective_export/__pycache__/apps.cpython-313.pyc deleted file mode 100644 index 60bcc7d0f0cf1c8dd6921fbc2d2e648b276bdb37..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1379 zcmZ`(&2Jk;6rZ*C%U}6&)0#lTrY)itViS_4;y@#%ttn0%H_8SO$kJ$SkDW#9UGruf zVxMxVEOFsd3GpBB-$csA8L3LgLh!UDqWbM)GgpoA>7F_r5&N<a$+~JlLR%Ale^lE4!VU3=oWI+CFE!|WiuJS z2cxYQjZ3K%6`tDeb{~a7%WDfI+a`>W6NY_v&=>mc{mSQ0%2iw`eNn1l0o6nN^x(;M zwfuGI0Dtz?!-MkUMjSOpb)t+P3gJ1bAy6Cy2}gmXlK{1*2|CG5CH4aS4ZTf~tUO>i z`Vs=uF(B18iz!~%2g1A)hxnwyykk;2>4v}r=}SYn^BdjBjOlATCXvJ6bJG`Ad~=28 zq!eg;D1uz&SyKha#FV2J>HpyDs9;2Q+SJE%bVv!t=~@P|jtRMTs+i`*|6^{)hJC-+ z_JTda+{=v2TsD4?DGjVv2v(u&7xmKLDTla|& znH9FImPh<1wYmQ3(}TV3!@*2kpREV$YwP#yjh|7qIKxeh1Ghsk=4p&OVYBB;Ig9bP zJ=dRDW-)GgK$-6aBnX9B!CaL#uDd;U6aqJn2~aN#c&15c1H3MiirH`(X@}6~xklLO zy3DJ4zQ_6?r!H4S; z;Xkcjb-P_^cl&(q-*ttoh$U-F?+bW_E@tP>=5L?R-WjSF^9w&bIGZ66XhQ`J9 z8)t>e`SrbFW|V(>XpRbZhq=+*#`hZB6i?w@0gcJn3K1bWu5yURa{|OJDq{b{E(VH) z+NhYUUP@=NK?e2Of$P&^f+M&k?-E=UZEHmem0>Ikmc@r_K-^}ZFUMI-$9FpLc4WUM zT&b+(Df-2{8MBjhs}|EBxxl=H1qpcxixT7`o)!J7hm=IUk1~Syr12dIGDI9qasjzp z5lDfq2zZ7@nLDSCFY?#U7T-V5-#xATr5f73KahZz3yQWJzn1>hqA6zL8#ic&w(Jea zLh-`nrpG*=+6>%)`eFpa0OX%WOfet#+OE5l*xhJ9Xf!x6p{pdh`AIyxTkwI1APY{H f1pJ;<6y*h4d4cZ#h93Q?&nU}3_Fp3Li737TLQrNJ diff --git a/plugins/selective_export_plugin/pretix_selective_export/__pycache__/exporter.cpython-313.pyc b/plugins/selective_export_plugin/pretix_selective_export/__pycache__/exporter.cpython-313.pyc deleted file mode 100644 index 3707a2f8704a8ba6ef230aa33581deb0d6bddad6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17966 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 diff --git a/plugins/selective_export_plugin/pretix_selective_export/__pycache__/signals.cpython-313.pyc b/plugins/selective_export_plugin/pretix_selective_export/__pycache__/signals.cpython-313.pyc deleted file mode 100644 index e6d8fc9052fe1d93cc7fb504774463ca227fa17e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4018 zcmcgvO>7&-6`tMY|5Bnzi9eD|nfj@1S`sPQjtbeb6gY`vP_de7E99UUo3*&6=u)II zyRsa?K$TwVoDv-ZH>VUiMYrTo1U(c;PrdF!K}am@CP3RmZX#4Pg-?BNxJybDQ#3%) z0rdEG-kX^>Gw=J}=%}kJK%k}mxLy2%NXTFD!)b0?Ir=9k9}H+MNs0P_BpeK%ZCgErxRu z8cC5ixe@ZFDEXzpEH8-z)YijXtVDpp=U{D*cXwfcH5oCaEQLO^1+g7S&Sz6?;J|p*j8X zJv1~!!JzX+ZBxU7^UH08 z7Br2A?o`TJ%C1*7)U3KgmFy-}_2R?HOW9T!jkAz9OIw9v*>;fI%~`EjC@Uo`v-yq{ zYaZE&v!n@FE(Cu^J`lU)Z{o1w8a@J@Sfj1^PVQQ6o%(;fVcH8bvMtj-(Cl5(IIc6? z46QZq$=jq!@WXMmAH@KU8fZmlA4c4pRC04r}KM7avFtXDc%a}!N z?FUA8@RVE`UUCC%cvgFyNtPpy*81%xm-$KfEW1{tPOs#3PHM$EMzTT2spMgQB_(zz4)>0C*sux3^SH-x*x1lyzDu zU1ju;X*gsWi3LpLZ&HQjW*++-#Z{jN7m9fxpCSN+UVq*rIXsbS4BhGLH1G4?4y#zc zd}X^_swnw=x8*J=ch!<5Y|)bC*;FVjVR;nBOqQz%bEbn7OO53R>RFeI<$D#&wWR@# z^sOsegX82)m2MO@tyn43QShZ>C_FgIr2%hHguNAL#cy%4<%1RFva+G>yD45Z9YKLx zr4!8E#u~V37(fbJ1I7bQ{GB2qIt#7{T@6bEuuLHQ!TD-r)QpTC1jcq>s)>>J=iZyE ziW8({RG*pQ%O9Jh`)Xry89w zqZ8HWv>BbQg%eMbd&z2e)C`Z--NK8Ur%r^3_s~OviLVlwYGT$*%o_2lhW}dKD@5jY zbN>jESlo!G526=qy~(Gsq%m}*mK^)|&PR8u$tz~^N;NraCTDB?sgIK%C9D18X8$;aW99R{?_jlaXg^bd6q1a<|&>YokM|%X!#n{t(0=(*vEDuyGD6eTxrvhS!;`}3^ z;U!=Rv5N>@fNv&V0!8~Zh+XonxBJ~+)gp<<-oJAx7iu0h>-(q5OTNT&QH6S3kw{wPJR!v#$)+ zag|bbAfj!=n%Ou-+_jZ(y7eMF(RHenHTd5Mt2Mqb8E;s1Dg`M_(<5Arx= MAX_PRESETS: + break + return dict(presets) + + +def get_presets(settings_holder): + raw = settings_holder.settings.get(SETTINGS_KEY, as_type=dict, default={}) + return normalize_presets(raw) + + +def save_preset(settings_holder, name, preset_data): + normalized_name = normalize_preset_name(name) + if not normalized_name: + raise ValueError("Preset name is required.") + + presets = OrderedDict(normalize_presets(get_presets(settings_holder))) + presets[normalized_name] = normalize_preset_data(preset_data) + + while len(presets) > MAX_PRESETS: + presets.popitem(last=False) + + settings_holder.settings.set(SETTINGS_KEY, dict(presets)) + return normalized_name, presets[normalized_name] + + +def _normalize_string_list(values): + if not isinstance(values, list): + return [] + + normalized = [] + seen = set() + + for value in values: + text = str(value).strip() + if not text or text in seen: + continue + seen.add(text) + normalized.append(text) + if len(normalized) >= MAX_VALUES_PER_LIST: + break + + return normalized diff --git a/plugins/selective_export_plugin/pretix_selective_export/signals.py b/plugins/selective_export_plugin/pretix_selective_export/signals.py index 425e947..f42f8fe 100644 --- a/plugins/selective_export_plugin/pretix_selective_export/signals.py +++ b/plugins/selective_export_plugin/pretix_selective_export/signals.py @@ -10,6 +10,7 @@ from pretix.base.signals import ( from pretix.control.signals import html_page_start, nav_event, nav_organizer from .exporter import SelectiveFieldExporter +from .presets import get_presets @receiver(register_data_exporters, dispatch_uid="selective_field_exporter") @@ -25,7 +26,7 @@ def register_multievent_data_exporter(sender, **kwargs): @receiver(nav_event, dispatch_uid="selective_export_nav_event") def nav_event_selective_export(sender, request=None, **kwargs): url = resolve(request.path_info) - if not request.user.has_event_permission(request.organizer, request.event, 'can_view_orders', request=request): + if not request.user.has_event_permission(request.organizer, request.event, 'event.orders:read', request=request): return [] export_url = reverse('control:event.orders.export', kwargs={ 'organizer': request.event.organizer.slug, @@ -48,7 +49,9 @@ def nav_event_selective_export(sender, request=None, **kwargs): @receiver(nav_organizer, dispatch_uid="selective_export_nav_organizer") def nav_organizer_selective_export(sender, request=None, **kwargs): url = resolve(request.path_info) - if not request.user.has_organizer_permission(request.organizer, 'can_view_orders', request=request): + if not request.user.get_events_with_permission('event.orders:read', request=request).filter( + organizer=request.organizer + ).exists(): return [] export_url = reverse('control:organizer.export', kwargs={ 'organizer': request.organizer.slug, @@ -76,5 +79,43 @@ def selective_export_html_page_start(sender, request=None, **kwargs): identifier = request.GET.get("identifier") or request.GET.get("exporter") if identifier != SelectiveFieldExporter.identifier: return "" + if url.url_name == "event.orders.export": + save_url = reverse( + "plugins:pretix_selective_export:event_preset_save", + kwargs={ + "organizer": request.event.organizer.slug, + "event": request.event.slug, + }, + ) + presets = get_presets(request.event) + else: + save_url = reverse( + "plugins:pretix_selective_export:organizer_preset_save", + kwargs={ + "organizer": request.organizer.slug, + }, + ) + presets = get_presets(request.organizer) template = get_template("pretixplugins/selective_export/control_preset.html") - return template.render({}) + return template.render( + { + "preset_config": { + "title": str(_("Presets")), + "selectPlaceholder": str(_("Select a preset")), + "load": str(_("Load")), + "namePlaceholder": str(_("Preset name")), + "save": str(_("Save preset")), + "nameRequired": str(_("Please enter a preset name.")), + "confirmOverwrite": str(_("Overwrite existing preset?")), + "saveFailed": str(_("Saving the preset failed.")), + "fieldOrderTitle": str(_("Column order")), + "fieldOrderHelp": str(_("Selected fields appear in export order. Use the arrows to move them.")), + "moveUp": str(_("Move up")), + "moveDown": str(_("Move down")), + "sharedHelp": str(_("Presets are shared for all users with access to this export.")), + "saveUrl": save_url, + "initialPresets": presets, + } + }, + request, + ) diff --git a/plugins/selective_export_plugin/pretix_selective_export/static/pretixplugins/selective_export/presets.js b/plugins/selective_export_plugin/pretix_selective_export/static/pretixplugins/selective_export/presets.js index 4d4b981..6e230e3 100644 --- a/plugins/selective_export_plugin/pretix_selective_export/static/pretixplugins/selective_export/presets.js +++ b/plugins/selective_export_plugin/pretix_selective_export/static/pretixplugins/selective_export/presets.js @@ -1,7 +1,7 @@ (function () { "use strict"; - const configEl = document.getElementById("selective-export-preset-i18n"); + const configEl = document.getElementById("selective-export-preset-config"); if (!configEl) { return; } @@ -28,25 +28,6 @@ return; } - const storageKey = `pretix_selective_export_presets:${window.location.pathname}`; - - const readPresets = function () { - try { - const raw = window.localStorage.getItem(storageKey); - return raw ? JSON.parse(raw) : {}; - } catch (e) { - return {}; - } - }; - - const writePresets = function (presets) { - try { - window.localStorage.setItem(storageKey, JSON.stringify(presets)); - } catch (e) { - // Ignore storage errors (e.g. private mode). - } - }; - const getCheckedValues = function (name) { return Array.from(form.querySelectorAll(`input[name="${name}"]:checked`)).map((input) => input.value); }; @@ -115,6 +96,14 @@ controls.appendChild(loadGroup); controls.appendChild(saveGroup); + if (i18n.sharedHelp) { + const help = document.createElement("p"); + help.className = "help-block"; + help.style.marginTop = "6px"; + help.textContent = i18n.sharedHelp; + controls.appendChild(help); + } + wrapper.appendChild(label); wrapper.appendChild(controls); @@ -152,12 +141,169 @@ } }; - let presets = readPresets(); + let presets = i18n.initialPresets || {}; refreshOptions(presets); const formPrefix = exporterInput.value; const fieldsName = `${formPrefix}-fields`; + const fieldOrderName = `${formPrefix}-field_order`; const eventsName = `${formPrefix}-events`; + const fieldInputs = Array.from(form.querySelectorAll(`input[name="${fieldsName}"]`)); + const fieldOrderInput = form.querySelector(`input[name="${fieldOrderName}"]`); + + if (!fieldInputs.length || !fieldOrderInput) { + return; + } + + const fieldLabels = new Map( + fieldInputs.map((input) => { + const label = input.closest("label"); + return [input.value, label ? label.textContent.trim() : input.value]; + }) + ); + + const readFieldOrder = function () { + try { + const parsed = JSON.parse(fieldOrderInput.value || "[]"); + return Array.isArray(parsed) ? parsed : []; + } catch (e) { + return []; + } + }; + + const setFieldOrder = function (values) { + fieldOrderInput.value = JSON.stringify(values); + }; + + const getSelectedFieldValues = function () { + return fieldInputs.filter((input) => input.checked).map((input) => input.value); + }; + + const syncFieldOrder = function (preferredOrder) { + const selected = getSelectedFieldValues(); + const selectedSet = new Set(selected); + const ordered = []; + const seen = new Set(); + + (preferredOrder || []).forEach((value) => { + if (selectedSet.has(value) && !seen.has(value)) { + seen.add(value); + ordered.push(value); + } + }); + + selected.forEach((value) => { + if (!seen.has(value)) { + seen.add(value); + ordered.push(value); + } + }); + + setFieldOrder(ordered); + return ordered; + }; + + const buildFieldOrderUI = function () { + const wrapper = document.createElement("div"); + wrapper.className = "form-group"; + + const label = document.createElement("label"); + label.className = "col-md-3 control-label"; + label.textContent = i18n.fieldOrderTitle || "Column order"; + + const controls = document.createElement("div"); + controls.className = "col-md-9"; + + const help = document.createElement("p"); + help.className = "help-block"; + help.textContent = i18n.fieldOrderHelp || "Selected fields appear in export order. Use the arrows to move them."; + + const list = document.createElement("ul"); + list.className = "list-group"; + list.style.marginBottom = "0"; + + controls.appendChild(help); + controls.appendChild(list); + wrapper.appendChild(label); + wrapper.appendChild(controls); + + return { wrapper, list }; + }; + + const fieldOrderUI = buildFieldOrderUI(); + const fieldContainer = fieldInputs[0].closest(".form-group"); + if (fieldContainer) { + fieldContainer.insertAdjacentElement("afterend", fieldOrderUI.wrapper); + } else { + fieldset.appendChild(fieldOrderUI.wrapper); + } + + const renderFieldOrder = function () { + const ordered = syncFieldOrder(readFieldOrder()); + fieldOrderUI.list.innerHTML = ""; + + ordered.forEach((value, index) => { + const item = document.createElement("li"); + item.className = "list-group-item"; + + const text = document.createElement("span"); + text.textContent = fieldLabels.get(value) || value; + + const buttons = document.createElement("span"); + buttons.className = "pull-right"; + + const upButton = document.createElement("button"); + upButton.type = "button"; + upButton.className = "btn btn-default btn-xs"; + upButton.textContent = "↑"; + upButton.title = i18n.moveUp || "Move up"; + upButton.disabled = index === 0; + upButton.addEventListener("click", function () { + const current = readFieldOrder(); + const currentIndex = current.indexOf(value); + if (currentIndex <= 0) { + return; + } + current.splice(currentIndex, 1); + current.splice(currentIndex - 1, 0, value); + setFieldOrder(current); + renderFieldOrder(); + }); + + const downButton = document.createElement("button"); + downButton.type = "button"; + downButton.className = "btn btn-default btn-xs"; + downButton.textContent = "↓"; + downButton.title = i18n.moveDown || "Move down"; + downButton.style.marginLeft = "4px"; + downButton.disabled = index === ordered.length - 1; + downButton.addEventListener("click", function () { + const current = readFieldOrder(); + const currentIndex = current.indexOf(value); + if (currentIndex === -1 || currentIndex >= current.length - 1) { + return; + } + current.splice(currentIndex, 1); + current.splice(currentIndex + 1, 0, value); + setFieldOrder(current); + renderFieldOrder(); + }); + + buttons.appendChild(upButton); + buttons.appendChild(downButton); + item.appendChild(text); + item.appendChild(buttons); + fieldOrderUI.list.appendChild(item); + }); + + fieldOrderUI.wrapper.style.display = ordered.length ? "" : "none"; + }; + + fieldInputs.forEach((input) => { + input.addEventListener("change", function () { + renderFieldOrder(); + }); + }); loadBtn.addEventListener("click", function () { const name = select.value; @@ -167,6 +313,8 @@ const preset = presets[name]; setCheckedValues(fieldsName, preset.fields || []); setCheckedValues(eventsName, preset.events || []); + setFieldOrder(Array.isArray(preset.fieldOrder) ? preset.fieldOrder : []); + renderFieldOrder(); }); saveBtn.addEventListener("click", function () { @@ -178,12 +326,40 @@ if (presets[name] && !window.confirm(i18n.confirmOverwrite || "Overwrite existing preset?")) { return; } - presets[name] = { + const payload = { fields: getCheckedValues(fieldsName), + fieldOrder: readFieldOrder(), events: getCheckedValues(eventsName), }; - writePresets(presets); - refreshOptions(presets, name); - nameInput.value = ""; + saveBtn.disabled = true; + window.fetch(i18n.saveUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": form.querySelector('input[name="csrfmiddlewaretoken"]')?.value || "", + }, + credentials: "same-origin", + body: JSON.stringify({ + name: name, + preset: payload, + }), + }).then(function (response) { + return response.json().then(function (data) { + if (!response.ok) { + throw new Error(data.error || i18n.saveFailed || "Saving the preset failed."); + } + return data; + }); + }).then(function (data) { + presets = data.presets || presets; + refreshOptions(presets, data.name || name); + nameInput.value = ""; + }).catch(function (error) { + window.alert(error.message || i18n.saveFailed || "Saving the preset failed."); + }).finally(function () { + saveBtn.disabled = false; + }); }); + + renderFieldOrder(); })(); diff --git a/plugins/selective_export_plugin/pretix_selective_export/templates/pretixplugins/selective_export/control_preset.html b/plugins/selective_export_plugin/pretix_selective_export/templates/pretixplugins/selective_export/control_preset.html index 1ac7df6..0ed6d18 100644 --- a/plugins/selective_export_plugin/pretix_selective_export/templates/pretixplugins/selective_export/control_preset.html +++ b/plugins/selective_export_plugin/pretix_selective_export/templates/pretixplugins/selective_export/control_preset.html @@ -1,13 +1,3 @@ -{% load i18n static %} - +{% load static %} +{{ preset_config|json_script:"selective-export-preset-config" }} diff --git a/plugins/selective_export_plugin/pretix_selective_export/urls.py b/plugins/selective_export_plugin/pretix_selective_export/urls.py new file mode 100644 index 0000000..007e973 --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export/urls.py @@ -0,0 +1,18 @@ +from django.urls import re_path + +from . import views + +app_name = "selective_export" + +urlpatterns = [ + re_path( + r"^control/event/(?P[^/]+)/(?P[^/]+)/selective-export/presets/save/$", + views.EventPresetSaveView.as_view(), + name="event_preset_save", + ), + re_path( + r"^control/organizer/(?P[^/]+)/selective-export/presets/save/$", + views.OrganizerPresetSaveView.as_view(), + name="organizer_preset_save", + ), +] diff --git a/plugins/selective_export_plugin/pretix_selective_export/views.py b/plugins/selective_export_plugin/pretix_selective_export/views.py new file mode 100644 index 0000000..88f1cfa --- /dev/null +++ b/plugins/selective_export_plugin/pretix_selective_export/views.py @@ -0,0 +1,50 @@ +import json + +from django.core.exceptions import PermissionDenied +from django.http import JsonResponse +from django.views import View + +from pretix.control.permissions import ( + EventPermissionRequiredMixin, + OrganizerPermissionRequiredMixin, +) + +from .presets import get_presets, save_preset + + +class EventPresetSaveView(EventPermissionRequiredMixin, View): + permission = "event.orders:read" + + def post(self, request, *args, **kwargs): + return _save_preset_response(request.event, request) + + +class OrganizerPresetSaveView(OrganizerPermissionRequiredMixin, View): + permission = None + + def post(self, request, *args, **kwargs): + if not request.user.get_events_with_permission("event.orders:read", request=request).filter( + organizer=request.organizer + ).exists(): + raise PermissionDenied("You do not have permission to view this content.") + return _save_preset_response(request.organizer, request) + + +def _save_preset_response(settings_holder, request): + try: + payload = json.loads(request.body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return JsonResponse({"error": "Invalid JSON payload."}, status=400) + + try: + name, preset = save_preset(settings_holder, payload.get("name"), payload.get("preset")) + except ValueError as exc: + return JsonResponse({"error": str(exc)}, status=400) + + return JsonResponse( + { + "name": name, + "preset": preset, + "presets": get_presets(settings_holder), + } + ) diff --git a/plugins/selective_export_plugin/setup.cfg b/plugins/selective_export_plugin/setup.cfg index 5d44e9e..011aae9 100644 --- a/plugins/selective_export_plugin/setup.cfg +++ b/plugins/selective_export_plugin/setup.cfg @@ -23,6 +23,11 @@ install_requires = pretix>=2024.3 python_requires = >=3.10 +[options.package_data] +pretix_selective_export = + templates/**/* + static/**/* + [options.packages.find] exclude = tests @@ -31,8 +36,3 @@ exclude = [options.entry_points] pretix.plugin = selective_export = pretix_selective_export - -[options.package_data] -pretix_selective_export = - templates/**/* - static/**/* diff --git a/plugins/selective_export_plugin/tests/test_exporter_grouping.py b/plugins/selective_export_plugin/tests/test_exporter_grouping.py new file mode 100644 index 0000000..b51d9c2 --- /dev/null +++ b/plugins/selective_export_plugin/tests/test_exporter_grouping.py @@ -0,0 +1,264 @@ +from types import SimpleNamespace + +from pretix_selective_export.exporter import FieldDefinition, SelectiveFieldExporter + + +class AnswersList: + def __init__(self, answers=None): + self._answers = answers or [] + + def all(self): + return list(self._answers) + + +def make_exporter(): + return SelectiveFieldExporter.__new__(SelectiveFieldExporter) + + +def make_position( + pk, + order, + positionid, + attendee_name="", + attendee_email="", + item="", + addon_to_id=None, + answers=None, +): + return SimpleNamespace( + pk=pk, + order=order, + order_id=order.pk, + positionid=positionid, + attendee_name_cached=attendee_name, + attendee_email=attendee_email, + item=item, + variation="", + subevent="", + addon_to_id=addon_to_id, + answers=AnswersList(answers), + ) + + +def make_answer(question_id, value, question_type="text"): + question = SimpleNamespace(type=question_type) + return SimpleNamespace( + question_id=question_id, + question=question, + backend_file_url="", + frontend_file_url="", + to_string=lambda: value, + ) + + +def test_collapses_same_participant_with_multiple_positions(): + exporter = make_exporter() + order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org", item="Module A"), + make_position(2, order, 2, attendee_name="Alex Example", attendee_email="alex@example.org", item="Module B"), + ] + + contexts = list(exporter._build_grouped_contexts(positions)) + + assert len(contexts) == 1 + assert [position.pk for position in contexts[0].positions] == [1, 2] + assert exporter._collapse_group_values(position.item for position in contexts[0].positions) == "Module A | Module B" + + +def test_keeps_distinct_participants_in_same_order_separate(): + exporter = make_exporter() + order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org", item="Module A"), + make_position(2, order, 2, attendee_name="Blair Example", attendee_email="blair@example.org", item="Module B"), + ] + + contexts = list(exporter._build_grouped_contexts(positions)) + + assert len(contexts) == 2 + assert [position.pk for position in contexts[0].positions] == [1] + assert [position.pk for position in contexts[1].positions] == [2] + + +def test_groups_addon_without_identity_into_parent_participant(): + exporter = make_exporter() + order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org", item="Main ticket"), + make_position(2, order, 2, item="Addon", addon_to_id=1), + ] + + contexts = list(exporter._build_grouped_contexts(positions)) + + assert len(contexts) == 1 + assert [position.pk for position in contexts[0].positions] == [1, 2] + + +def test_merges_question_answers_from_grouped_positions(): + exporter = make_exporter() + order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example", answers=[make_answer(5, "Module A")]), + make_position(2, order, 2, attendee_name="Alex Example", answers=[make_answer(5, "Module B")]), + ] + + context = list(exporter._build_grouped_contexts(positions))[0] + + assert exporter._get_question_answer(context, 5) == "Module A | Module B" + + +def test_collapses_same_name_without_email_when_no_better_identity_exists(): + exporter = make_exporter() + order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example", item="Module A"), + make_position(2, order, 2, attendee_name="Alex Example", item="Module B"), + ] + + contexts = list(exporter._build_grouped_contexts(positions)) + + assert len(contexts) == 1 + assert [position.pk for position in contexts[0].positions] == [1, 2] + + +def test_keeps_same_name_separate_when_emails_differ(): + exporter = make_exporter() + order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex.one@example.org"), + make_position(2, order, 2, attendee_name="Alex Example", attendee_email="alex.two@example.org"), + ] + + contexts = list(exporter._build_grouped_contexts(positions)) + + assert len(contexts) == 2 + assert [position.pk for position in contexts[0].positions] == [1] + assert [position.pk for position in contexts[1].positions] == [2] + + +def test_keeps_same_email_separate_when_names_differ(): + exporter = make_exporter() + order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example", attendee_email="shared@example.org"), + make_position(2, order, 2, attendee_name="A. Example", attendee_email="shared@example.org"), + ] + + contexts = list(exporter._build_grouped_contexts(positions)) + + assert len(contexts) == 2 + assert [position.pk for position in contexts[0].positions] == [1] + assert [position.pk for position in contexts[1].positions] == [2] + + +def test_keeps_positions_without_identity_separate(): + exporter = make_exporter() + order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None) + positions = [ + make_position(1, order, 1, item="Module A"), + make_position(2, order, 2, item="Module B"), + ] + + contexts = list(exporter._build_grouped_contexts(positions)) + + assert len(contexts) == 2 + assert [position.pk for position in contexts[0].positions] == [1] + assert [position.pk for position in contexts[1].positions] == [2] + + +def test_keeps_addon_with_own_identity_separate_from_parent(): + exporter = make_exporter() + order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None) + positions = [ + make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org", item="Main ticket"), + make_position(2, order, 2, attendee_name="Blair Example", attendee_email="blair@example.org", item="Addon", addon_to_id=1), + ] + + contexts = list(exporter._build_grouped_contexts(positions)) + + assert len(contexts) == 2 + assert [position.pk for position in contexts[0].positions] == [1] + assert [position.pk for position in contexts[1].positions] == [2] + + +def test_primary_position_prefers_non_addon_row(): + exporter = make_exporter() + order = SimpleNamespace(pk=10, event=SimpleNamespace(), invoice_address=None) + positions = [ + make_position(2, order, 2, attendee_name="Alex Example", attendee_email="alex@example.org", item="Addon", addon_to_id=1), + make_position(1, order, 1, attendee_name="Alex Example", attendee_email="alex@example.org", item="Main ticket"), + ] + + context = list(exporter._build_grouped_contexts(positions))[0] + + assert context.position.pk == 1 + + +def test_collapse_group_values_deduplicates_repeated_values(): + exporter = make_exporter() + + assert exporter._collapse_group_values(["Module A", "Module A", "", "Module B"]) == "Module A | Module B" + + +def test_field_order_reorders_selected_fields(): + exporter = make_exporter() + exporter.field_definitions = { + "event.name": FieldDefinition(label="Event", getter=lambda ctx: ""), + "order.email": FieldDefinition(label="Email", getter=lambda ctx: ""), + "position.attendee_name_cached": FieldDefinition(label="Name", getter=lambda ctx: ""), + } + + ordered = exporter._get_ordered_selected_keys( + ["event.name", "order.email", "position.attendee_name_cached"], + '["position.attendee_name_cached", "event.name"]', + ) + + assert ordered == ["position.attendee_name_cached", "event.name", "order.email"] + + +def test_field_order_ignores_unknown_duplicate_and_unselected_keys(): + exporter = make_exporter() + exporter.field_definitions = { + "event.name": FieldDefinition(label="Event", getter=lambda ctx: ""), + "order.email": FieldDefinition(label="Email", getter=lambda ctx: ""), + "position.attendee_name_cached": FieldDefinition(label="Name", getter=lambda ctx: ""), + } + + ordered = exporter._get_ordered_selected_keys( + ["event.name", "order.email"], + '["order.email", "missing", "order.email", "position.attendee_name_cached"]', + ) + + assert ordered == ["order.email", "event.name"] + + +def test_field_order_falls_back_to_selection_order_on_invalid_json(): + exporter = make_exporter() + exporter.field_definitions = { + "event.name": FieldDefinition(label="Event", getter=lambda ctx: ""), + "order.email": FieldDefinition(label="Email", getter=lambda ctx: ""), + } + + ordered = exporter._get_ordered_selected_keys( + ["event.name", "order.email"], + "not-json", + ) + + assert ordered == ["event.name", "order.email"] + + +def test_get_order_invoice_address_handles_missing_relation(): + exporter = make_exporter() + + class MissingInvoiceDescriptor: + class RelatedObjectDoesNotExist(Exception): + pass + + def __get__(self, instance, owner): + raise self.RelatedObjectDoesNotExist() + + class MissingInvoiceOrder: + invoice_address = MissingInvoiceDescriptor() + + assert exporter._get_order_invoice_address(MissingInvoiceOrder()) is None diff --git a/plugins/selective_export_plugin/tests/test_presets.py b/plugins/selective_export_plugin/tests/test_presets.py new file mode 100644 index 0000000..923d0ea --- /dev/null +++ b/plugins/selective_export_plugin/tests/test_presets.py @@ -0,0 +1,116 @@ +from types import SimpleNamespace + +from pretix_selective_export.presets import ( + SETTINGS_KEY, + get_presets, + normalize_presets, + save_preset, +) + + +class DummySettings: + def __init__(self, initial=None): + self._data = dict(initial or {}) + + def get(self, key, as_type=None, default=None): + return self._data.get(key, default) + + def set(self, key, value): + self._data[key] = value + + +def make_holder(initial=None): + return SimpleNamespace(settings=DummySettings(initial)) + + +def test_normalize_presets_discards_invalid_entries(): + normalized = normalize_presets( + { + " Useful ": { + "fields": [" order.code ", "order.code", "", 12], + "fieldOrder": ["position.attendee_name_cached", "position.attendee_name_cached"], + "events": [" TE1 ", ""], + }, + "": {"fields": ["ignored"]}, + "Other": "invalid", + } + ) + + assert normalized == { + "Useful": { + "fields": ["order.code", "12"], + "fieldOrder": ["position.attendee_name_cached"], + "events": ["TE1"], + }, + "Other": { + "fields": [], + "fieldOrder": [], + "events": [], + }, + } + + +def test_save_preset_persists_to_settings_holder(): + holder = make_holder() + + name, preset = save_preset( + holder, + " Shared preset ", + { + "fields": ["order.code", "position.attendee_name_cached"], + "fieldOrder": ["position.attendee_name_cached", "order.code"], + "events": ["TE1"], + }, + ) + + assert name == "Shared preset" + assert preset["fields"] == ["order.code", "position.attendee_name_cached"] + assert get_presets(holder) == { + "Shared preset": { + "fields": ["order.code", "position.attendee_name_cached"], + "fieldOrder": ["position.attendee_name_cached", "order.code"], + "events": ["TE1"], + } + } + assert holder.settings.get(SETTINGS_KEY)["Shared preset"]["events"] == ["TE1"] + + +def test_save_preset_overwrites_existing_name(): + holder = make_holder( + { + SETTINGS_KEY: { + "Shared preset": { + "fields": ["order.code"], + "fieldOrder": ["order.code"], + "events": [], + } + } + } + ) + + save_preset( + holder, + "Shared preset", + { + "fields": ["position.attendee_name_cached"], + "fieldOrder": ["position.attendee_name_cached"], + "events": ["TE1"], + }, + ) + + assert get_presets(holder)["Shared preset"] == { + "fields": ["position.attendee_name_cached"], + "fieldOrder": ["position.attendee_name_cached"], + "events": ["TE1"], + } + + +def test_save_preset_requires_non_blank_name(): + holder = make_holder() + + try: + save_preset(holder, " ", {"fields": []}) + except ValueError as exc: + assert str(exc) == "Preset name is required." + else: + raise AssertionError("Expected ValueError for blank preset name")