Compare commits

...
Author SHA1 Message Date
Mira Weller 9e2b856887 Support fake-required fields 2024-12-04 10:18:34 +01:00
Richard SchreiberandGitHub 4d94158ff0 Improve organizer/event-series calendar UI on mobile 2024-12-04 08:17:52 +01:00
Raphael Michel 8f92eb2d2d remove debug statement 2024-12-03 12:40:29 +01:00
Richard Schreiber f29896b267 [A11y] Fix missing aria-hidden and translation 2024-12-03 12:10:16 +01:00
2dc625cf31 Add the option to introduce rich-text placeholders (#4657)
* Add the option to introduce rich-text placeholders

* Add tests in test_format

* Add some css

* Block vs inline

* Some fixed css

* Update src/pretix/control/forms/event.py

Co-authored-by: Mira <weller@rami.io>

* Add missing docstring prat

---------

Co-authored-by: Mira <weller@rami.io>
2024-12-03 11:38:15 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
855226d37c Update ua-parser requirement from ==0.18.* to ==1.0.* (#4665)
Updates the requirements on [ua-parser](https://github.com/ua-parser/uap-python) to permit the latest version.
- [Release notes](https://github.com/ua-parser/uap-python/releases)
- [Commits](https://github.com/ua-parser/uap-python/compare/0.18.0...1.0.0)

---
updated-dependencies:
- dependency-name: ua-parser
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 11:18:37 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
648c0da9fe Update webauthn requirement from ==2.2.* to ==2.3.* (#4655)
Updates the requirements on [webauthn](https://github.com/duo-labs/py_webauthn) to permit the latest version.
- [Release notes](https://github.com/duo-labs/py_webauthn/releases)
- [Changelog](https://github.com/duo-labs/py_webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/duo-labs/py_webauthn/compare/v2.2.0...v2.3.0)

---
updated-dependencies:
- dependency-name: webauthn
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-03 11:01:05 +01:00
Raphael MichelandGitHub 59e3494fa2 Add fee type for late fees (#4656) 2024-12-03 11:00:11 +01:00
MiraandGitHub c4ff57c07a Change error message for unavailable addon products (#4673)
This can not only happen in case of sold-out addons, but also if they are e.g. not available via the current sales channel.
2024-12-03 10:59:15 +01:00
cc4fbfe4c7 API: Allow to block/unblock seats in bulk (#4668)
* API: Allow to block/unblock seats in bulk

* Update doc/api/resources/seats.rst

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Update doc/api/resources/seats.rst

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Update doc/api/resources/seats.rst

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Update src/pretix/api/views/event.py

Co-authored-by: Richard Schreiber <schreiber@rami.io>

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-12-02 16:03:11 +01:00
e99ee91573 Allow to use custom domains for some but not all events (Z#23153875) (#4627)
* Allow to use custom domains for some but not all events

* Update src/pretix/multidomain/urlreverse.py

* Apply suggestions from code review

Co-authored-by: Mira <weller@rami.io>

* Logging for domain config changes

---------

Co-authored-by: Mira <weller@rami.io>
2024-12-02 15:58:50 +01:00
Patrick ChiltonandRaphael Michel e2753686ee Translations: Update Hungarian
Currently translated at 10.8% (629 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/hu/

powered by weblate
2024-12-02 15:58:41 +01:00
CVZ-esandRaphael Michel 33f8b9851e Translations: Update Spanish
Currently translated at 100.0% (5782 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2024-12-02 15:58:41 +01:00
CVZ-esandRaphael Michel e3d8cf07af Translations: Update French
Currently translated at 100.0% (5782 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2024-12-02 15:58:41 +01:00
Mira Weller 0279ca7d94 Add missing error handling to addressform.js 2024-12-02 10:15:16 +01:00
Richard SchreiberandGitHub d1989c3cd3 Fix all-optional in address-form for resellers (#4672) 2024-12-02 09:46:33 +01:00
Raphael Michel 61cb2e15cf Fix validation crash of InvoiceNameForm 2024-11-29 20:08:36 +01:00
Mira Weller f2ee1d00b3 Don't use animation for address information load indicator 2024-11-29 17:09:14 +01:00
MiraandGitHub e8e9698a31 Update address field logic (Z#23163120) (#4659)
* Move country-dependent JS logic to separate file (avoids code duplication for presale and control)
* Correctly apply "required" attribute to address state field
* Load address format information when selecting country
* Fix some other bugs and inconsistencies
2024-11-29 14:56:56 +01:00
Richard SchreiberandGitHub a1bf7be244 [A11y] Improve customer account pages (#4654) 2024-11-29 14:16:40 +01:00
Patrick ChiltonandRaphael Michel f4ca9a5681 Translations: Update Hungarian
Currently translated at 45.6% (106 of 232 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/hu/

powered by weblate
2024-11-29 09:22:29 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e6d984538f Update django-statici18n requirement from ==2.5.* to ==2.6.* (#4664)
Updates the requirements on [django-statici18n](https://github.com/zyegfryed/django-statici18n) to permit the latest version.
- [Changelog](https://github.com/zyegfryed/django-statici18n/blob/main/docs/changelog.rst)
- [Commits](https://github.com/zyegfryed/django-statici18n/compare/v2.5.0...v2.6.0)

---
updated-dependencies:
- dependency-name: django-statici18n
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-29 09:22:04 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9f1ee9157f Update protobuf requirement from ==5.28.* to ==5.29.* (#4666)
Updates the requirements on [protobuf](https://github.com/protocolbuffers/protobuf) to permit the latest version.
- [Release notes](https://github.com/protocolbuffers/protobuf/releases)
- [Changelog](https://github.com/protocolbuffers/protobuf/blob/main/protobuf_release.bzl)
- [Commits](https://github.com/protocolbuffers/protobuf/compare/v5.28.0-rc1...v5.29.0)

---
updated-dependencies:
- dependency-name: protobuf
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-29 09:21:54 +01:00
Raphael Michel 242e5af4b5 Bump version to 2024.12.0.dev0 2024-11-27 13:57:03 +01:00
Raphael Michel 7d6e98e6da Bump version to 2024.11.0 2024-11-27 13:56:37 +01:00
MiraandGitHub 27f964f3ae Checkout flow: Observe direction when skipping AddOnsStep (#4658) 2024-11-27 11:13:07 +01:00
Patrick ChiltonandRaphael Michel 84b3060c0f Translations: Update Hungarian
Currently translated at 10.7% (623 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/hu/

powered by weblate
2024-11-27 11:10:55 +01:00
CVZ-esandRaphael Michel 25dcb72f92 Translations: Update Spanish
Currently translated at 100.0% (5782 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2024-11-27 11:10:55 +01:00
CVZ-esandRaphael Michel 4b078867c6 Translations: Update French
Currently translated at 100.0% (5782 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2024-11-27 11:10:55 +01:00
Jakub StribrnyandRaphael Michel c595a59d4a Translations: Update Czech
Currently translated at 73.5% (4250 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/cs/

powered by weblate
2024-11-26 18:48:58 +01:00
Patrick ChiltonandRaphael Michel f164daeaee Translations: Update Hungarian
Currently translated at 10.7% (621 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/hu/

powered by weblate
2024-11-26 18:48:58 +01:00
gabriblasandRaphael Michel c6b6dd8d49 Translations: Update Italian
Currently translated at 24.3% (1409 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/it/

powered by weblate
2024-11-26 18:48:58 +01:00
CVZ-esandRaphael Michel 8038c87963 Translations: Update French
Currently translated at 100.0% (5782 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2024-11-26 18:48:58 +01:00
RyoandRaphael Michel c45a970d32 Translations: Update Japanese
Currently translated at 3.8% (220 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/ja/

powered by weblate
2024-11-26 18:48:58 +01:00
kei oganeandRaphael Michel a34517233d Translations: Update Japanese
Currently translated at 3.8% (220 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/ja/

powered by weblate
2024-11-26 18:48:58 +01:00
Yasunobu YesNo KawaguchiandRaphael Michel 8fb2e5383c Translations: Update Japanese
Currently translated at 3.4% (200 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/ja/

powered by weblate
2024-11-26 18:48:58 +01:00
CVZ-esandRaphael Michel 86a00f3338 Translations: Update Spanish
Currently translated at 100.0% (5782 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2024-11-26 18:48:58 +01:00
CVZ-esandRaphael Michel c8c0d3e7f5 Translations: Update French
Currently translated at 100.0% (5782 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2024-11-26 18:48:58 +01:00
Raphael MichelandGitHub 7dd455ce15 Fix #4641 -- Make usage of argon2id optional (#4643) 2024-11-26 17:31:27 +01:00
Richard SchreiberandGitHub 391eda25da [A11y] Improve color combinations for alerts 2024-11-21 13:58:19 +01:00
Raphael Michel fcff5a522d Fix inconsistent labels 2024-11-19 16:36:09 +01:00
Raphael MichelandRaphael Michel 7e93d38a01 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5782 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de_Informal/

powered by weblate
2024-11-19 16:33:52 +01:00
Raphael MichelandRaphael Michel 6469381899 Translations: Update German
Currently translated at 100.0% (5782 of 5782 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2024-11-19 16:33:52 +01:00
Raphael Michel 761706c60c Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-11-19 16:16:36 +01:00
CVZ-esandRaphael Michel f91315c88e Translations: Update Spanish
Currently translated at 100.0% (5809 of 5809 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/es/

powered by weblate
2024-11-19 16:16:13 +01:00
CVZ-esandRaphael Michel bc05afeab9 Translations: Update French
Currently translated at 100.0% (5779 of 5779 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/fr/

powered by weblate
2024-11-19 16:16:13 +01:00
Raphael Michel 02d495d287 Revert "Update po files"
This reverts commit 894878d9da.
2024-11-19 16:15:55 +01:00
Raphael Michel 894878d9da Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-11-19 16:15:26 +01:00
5896ca0197 Event creation: Prevent accidentally creating events without tax rate (#4623)
* Event creation: Prevent accidentally creating events without tax rate

* Update src/pretix/control/forms/event.py

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Fix tests

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-11-19 16:14:56 +01:00
Raphael Michel fe6fc8df32 Fix placehodler sample in empty series (PRETIXEU-ATN) 2024-11-19 16:14:13 +01:00
dependabot[bot]andRaphael Michel 9de8f3a775 Update aiohttp requirement from ==3.10.* to ==3.11.*
Updates the requirements on [aiohttp](https://github.com/aio-libs/aiohttp) to permit the latest version.
- [Release notes](https://github.com/aio-libs/aiohttp/releases)
- [Changelog](https://github.com/aio-libs/aiohttp/blob/master/CHANGES.rst)
- [Commits](https://github.com/aio-libs/aiohttp/compare/v3.10.0b0...v3.11.0)

---
updated-dependencies:
- dependency-name: aiohttp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-19 15:08:00 +01:00
Martin Gross c92bb9cb8b Stripe: (FIX) Make MobilePay optional 2024-11-19 13:17:52 +01:00
Raphael MichelandGitHub 76ecec8b98 Scheduled mails: Allow subevent-dependent placeholders (Z#23171818) (#4629) 2024-11-19 10:50:10 +01:00
MiraandGitHub 4b8416df8f docs: update nginx config example (#4640) 2024-11-19 09:28:01 +01:00
135 changed files with 16688 additions and 14912 deletions
+5
View File
@@ -288,6 +288,7 @@ Example::
[django]
secret=j1kjps5a5&4ilpn912s7a1!e2h!duz^i3&idu@_907s$wrz@x-
debug=off
passwords_argon2=on
``secret``
The secret to be used by Django for signing and verification purposes. If this
@@ -303,6 +304,10 @@ Example::
.. WARNING:: Never set this to ``True`` in production!
``passwords_argon``
Use the ``argon2`` algorithm for password hashing. Disable on systems with a small number of CPU cores (currently
less than 8).
``profile``
Enable code profiling for a random subset of requests. Disabled by default, see
:ref:`perf-monitoring` for details.
+2 -3
View File
@@ -231,11 +231,10 @@ The following snippet is an example on how to configure a nginx proxy for pretix
}
}
server {
listen 443 default_server;
listen [::]:443 ipv6only=on default_server;
listen 443 ssl default_server;
listen [::]:443 ipv6only=on ssl default_server;
server_name pretix.mydomain.com;
ssl on;
ssl_certificate /path/to/cert.chain.pem;
ssl_certificate_key /path/to/key.pem;
+2 -3
View File
@@ -216,11 +216,10 @@ The following snippet is an example on how to configure a nginx proxy for pretix
}
}
server {
listen 443 default_server;
listen [::]:443 ipv6only=on default_server;
listen 443 ssl default_server;
listen [::]:443 ipv6only=on ssl default_server;
server_name pretix.mydomain.com;
ssl on;
ssl_certificate /path/to/cert.chain.pem;
ssl_certificate_key /path/to/key.pem;
+112 -1
View File
@@ -249,7 +249,7 @@ Endpoints
"orderposition": null,
"cartposition": null,
"voucher": null
},
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
@@ -260,3 +260,114 @@ Endpoints
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
:statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/seats/bulk_block/
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/bulk_block/
Set the ``blocked`` attribute to ``true`` for a large number of seats at once.
You can pass either a list of ``id`` values or a list of ``seat_guid`` values.
You can pass up to 10,000 seats in one request.
The endpoint will return an error if you pass a seat ID that does not exist.
However, it will not return an error if one of the passed seats is already blocked or sold.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_block/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"ids": [12, 45, 56]
}
or
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_block/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"seat_guids": ["6c0e29e5-05d6-421f-99f3-afd01478ecad", "c2899340-e2e7-4d05-8100-000a4b6d7cf4"]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param subevent_id: The ``id`` field of the subevent to modify
:statuscode 200: no error
:statuscode 400: The seat could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
:statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/seats/bulk_unblock/
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/subevents/(id)/seats/bulk_unblock/
Set the ``blocked`` attribute to ``false`` for a large number of seats at once.
You can pass either a list of ``id`` values or a list of ``seat_guid`` values.
You can pass up to 10,000 seats in one request.
The endpoint will return an error if you pass a seat ID that does not exist.
However, it will not return an error if one of the passed seat is already unblocked or is sold.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_unblock/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"ids": [12, 45, 56]
}
or
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/seats/bulk_unblock/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"seat_guids": ["6c0e29e5-05d6-421f-99f3-afd01478ecad", "c2899340-e2e7-4d05-8100-000a4b6d7cf4"]
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param subevent_id: The ``id`` field of the subevent to modify
:statuscode 200: no error
:statuscode 400: The seat could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer or event does not exist **or** you have no permission to change this resource.
:statuscode 404: Seat does not exist; or the endpoint without subevent id was used for event with subevents, or vice versa.
+5 -5
View File
@@ -53,7 +53,7 @@ dependencies = [
"django-phonenumber-field==7.3.*",
"django-redis==5.4.*",
"django-scopes==2.0.*",
"django-statici18n==2.5.*",
"django-statici18n==2.6.*",
"djangorestframework==3.15.*",
"dnspython==2.7.*",
"drf_ujson2==1.7.*",
@@ -76,7 +76,7 @@ dependencies = [
"phonenumberslite==8.13.*",
"Pillow==11.0.*",
"pretix-plugin-build",
"protobuf==5.28.*",
"protobuf==5.29.*",
"psycopg2-binary",
"pycountry",
"pycparser==2.22",
@@ -97,17 +97,17 @@ dependencies = [
"text-unidecode==1.*",
"tlds>=2020041600",
"tqdm==4.*",
"ua-parser==0.18.*",
"ua-parser==1.0.*",
"vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*",
"webauthn==2.2.*",
"webauthn==2.3.*",
"zeep==4.3.*"
]
[project.optional-dependencies]
memcached = ["pylibmc"]
dev = [
"aiohttp==3.10.*",
"aiohttp==3.11.*",
"coverage",
"coveralls",
"fakeredis==2.26.*",
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2024.11.0.dev0"
__version__ = "2024.12.0.dev0"
+34
View File
@@ -989,6 +989,40 @@ def prefetch_by_id(items, qs, id_attr, target_attr):
setattr(item, target_attr, result.get(getattr(item, id_attr)))
class SeatBulkBlockInputSerializer(serializers.Serializer):
ids = serializers.ListField(child=serializers.IntegerField(), required=False, allow_empty=True)
seat_guids = serializers.ListField(child=serializers.CharField(), required=False, allow_empty=True)
def to_internal_value(self, data):
data = super().to_internal_value(data)
if data.get("seat_guids") and data.get("ids"):
raise ValidationError("Please pass either seat_guids or ids.")
if data.get("seat_guids"):
seat_ids = data["seat_guids"]
if len(seat_ids) > 10000:
raise ValidationError({"seat_guids": ["Please do not pass over 10000 seats."]})
seats = {s.seat_guid: s for s in self.context["queryset"].filter(seat_guid__in=seat_ids)}
for s in seat_ids:
if s not in seats:
raise ValidationError({"seat_guids": [f"The seat '{s}' does not exist."]})
elif data.get("ids"):
seat_ids = data["ids"]
if len(seat_ids) > 10000:
raise ValidationError({"ids": ["Please do not pass over 10000 seats."]})
seats = self.context["queryset"].in_bulk(seat_ids)
for s in seat_ids:
if s not in seats:
raise ValidationError({"ids": [f"The seat '{s}' does not exist."]})
else:
raise ValidationError("Please pass either seat_guids or ids.")
return {"seats": seats.values()}
class SeatSerializer(I18nAwareModelSerializer):
orderposition = serializers.IntegerField(source='orderposition_id')
cartposition = serializers.IntegerField(source='cartposition_id')
+27 -4
View File
@@ -40,6 +40,7 @@ from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import serializers, views, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import (
NotFound, PermissionDenied, ValidationError,
)
@@ -50,8 +51,9 @@ from pretix.api.auth.permission import EventCRUDPermission
from pretix.api.pagination import TotalOrderingFilter
from pretix.api.serializers.event import (
CloneEventSerializer, DeviceEventSettingsSerializer, EventSerializer,
EventSettingsSerializer, ItemMetaPropertiesSerializer, SeatSerializer,
SubEventSerializer, TaxRuleSerializer,
EventSettingsSerializer, ItemMetaPropertiesSerializer,
SeatBulkBlockInputSerializer, SeatSerializer, SubEventSerializer,
TaxRuleSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import (
@@ -237,9 +239,9 @@ class EventViewSet(viewsets.ModelViewSet):
disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value}
changed = merge_dicts(enabled, disabled)
for module, action in changed.items():
for module, operation in changed.items():
serializer.instance.log_action(
'pretix.event.plugins.' + action,
'pretix.event.plugins.' + operation,
user=self.request.user,
auth=self.request.auth,
data={'plugin': module}
@@ -744,3 +746,24 @@ class SeatViewSet(ConditionalListView, viewsets.ModelViewSet):
auth=self.request.auth,
data={"seats": [serializer.instance.pk]},
)
def bulk_change_blocked(self, blocked):
s = SeatBulkBlockInputSerializer(
data=self.request.data,
context={"event": self.request.event, "queryset": self.get_queryset()},
)
s.is_valid(raise_exception=True)
seats = s.validated_data["seats"]
for seat in seats:
seat.blocked = blocked
Seat.objects.bulk_update(seats, ["blocked"], batch_size=1000)
return Response({})
@action(methods=["POST"], detail=False)
def bulk_block(self, request, *args, **kwargs):
return self.bulk_change_blocked(True)
@action(methods=["POST"], detail=False)
def bulk_unblock(self, request, *args, **kwargs):
return self.bulk_change_blocked(False)
+6 -2
View File
@@ -35,6 +35,7 @@ from django.utils.translation import get_language, gettext_lazy as _
from pretix.base.models import Event
from pretix.base.signals import register_html_mail_renderers
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.helpers.format import SafeFormatter, format_map
from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext
@@ -79,7 +80,7 @@ class BaseHTMLMailRenderer:
return self.identifier
def render(self, plain_body: str, plain_signature: str, subject: str, order=None,
position=None) -> str:
position=None, context=None) -> str:
"""
This method should generate the HTML part of the email.
@@ -88,6 +89,7 @@ class BaseHTMLMailRenderer:
:param subject: The email subject.
:param order: The order if this email is connected to one, otherwise ``None``.
:param position: The order position if this email is connected to one, otherwise ``None``.
:param context: Context to use to render placeholders in the plain body
:return: An HTML string
"""
raise NotImplementedError()
@@ -134,8 +136,10 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def compile_markdown(self, plaintext):
return markdown_compile_email(plaintext)
def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str:
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
body_md = self.compile_markdown(plain_body)
if context:
body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
+21 -10
View File
@@ -54,6 +54,7 @@ from django.core.validators import (
from django.db.models import QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.urls import reverse
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
@@ -77,7 +78,7 @@ from pretix.base.i18n import (
get_babel_locale, get_language_without_region, language,
)
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
from pretix.base.models.tax import VAT_ID_COUNTRIES, ask_for_vat_id
from pretix.base.models.tax import ask_for_vat_id
from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, validate_vat_id,
)
@@ -602,6 +603,7 @@ class BaseQuestionsForm(forms.Form):
questions = pos.item.questions_to_ask
event = kwargs.pop('event')
self.all_optional = kwargs.pop('all_optional', False)
self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional
super().__init__(*args, **kwargs)
@@ -676,7 +678,7 @@ class BaseQuestionsForm(forms.Form):
if item.ask_attendee_data and event.settings.attendee_addresses_asked:
add_fields['street'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
required=self.attendee_addresses_required,
label=_('Address'),
widget=forms.Textarea(attrs={
'rows': 2,
@@ -686,7 +688,7 @@ class BaseQuestionsForm(forms.Form):
initial=(cartpos.street if cartpos else orderpos.street),
)
add_fields['zipcode'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
required=False,
max_length=30,
label=_('ZIP code'),
initial=(cartpos.zipcode if cartpos else orderpos.zipcode),
@@ -695,7 +697,7 @@ class BaseQuestionsForm(forms.Form):
}),
)
add_fields['city'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
required=False,
label=_('City'),
max_length=255,
initial=(cartpos.city if cartpos else orderpos.city),
@@ -707,11 +709,12 @@ class BaseQuestionsForm(forms.Form):
add_fields['country'] = CountryField(
countries=CachedCountries
).formfield(
required=event.settings.attendee_addresses_required and not self.all_optional,
required=self.attendee_addresses_required,
label=_('Country'),
initial=country,
widget=forms.Select(attrs={
'autocomplete': 'country',
'data-country-information-url': reverse('js_helpers.states'),
}),
)
c = [('', pgettext_lazy('address', 'Select state'))]
@@ -946,9 +949,9 @@ class BaseQuestionsForm(forms.Form):
d = super().clean()
if self.address_validation:
self.cleaned_data = d = validate_address(d, True)
self.cleaned_data = d = validate_address(d, all_optional=not self.attendee_addresses_required)
if d.get('city') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if d.get('street') and d.get('country') and str(d['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not d.get('state'):
self.add_error('state', _('This field is required.'))
@@ -1005,7 +1008,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'street': forms.Textarea(attrs={
'rows': 2,
'placeholder': _('Street and Number'),
'autocomplete': 'street-address'
'autocomplete': 'street-address',
}),
'beneficiary': forms.Textarea(attrs={'rows': 3}),
'country': forms.Select(attrs={
@@ -1021,7 +1024,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'data-display-dependency': '#id_is_business_1',
'autocomplete': 'organization',
}),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-with-vat-id': ','.join(VAT_ID_COUNTRIES)}),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
'internal_reference': forms.TextInput,
}
labels = {
@@ -1055,6 +1058,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
])
self.fields['country'].choices = CachedCountries()
self.fields['country'].widget.attrs['data-country-information-url'] = reverse('js_helpers.states')
c = [('', pgettext_lazy('address', 'Select state'))]
fprefix = self.prefix + '-' if self.prefix else ''
@@ -1083,6 +1087,10 @@ class BaseInvoiceAddressForm(forms.ModelForm):
)
self.fields['state'].widget.is_required = True
self.fields['street'].required = False
self.fields['zipcode'].required = False
self.fields['city'].required = False
# Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected.
if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data:
self.data = self.data.copy()
@@ -1135,6 +1143,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
validate_address # local import to prevent impact on startup time
data = self.cleaned_data
if not data.get('is_business'):
data['company'] = ''
data['vat_id'] = ''
@@ -1142,9 +1151,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
data['vat_id'] = ''
if self.event.settings.invoice_address_required:
if data.get('is_business') and not data.get('company'):
raise ValidationError(_('You need to provide a company name.'))
raise ValidationError({"company": _('You need to provide a company name.')})
if not data.get('is_business') and not data.get('name_parts'):
raise ValidationError(_('You need to provide your name.'))
if not self.all_optional and 'street' in self.fields and not data.get('street') and not data.get('zipcode') and not data.get('city'):
raise ValidationError({"street": _('This field is required.')})
if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False
@@ -9,6 +9,7 @@ from decimal import Decimal
import django.core.validators
import django.db.models.deletion
import i18nfield.fields
from argon2.exceptions import HashingError
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.db import migrations, models
@@ -25,7 +26,14 @@ def initial_user(apps, schema_editor):
user = User(email='admin@localhost')
user.is_staff = True
user.is_superuser = True
user.password = make_password('admin')
try:
user.password = make_password('admin')
except HashingError:
raise Exception(
"Could not hash password of initial user with argon2id. If this is a system with less than 8 CPU cores, "
"you might need to disable argon2id by setting `passwords_argon2=off` in the `[django]` section of the "
"pretix.cfg configuration file."
)
user.save()
+3
View File
@@ -823,6 +823,9 @@ class Event(EventMixin, LoggedModel):
self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
if hasattr(other, 'alternative_domain_assignment'):
other.alternative_domain_assignment.domain.event_assignments.create(event=self)
if not self.all_sales_channels:
self.limit_sales_channels.set(
self.organizer.sales_channels.filter(
+14
View File
@@ -159,10 +159,24 @@ class Membership(models.Model):
de = date_format(self.date_end, 'SHORT_DATE_FORMAT')
return f'{self.membership_type.name}: {self.attendee_name} ({ds} {de})'
@property
def percentage_used(self):
if self.membership_type.max_usages and self.usages:
return int(self.usages / self.membership_type.max_usages * 100)
return 0
@property
def attendee_name(self):
return build_name(self.attendee_name_parts, fallback_scheme=lambda: self.customer.organizer.settings.name_scheme)
@property
def expired(self):
return time_machine_now() > self.date_end
@property
def not_yet_valid(self):
return time_machine_now() < self.date_start
def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False):
if valid_from_not_chosen:
return not self.canceled and self.date_end >= time_machine_now()
+5 -3
View File
@@ -2275,6 +2275,7 @@ class OrderFee(models.Model):
FEE_TYPE_SERVICE = "service"
FEE_TYPE_CANCELLATION = "cancellation"
FEE_TYPE_INSURANCE = "insurance"
FEE_TYPE_LATE = "late"
FEE_TYPE_OTHER = "other"
FEE_TYPE_GIFTCARD = "giftcard"
FEE_TYPES = (
@@ -2283,6 +2284,7 @@ class OrderFee(models.Model):
(FEE_TYPE_SERVICE, _("Service fee")),
(FEE_TYPE_CANCELLATION, _("Cancellation fee")),
(FEE_TYPE_INSURANCE, _("Insurance fee")),
(FEE_TYPE_LATE, _("Late fee")),
(FEE_TYPE_OTHER, _("Other fees")),
(FEE_TYPE_GIFTCARD, _("Gift card")),
)
@@ -3204,9 +3206,9 @@ class InvoiceAddress(models.Model):
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
name_cached = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)
name_parts = models.JSONField(default=dict)
street = models.TextField(verbose_name=_('Address'), blank=False)
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=False)
city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
street = models.TextField(verbose_name=_('Address'), blank=True)
zipcode = models.CharField(max_length=30, verbose_name=_('ZIP code'), blank=True)
city = models.CharField(max_length=255, verbose_name=_('City'), blank=True)
country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'),
countries=CachedCountries)
+10 -2
View File
@@ -76,7 +76,7 @@ from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter, global_email_filter
from pretix.celery_app import app
from pretix.helpers.format import format_map
from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_private_icals
@@ -311,7 +311,13 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
try:
if plain_text_only:
body_html = None
elif 'context' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, raw_subject, order, position, context)
elif 'position' in inspect.signature(renderer.render).parameters:
# Backwards compatibility
warnings.warn('Email renderer called without context argument because context argument is not '
'supported.',
DeprecationWarning)
body_html = renderer.render(content_plain, signature, raw_subject, order, position)
else:
# Backwards compatibility
@@ -323,6 +329,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
logger.exception('Could not render HTML body')
body_html = None
body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
send_task = mail_send_task.si(
to=[email] if isinstance(email, str) else list(email),
cc=cc,
@@ -655,7 +663,7 @@ def render_mail(template, context):
if isinstance(template, LazyI18nString):
body = str(template)
if context:
body = format_map(body, context)
body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH)
else:
tpl = get_template(template)
body = tpl.render(context)
+174 -5
View File
@@ -26,6 +26,7 @@ from decimal import Decimal
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -39,7 +40,8 @@ from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
from pretix.base.signals import (
register_mail_placeholders, register_text_placeholders,
)
from pretix.helpers.format import SafeFormatter
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.helpers.format import PlainHtmlAlternativeString, SafeFormatter
logger = logging.getLogger('pretix.base.services.placeholders')
@@ -107,6 +109,91 @@ class SimpleFunctionalTextPlaceholder(BaseTextPlaceholder):
return self._sample
class BaseRichTextPlaceholder(BaseTextPlaceholder):
"""
This is the base class for all placeholders which can render either to plain text
or to a rich HTML element.
"""
def __init__(self, identifier, args):
self._identifier = identifier
self._args = args
@property
def identifier(self):
return self._identifier
@property
def required_context(self):
return self._args
@property
def is_block(self):
return False
def render(self, context):
return PlainHtmlAlternativeString(
self.render_plain(**{k: context[k] for k in self._args}),
self.render_html(**{k: context[k] for k in self._args}),
self.is_block,
)
def render_html(self, **kwargs):
"""
HTML rendering of the placeholder. Should return "safe" HTML, i.e. everything needs to be
escaped.
"""
raise NotImplementedError
def render_plain(self, **kwargs):
"""
Plain text rendering of the placeholder.
"""
raise NotImplementedError
def render_sample(self, event):
return PlainHtmlAlternativeString(
self.render_sample_plain(event=event),
self.render_sample_html(event=event),
self.is_block,
)
def render_sample_html(self, event):
raise NotImplementedError
def render_sample_plain(self, event):
raise NotImplementedError
class SimpleButtonPlaceholder(BaseRichTextPlaceholder):
def __init__(self, identifier, args, url_func, text_func, sample_url_func, sample_text_func):
super().__init__(identifier, args)
self._url_func = url_func
self._text_func = text_func
self._sample_url_func = sample_url_func
self._sample_text_func = sample_text_func
def render_html(self, **context):
text = self._text_func(**{k: context[k] for k in self._args})
url = self._url_func(**{k: context[k] for k in self._args})
return f'<a href="{url}" class="button">{escape(text)}</a>'
def render_plain(self, **context):
text = self._text_func(**{k: context[k] for k in self._args})
url = self._url_func(**{k: context[k] for k in self._args})
return f'{text}: {url}'
def render_sample_html(self, event):
text = self._sample_text_func(event)
url = self._sample_url_func(event)
return f'<a href="{url}" class="button">{escape(text)}</a>'
def render_sample_plain(self, event):
text = self._sample_text_func(event)
url = self._sample_url_func(event)
return f'{text}: {url}'
class PlaceholderContext(SafeFormatter):
"""
Holds the contextual arguments and corresponding list of available placeholders for formatting
@@ -209,13 +296,24 @@ def get_best_name(position_or_address, parts=False):
def base_placeholders(sender, **kwargs):
from pretix.multidomain.urlreverse import build_absolute_uri
def _event_sample(event):
if event.has_subevents:
se = event.subevents.first()
if se:
return se.name
return event.name
ph = [
SimpleFunctionalTextPlaceholder(
'event', ['event'], lambda event: event.name, lambda event: event.name
),
SimpleFunctionalTextPlaceholder(
'event', ['event_or_subevent'], lambda event_or_subevent: event_or_subevent.name,
lambda event_or_subevent: event_or_subevent.name
_event_sample,
),
SimpleFunctionalTextPlaceholder(
'event_series_name', ['event', 'event_or_subevent'], lambda event, event_or_subevent: event.name,
lambda event: event.name
),
SimpleFunctionalTextPlaceholder(
'event_slug', ['event'], lambda event: event.slug, lambda event: event.slug
@@ -273,6 +371,27 @@ def base_placeholders(sender, **kwargs):
}
),
),
SimpleButtonPlaceholder(
'url_button', ['order', 'event'],
url_func=lambda order, event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_secret()
}
),
text_func=lambda order, event: _("View order details"),
sample_url_func=lambda event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'hash': '98kusd8ofsj8dnkd'
}
),
sample_text_func=lambda event: _("View order details"),
),
SimpleFunctionalTextPlaceholder(
'url_info_change', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
@@ -337,6 +456,27 @@ def base_placeholders(sender, **kwargs):
}
),
),
SimpleButtonPlaceholder(
'url_button', ['event', 'position'],
url_func=lambda event, position: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': position.order.code,
'secret': position.web_secret,
'position': position.positionid
}
),
text_func=lambda event, position: _("View registration details"),
sample_url_func=lambda event: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'position': '123'
}
),
sample_text_func=lambda event: _("View registration details"),
),
SimpleFunctionalTextPlaceholder(
'url_info_change', ['position', 'event'], lambda position, event: build_absolute_uri(
event,
@@ -592,8 +732,8 @@ def base_placeholders(sender, **kwargs):
class FormPlaceholderMixin:
def _set_field_placeholders(self, fn, base_parameters):
placeholders = get_available_placeholders(self.event, base_parameters)
def _set_field_placeholders(self, fn, base_parameters, rich=False):
placeholders = get_available_placeholders(self.event, base_parameters, rich=rich)
ht = format_placeholders_help_text(placeholders, self.event)
if self.fields[fn].help_text:
self.fields[fn].help_text += ' ' + str(ht)
@@ -604,7 +744,7 @@ class FormPlaceholderMixin:
)
def get_available_placeholders(event, base_parameters):
def get_available_placeholders(event, base_parameters, rich=False):
if 'order' in base_parameters:
base_parameters.append('invoice_address')
base_parameters.append('position_or_address')
@@ -613,6 +753,35 @@ def get_available_placeholders(event, base_parameters):
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
if isinstance(v, BaseRichTextPlaceholder) and not rich:
continue
if all(rp in base_parameters for rp in v.required_context):
params[v.identifier] = v
return params
def get_sample_context(event, context_parameters, rich=True):
context_dict = {}
lbl = _('This value will be replaced based on dynamic parameters.')
for k, v in get_available_placeholders(event, context_parameters, rich=rich).items():
sample = v.render_sample(event)
if isinstance(sample, PlainHtmlAlternativeString):
context_dict[k] = PlainHtmlAlternativeString(
sample.plain,
'<{el} class="placeholder placeholder-html" title="{title}">{html}</{el}>'.format(
el='div' if sample.is_block else 'span',
title=lbl,
html=sample.html,
)
)
elif str(sample).strip().startswith('* ') or str(sample).startswith(' '):
context_dict[k] = '<div class="placeholder" title="{}">{}</div>'.format(
lbl,
markdown_compile_email(str(sample))
)
else:
context_dict[k] = '<span class="placeholder" title="{}">{}</span>'.format(
lbl,
escape(sample)
)
return context_dict
@@ -131,6 +131,9 @@
text-align: left;
padding: 0;
}
.content table td.align-right {
text-align: right;
}
a.button {
display: inline-block;
@@ -178,6 +181,9 @@
pre, pre code {
white-space: pre-line;
}
.text-right, .content table td.text-right {
text-align: right;
}
{% if rtl %}
body {
@@ -186,6 +192,9 @@
.content {
text-align: right;
}
.text-right, .content table td.text-right {
text-align: left;
}
{% endif %}
{% block addcss %}{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django import template
from django.utils.html import format_html
register = template.Library()
@register.simple_tag
def icon(key, *args, **kwargs):
return format_html(
'<span class="fa fa-{} {}" aria-hidden="true"></span>',
key,
kwargs["class"] if "class" in kwargs else "",
)
@@ -305,6 +305,7 @@ def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes
source,
extensions=[
'markdown.extensions.sane_lists',
'markdown.extensions.tables',
EmailNl2BrExtension(),
LinkifyAndCleanExtension(
linker,
@@ -0,0 +1,42 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django import template
from django.utils.html import format_html, mark_safe
register = template.Library()
@register.simple_tag
def textbubble(type, *args, **kwargs):
return format_html(
'<span class="textbubble-{}">{}',
type or "info",
"" if "icon" not in kwargs else format_html(
'<i class="fa fa-{}" aria-hidden="true"></i> ',
kwargs["icon"]
)
)
@register.simple_tag
def endtextbubble():
return mark_safe('</span>')
+19 -5
View File
@@ -22,16 +22,30 @@
import pycountry
from django.http import JsonResponse
from pretix.base.addressvalidation import (
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED,
)
from pretix.base.models.tax import VAT_ID_COUNTRIES
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
def states(request):
cc = request.GET.get("country", "DE")
info = {
'street': {'required': True},
'zipcode': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED},
'city': {'required': cc in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED},
'state': {'visible': cc in COUNTRIES_WITH_STATE_IN_ADDRESS, 'required': cc in COUNTRIES_WITH_STATE_IN_ADDRESS},
'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False},
}
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
return JsonResponse({'data': []})
return JsonResponse({'data': [], **info, })
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
return JsonResponse({'data': [
{'name': s.name, 'code': s.code[3:]}
for s in sorted(statelist, key=lambda s: s.name)
]})
return JsonResponse({
'data': [
{'name': s.name, 'code': s.code[3:]}
for s in sorted(statelist, key=lambda s: s.name)
],
**info,
})
+52 -39
View File
@@ -35,7 +35,7 @@
# License for the specific language governing permissions and limitations under the License.
from decimal import Decimal
from urllib.parse import urlencode, urlparse
from urllib.parse import urlencode
from zoneinfo import ZoneInfo
import pycountry
@@ -76,8 +76,10 @@ from pretix.control.forms import (
)
from pretix.control.forms.widgets import Select2
from pretix.helpers.countries import CachedCountries
from pretix.multidomain.models import KnownDomain
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.multidomain.models import AlternativeDomainAssignment, KnownDomain
from pretix.multidomain.urlreverse import (
build_absolute_uri, get_organizer_domain,
)
from pretix.plugins.banktransfer.payment import BankTransfer
from pretix.presale.style import get_fonts
@@ -136,6 +138,11 @@ class EventWizardBasicsForm(I18nModelForm):
choices=settings.LANGUAGES,
label=_("Default language"),
)
no_taxes = forms.BooleanField(
label=_("I don't want to specify taxes now"),
help_text=_("You can always configure tax rates later."),
required=False,
)
tax_rate = forms.DecimalField(
label=_("Sales tax rate"),
help_text=_("Do you need to pay sales tax on your tickets? In this case, please enter the applicable tax rate "
@@ -223,6 +230,11 @@ class EventWizardBasicsForm(I18nModelForm):
raise ValidationError({
'timezone': _('Your default locale must be specified.')
})
if not data.get("no_taxes") and not data.get("tax_rate"):
raise ValidationError({
'tax_rate': _('You have not specified a tax rate. If you do not want us to compute sales taxes, please '
'check "{field}" above.').format(field=self.fields["no_taxes"].label)
})
# change timezone
zone = ZoneInfo(data.get('timezone'))
@@ -353,14 +365,9 @@ class EventUpdateForm(I18nModelForm):
def __init__(self, *args, **kwargs):
self.change_slug = kwargs.pop('change_slug', False)
self.domain = kwargs.pop('domain', False)
kwargs.setdefault('initial', {})
self.instance = kwargs['instance']
if self.domain and self.instance:
initial_domain = self.instance.domains.first()
if initial_domain:
kwargs['initial'].setdefault('domain', initial_domain.domainname)
super().__init__(*args, **kwargs)
if not self.change_slug:
@@ -369,48 +376,54 @@ class EventUpdateForm(I18nModelForm):
self.fields['location'].widget.attrs['placeholder'] = _(
'Sample Conference Center\nHeidelberg, Germany'
)
if self.domain:
try:
self.fields['domain'] = forms.CharField(
max_length=255,
label=_('Custom domain'),
label=_('Domain'),
initial=self.instance.domain.domainname,
required=False,
disabled=True,
help_text=_('You can configure this in your organizer settings.')
)
except KnownDomain.DoesNotExist:
domain = get_organizer_domain(self.instance.organizer)
try:
current_domain_assignment = self.instance.alternative_domain_assignment
except AlternativeDomainAssignment.DoesNotExist:
current_domain_assignment = None
self.fields['domain'] = forms.ChoiceField(
label=_('Domain'),
help_text=_('You can add more domains in your organizer account.'),
choices=[('', _('Same as organizer account') + (f" ({domain})" if domain else ""))] + [
(d.domainname, d.domainname) for d in self.instance.organizer.domains.filter(mode=KnownDomain.MODE_ORG_ALT_DOMAIN)
],
initial=current_domain_assignment.domain_id if current_domain_assignment else "",
required=False,
help_text=_('You need to configure the custom domain in the webserver beforehand.')
)
self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all()
self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={
'data-inverse-dependency': '<[name$=all_sales_channels]',
}, choices=self.fields['limit_sales_channels'].widget.choices)
def clean_domain(self):
d = self.cleaned_data['domain']
if d:
if d == urlparse(settings.SITE_URL).hostname:
raise ValidationError(
_('You cannot choose the base domain of this installation.')
)
if KnownDomain.objects.filter(domainname=d).exclude(event=self.instance.pk).exists():
raise ValidationError(
_('This domain is already in use for a different event or organizer.')
)
return d
def save(self, commit=True):
instance = super().save(commit)
if self.domain:
current_domain = instance.domains.first()
if self.cleaned_data['domain']:
if current_domain and current_domain.domainname != self.cleaned_data['domain']:
current_domain.delete()
KnownDomain.objects.create(
organizer=instance.organizer, event=instance, domainname=self.cleaned_data['domain']
)
elif not current_domain:
KnownDomain.objects.create(
organizer=instance.organizer, event=instance, domainname=self.cleaned_data['domain']
)
elif current_domain:
current_domain.delete()
try:
current_domain_assignment = instance.alternative_domain_assignment
except AlternativeDomainAssignment.DoesNotExist:
current_domain_assignment = None
if self.cleaned_data['domain'] and not hasattr(instance, 'domain'):
domain = self.instance.organizer.domains.get(mode=KnownDomain.MODE_ORG_ALT_DOMAIN, domainname=self.cleaned_data["domain"])
AlternativeDomainAssignment.objects.update_or_create(
event=instance,
defaults={
"domain": domain,
}
)
instance.cache.clear()
elif current_domain_assignment:
current_domain_assignment.delete()
instance.cache.clear()
return instance
@@ -1372,7 +1385,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
self.event.meta_values_cached = self.event.meta_values.select_related('property').all()
for k, v in self.base_context.items():
self._set_field_placeholders(k, v)
self._set_field_placeholders(k, v, rich=k.startswith('mail_text_'))
for k, v in list(self.fields.items()):
if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
+87 -42
View File
@@ -133,63 +133,108 @@ class OrganizerDeleteForm(forms.Form):
class OrganizerUpdateForm(OrganizerForm):
def __init__(self, *args, **kwargs):
self.domain = kwargs.pop('domain', False)
self.change_slug = kwargs.pop('change_slug', False)
kwargs.setdefault('initial', {})
self.instance = kwargs['instance']
if self.domain and self.instance:
initial_domain = self.instance.domains.filter(event__isnull=True).first()
if initial_domain:
kwargs['initial'].setdefault('domain', initial_domain.domainname)
super().__init__(*args, **kwargs)
if not self.change_slug:
self.fields['slug'].widget.attrs['readonly'] = 'readonly'
if self.domain:
self.fields['domain'] = forms.CharField(
max_length=255,
label=_('Custom domain'),
required=False,
help_text=_('You need to configure the custom domain in the webserver beforehand.')
)
def clean_domain(self):
d = self.cleaned_data['domain']
if d:
if d == urlparse(settings.SITE_URL).hostname:
raise ValidationError(
_('You cannot choose the base domain of this installation.')
)
if KnownDomain.objects.filter(domainname=d).exclude(organizer=self.instance.pk,
event__isnull=True).exists():
raise ValidationError(
_('This domain is already in use for a different event or organizer.')
)
return d
def clean_slug(self):
if self.change_slug:
return self.cleaned_data['slug']
return self.instance.slug
def save(self, commit=True):
instance = super().save(commit)
if self.domain:
current_domain = instance.domains.filter(event__isnull=True).first()
if self.cleaned_data['domain']:
if current_domain and current_domain.domainname != self.cleaned_data['domain']:
current_domain.delete()
KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain'])
elif not current_domain:
KnownDomain.objects.create(organizer=instance, domainname=self.cleaned_data['domain'])
elif current_domain:
current_domain.delete()
instance.cache.clear()
for ev in instance.events.all():
ev.cache.clear()
class KnownDomainForm(forms.ModelForm):
class Meta:
model = KnownDomain
fields = ["domainname", "mode", "event"]
field_classes = {
"event": SafeModelChoiceField,
}
return instance
def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
self.fields["event"].queryset = self.organizer.events.all()
if self.instance and self.instance.pk:
self.fields["domainname"].widget.attrs['readonly'] = 'readonly'
def clean_domainname(self):
if self.instance and self.instance.pk:
return self.instance.domainname
d = self.cleaned_data['domainname']
if d:
if d == urlparse(settings.SITE_URL).hostname:
raise ValidationError(
_('You cannot choose the base domain of this installation.')
)
if KnownDomain.objects.filter(domainname=d).exclude(organizer=self.instance.organizer).exists():
raise ValidationError(
_('This domain is already in use for a different event or organizer.')
)
return d
def clean(self):
d = super().clean()
if d["mode"] == KnownDomain.MODE_ORG_DOMAIN and d["event"]:
raise ValidationError(
_("Do not choose an event for this mode.")
)
if d["mode"] == KnownDomain.MODE_ORG_ALT_DOMAIN and d["event"]:
raise ValidationError(
_("Do not choose an event for this mode. You can assign events to this domain in event settings.")
)
if d["mode"] == KnownDomain.MODE_EVENT_DOMAIN and not d["event"]:
raise ValidationError(
_("You need to choose an event.")
)
return d
class BaseKnownDomainFormSet(forms.BaseInlineFormSet):
def __init__(self, *args, **kwargs):
self.organizer = kwargs.pop('organizer')
super().__init__(*args, **kwargs)
def _construct_form(self, i, **kwargs):
kwargs['organizer'] = self.organizer
return super()._construct_form(i, **kwargs)
@property
def empty_form(self):
form = self.form(
auto_id=self.auto_id,
prefix=self.add_prefix('__prefix__'),
empty_permitted=True,
use_required_attribute=False,
organizer=self.organizer,
)
self.add_fields(form, None)
return form
def clean(self):
super().clean()
data = [f.cleaned_data for f in self.forms]
if len([d for d in data if d.get("mode") == KnownDomain.MODE_ORG_DOMAIN and not d.get("DELETE")]) > 1:
raise ValidationError(_("You may set only one organizer domain."))
return data
KnownDomainFormset = inlineformset_factory(
Organizer, KnownDomain,
KnownDomainForm,
formset=BaseKnownDomainFormSet,
can_order=False, can_delete=True, extra=0
)
class SafeOrderPositionChoiceField(forms.ModelChoiceField):
@@ -61,6 +61,7 @@
<script type="text/javascript" src="{% static "fileupload/jquery.fileupload.js" %}"></script>
<script type="text/javascript" src="{% static "lightbox/js/lightbox.js" %}"></script>
<script type="text/javascript" src="{% static "are-you-sure/jquery.are-you-sure.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/addressform.js" %}"></script>
{% endcompress %}
{{ html_head|safe }}
@@ -22,7 +22,7 @@
{% bootstrap_field form.name layout="control" %}
{% bootstrap_field form.slug layout="control" %}
{% if form.domain %}
{% bootstrap_field form.domain layout="control" %}
{% bootstrap_field form.domain layout="horizontal" %}
{% endif %}
{% bootstrap_field form.date_from layout="control" %}
{% bootstrap_field form.date_to layout="control" %}
@@ -41,7 +41,10 @@
{% endif %}
{% include "pretixcontrol/event/fragment_geodata.html" %}
{% bootstrap_field form.currency layout="control" %}
{% bootstrap_field form.tax_rate addon_after="%" layout="control" %}
{% bootstrap_field form.no_taxes layout="control" %}
<div data-display-dependency="#{{ form.no_taxes.id_for_label }}" data-inverse>
{% bootstrap_field form.tax_rate addon_after="%" layout="control" %}
</div>
</fieldset>
<fieldset>
<legend>{% trans "Display settings" %}</legend>
@@ -46,11 +46,13 @@
<div id="cp{{ pos.id }}">
<div class="panel-body">
{% for form in forms %}
{% if form.pos.item != pos.item %}
{# Add-Ons #}
<legend>+ {{ form.pos.item }}</legend>
{% endif %}
{% bootstrap_form form layout="control" %}
<div class="profile-scope">
{% if form.pos.item != pos.item %}
{# Add-Ons #}
<legend>+ {{ form.pos.item }}</legend>
{% endif %}
{% bootstrap_form form layout="control" %}
</div>
{% endfor %}
</div>
</div>
@@ -294,6 +294,71 @@
<legend>{% trans "Invoices" %}</legend>
{% bootstrap_field sform.invoice_regenerate_allowed layout="control" %}
</fieldset>
{% if domain_formset %}
<fieldset>
<legend>{% trans "Domains" %}</legend>
<div class="alert alert-warning">
{% trans "This dialog is intended for advanced users." %}
{% trans "The domain needs to be configured on your webserver before it can be used here." %}
</div>
<div class="formset" data-formset data-formset-prefix="{{ domain_formset.prefix }}">
{{ domain_formset.management_form }}
{% bootstrap_formset_errors domain_formset %}
<div data-formset-body>
{% for form in domain_formset %}
<div class="row formset-row" data-formset-form>
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-md-4">
{% bootstrap_field form.domainname layout='' form_group_class="" %}
{% bootstrap_form_errors form %}
</div>
<div class="col-md-3">
{% bootstrap_field form.mode layout='' form_group_class="" %}
</div>
<div class="col-md-3">
{% bootstrap_field form.event layout='' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<label aria-hidden="true">&nbsp;</label><br>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
<div class="row formset-row" data-formset-form>
<div class="sr-only">
{{ domain_formset.empty_form.id }}
{% bootstrap_field domain_formset.empty_form.DELETE form_group_class="" layout="inline" %}
</div>
<div class="col-md-4">
{% bootstrap_field domain_formset.empty_form.domainname layout='' form_group_class="" %}
</div>
<div class="col-md-3">
{% bootstrap_field domain_formset.empty_form.mode layout='' form_group_class="" %}
</div>
<div class="col-md-3">
{% bootstrap_field domain_formset.empty_form.event layout='' form_group_class="" %}
</div>
<div class="col-md-2 text-right flip">
<label aria-hidden="true">&nbsp;</label><br>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add domain" %}</button>
</p>
</fieldset>
{% endif %}
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
+24 -23
View File
@@ -62,7 +62,7 @@ from django.http import (
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.html import conditional_escape
from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy as _, gettext_noop
@@ -100,9 +100,12 @@ from ...base.models.items import (
Item, ItemCategory, ItemMetaProperty, Question, Quota,
)
from ...base.services.mail import prefix_subject
from ...base.services.placeholders import get_sample_context
from ...base.settings import LazyI18nStringList
from ...helpers.compat import CompatDeleteView
from ...helpers.format import format_map
from ...helpers.format import (
PlainHtmlAlternativeString, SafeFormatter, format_map,
)
from ..logdisplay import OVERVIEW_BANLIST
from . import CreateView, PaginationMixin, UpdateView
@@ -239,7 +242,6 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired
kwargs = super().get_form_kwargs()
if self.request.user.has_active_staff_session(self.request.session.session_key):
kwargs['change_slug'] = True
kwargs['domain'] = True
return kwargs
def post(self, request, *args, **kwargs):
@@ -717,20 +719,7 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
# get all supported placeholders with dummy values
def placeholders(self, item):
ctx = {}
for p in get_available_placeholders(self.request.event, MailSettingsForm.base_context[item]).values():
s = str(p.render_sample(self.request.event))
if s.strip().startswith('* '):
ctx[p.identifier] = '<div class="placeholder" title="{}">{}</div>'.format(
_('This value will be replaced based on dynamic parameters.'),
markdown_compile_email(s)
)
else:
ctx[p.identifier] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
escape(s)
)
return ctx
return get_sample_context(self.request.event, MailSettingsForm.base_context[item])
def post(self, request, *args, **kwargs):
preview_item = request.POST.get('item', '')
@@ -752,9 +741,15 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
bleach.clean(v), self.placeholders(preview_item), raise_on_missing=True
), highlight=True)
else:
msgs[self.supported_locale[idx]] = markdown_compile_email(
format_map(v, self.placeholders(preview_item), raise_on_missing=True)
placeholders = self.placeholders(preview_item)
msgs[self.supported_locale[idx]] = format_map(
markdown_compile_email(
format_map(v, placeholders, raise_on_missing=True)
),
placeholders,
mode=SafeFormatter.MODE_RICH_TO_HTML,
)
except ValueError:
msgs[self.supported_locale[idx]] = '<div class="alert alert-danger">{}</div>'.format(
PlaceholderValidator.error_message)
@@ -777,13 +772,18 @@ class MailSettingsRendererPreview(MailSettingsPreview):
# get all supported placeholders with dummy values
def placeholders(self, item):
ctx = {}
for p in get_available_placeholders(self.request.event, MailSettingsForm.base_context[item]).values():
ctx[p.identifier] = escape(str(p.render_sample(self.request.event)))
for p in get_available_placeholders(self.request.event, MailSettingsForm.base_context[item], rich=True).values():
sample = p.render_sample(self.request.event)
if isinstance(sample, PlainHtmlAlternativeString):
ctx[p.identifier] = sample
else:
ctx[p.identifier] = conditional_escape(sample)
return ctx
def get(self, request, *args, **kwargs):
v = str(request.event.settings.mail_text_order_placed)
v = format_map(v, self.placeholders('mail_text_order_placed'))
context = self.placeholders('mail_text_order_placed')
v = format_map(v, context)
renderers = request.event.get_html_mail_renderers()
if request.GET.get('renderer') in renderers:
with rolledback_transaction():
@@ -801,7 +801,8 @@ class MailSettingsRendererPreview(MailSettingsPreview):
str(request.event.settings.mail_text_signature),
gettext('Your order: %(code)s') % {'code': order.code},
order,
position=None
position=None,
context=context,
)
r = HttpResponse(v, content_type='text/html')
r._csp_ignore = True
+2 -2
View File
@@ -134,7 +134,7 @@ from pretix.control.signals import order_search_forms
from pretix.control.views import PaginationMixin
from pretix.helpers import OF_SELF
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.format import format_map
from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.safedownload import check_token
from pretix.presale.signals import question_form_fields
@@ -2351,7 +2351,7 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView):
'subject': mark_safe(_('Subject: {subject}').format(
subject=prefix_subject(order.event, escape(email_subject), highlight=True)
)),
'html': markdown_compile_email(email_content)
'html': format_map(markdown_compile_email(email_content), email_context, mode=SafeFormatter.MODE_RICH_TO_HTML)
}
return self.get(self.request, *self.args, **self.kwargs)
else:
+37 -11
View File
@@ -104,11 +104,11 @@ from pretix.control.forms.organizer import (
CustomerCreateForm, CustomerUpdateForm, DeviceBulkEditForm, DeviceForm,
EventMetaPropertyAllowedValueFormSet, EventMetaPropertyForm, GateForm,
GiftCardAcceptanceInviteForm, GiftCardCreateForm, GiftCardUpdateForm,
MailSettingsForm, MembershipTypeForm, MembershipUpdateForm,
OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm,
OrganizerSettingsForm, OrganizerUpdateForm, ReusableMediumCreateForm,
ReusableMediumUpdateForm, SalesChannelForm, SSOClientForm, SSOProviderForm,
TeamForm, WebHookForm,
KnownDomainFormset, MailSettingsForm, MembershipTypeForm,
MembershipUpdateForm, OrganizerDeleteForm, OrganizerFooterLinkFormset,
OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm,
ReusableMediumCreateForm, ReusableMediumUpdateForm, SalesChannelForm,
SSOClientForm, SSOProviderForm, TeamForm, WebHookForm,
)
from pretix.control.forms.rrule import RRuleForm
from pretix.control.logdisplay import OVERVIEW_BANLIST
@@ -122,7 +122,7 @@ from pretix.control.views.mailsetup import MailSettingsSetupView
from pretix.helpers import OF_SELF, GroupConcat
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.dicts import merge_dicts
from pretix.helpers.format import format_map
from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.forms.customer import TokenGenerator
@@ -357,9 +357,10 @@ class MailSettingsPreview(OrganizerPermissionRequiredMixin, View):
highlight=True,
)
else:
msgs[self.supported_locale[idx]] = markdown_compile_email(
format_map(v, self.placeholders(preview_item))
)
placeholders = self.placeholders(preview_item)
msgs[self.supported_locale[idx]] = format_map(markdown_compile_email(
format_map(v, placeholders)
), placeholders, mode=SafeFormatter.MODE_RICH_TO_HTML)
return JsonResponse({
'item': preview_item,
@@ -447,6 +448,10 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
def get_object(self, queryset=None) -> Organizer:
return self.object
@cached_property
def domain_config(self):
return self.request.user.has_active_staff_session(self.request.session.session_key)
@cached_property
def sform(self):
return OrganizerSettingsForm(
@@ -461,6 +466,8 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
context = super().get_context_data(*args, **kwargs)
context['sform'] = self.sform
context['footer_links_formset'] = self.footer_links_formset
if self.domain_config:
context['domain_formset'] = self.domain_formset
return context
@transaction.atomic
@@ -483,6 +490,8 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
self.request.organizer.log_action('pretix.organizer.footerlinks.changed', user=self.request.user, data={
'data': self.footer_links_formset.cleaned_data
})
if self.domain_config and self.domain_formset.has_changed():
self._save_domain_config()
if form.has_changed():
self.request.organizer.log_action(
'pretix.organizer.changed',
@@ -493,10 +502,22 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def _save_domain_config(self):
for form in self.domain_formset.initial_forms:
if form.instance.pk and form.has_changed():
self.object.domains.get(pk=form.instance.pk).log_delete(self.request.user)
self.domain_formset.save()
for new_obj in self.domain_formset.new_objects:
new_obj.log_create(self.request.user)
for ch_obj, form in self.domain_formset.changed_objects:
ch_obj.log_create(self.request.user)
self.request.organizer.cache.clear()
for ev in self.request.organizer.events.all():
ev.cache.clear()
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
if self.request.user.has_active_staff_session(self.request.session.session_key):
kwargs['domain'] = True
kwargs['change_slug'] = True
return kwargs
@@ -508,7 +529,7 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
def post(self, request, *args, **kwargs):
self.object = self.get_object()
form = self.get_form()
if form.is_valid() and self.sform.is_valid() and self.footer_links_formset.is_valid():
if form.is_valid() and self.sform.is_valid() and self.footer_links_formset.is_valid() and (not self.domain_config or self.domain_formset.is_valid()):
return self.form_valid(form)
else:
return self.form_invalid(form)
@@ -519,6 +540,11 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
organizer=self.object,
prefix="footer-links", instance=self.object)
@cached_property
def domain_formset(self):
return KnownDomainFormset(self.request.POST if self.request.method == "POST" else None, prefix="domains",
instance=self.object, organizer=self.object)
def save_footer_links_formset(self, obj):
self.footer_links_formset.save()
+8 -19
View File
@@ -50,7 +50,7 @@ from django.http import (
from django.shortcuts import redirect, render
from django.urls import resolve, reverse
from django.utils.functional import cached_property
from django.utils.html import escape, format_html
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
@@ -59,12 +59,12 @@ from django.views.generic import (
)
from django_scopes import scopes_disabled
from pretix.base.email import get_available_placeholders
from pretix.base.models import (
CartPosition, LogEntry, Voucher, WaitingListEntry,
)
from pretix.base.models.vouchers import generate_codes
from pretix.base.services.mail import prefix_subject
from pretix.base.services.placeholders import get_sample_context
from pretix.base.services.vouchers import vouchers_send
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.tasks import AsyncFormView
@@ -74,7 +74,7 @@ from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import voucher_form_class
from pretix.control.views import PaginationMixin
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.format import format_map
from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.models import modelcopy
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -549,22 +549,10 @@ class VoucherBulkMailPreview(EventPermissionRequiredMixin, View):
# get all supported placeholders with dummy values
def placeholders(self, item):
ctx = {}
base_ctx = ['event', 'name']
if item == 'send_message':
base_ctx += ['voucher_list']
for p in get_available_placeholders(self.request.event, base_ctx).values():
s = str(p.render_sample(self.request.event))
if s.strip().startswith('* ') or s.startswith(' '):
ctx[p.identifier] = '<div class="placeholder" title="{}">{}</div>'.format(
_('This value will be replaced based on dynamic parameters.'),
markdown_compile_email(s)
)
else:
ctx[p.identifier] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
escape(s)
)
ctx = get_sample_context(self.request.event, base_ctx)
return self.SafeDict(ctx)
def post(self, request, *args, **kwargs):
@@ -579,9 +567,10 @@ class VoucherBulkMailPreview(EventPermissionRequiredMixin, View):
highlight=True
)
else:
msgs["all"] = markdown_compile_email(
format_map(request.POST.get(preview_item), self.placeholders(preview_item))
)
placeholders = self.placeholders(preview_item)
msgs["all"] = format_map(markdown_compile_email(
format_map(request.POST.get(preview_item), placeholders)
), placeholders, mode=SafeFormatter.MODE_RICH_TO_HTML)
return JsonResponse({
'item': preview_item,
+28 -5
View File
@@ -25,14 +25,29 @@ from string import Formatter
logger = logging.getLogger(__name__)
class PlainHtmlAlternativeString:
def __init__(self, plain, html, is_block=False):
self.plain = plain
self.html = html
self.is_block = is_block
def __repr__(self):
return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')"
class SafeFormatter(Formatter):
"""
Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and
(b) does not allow any unwanted shenanigans like attribute access or format specifiers.
"""
def __init__(self, context, raise_on_missing=False):
MODE_IGNORE_RICH = 0
MODE_RICH_TO_PLAIN = 1
MODE_RICH_TO_HTML = 2
def __init__(self, context, raise_on_missing=False, mode=MODE_IGNORE_RICH):
self.context = context
self.raise_on_missing = raise_on_missing
self.mode = mode
def get_field(self, field_name, args, kwargs):
return self.get_value(field_name, args, kwargs), field_name
@@ -40,14 +55,22 @@ class SafeFormatter(Formatter):
def get_value(self, key, args, kwargs):
if not self.raise_on_missing and key not in self.context:
return '{' + str(key) + '}'
return self.context[key]
r = self.context[key]
if isinstance(r, PlainHtmlAlternativeString):
if self.mode == self.MODE_IGNORE_RICH:
return '{' + str(key) + '}'
elif self.mode == self.MODE_RICH_TO_PLAIN:
return r.plain
elif self.mode == self.MODE_RICH_TO_HTML:
return r.html
return r
def format_field(self, value, format_spec):
# Ignore format _spec
# Ignore format_spec
return super().format_field(value, '')
def format_map(template, context, raise_on_missing=False):
def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_IGNORE_RICH):
if not isinstance(template, str):
template = str(template)
return SafeFormatter(context, raise_on_missing).format(template)
return SafeFormatter(context, raise_on_missing, mode=mode).format(template)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-11-18 15:30+0000\n"
"POT-Creation-Date: 2024-11-19 15:34+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -8,16 +8,16 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-11-08 13:45+0000\n"
"PO-Revision-Date: 2024-10-01 22:52+0000\n"
"PO-Revision-Date: 2024-11-28 06:00+0000\n"
"Last-Translator: Patrick Chilton <chpatrick@gmail.com>\n"
"Language-Team: Hungarian <https://translate.pretix.eu/projects/pretix/pretix-"
"js/hu/>\n"
"Language-Team: Hungarian <https://translate.pretix.eu/projects/pretix/"
"pretix-js/hu/>\n"
"Language: hu\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.7.2\n"
"X-Generator: Weblate 5.8.3\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
@@ -779,7 +779,7 @@ msgstr "A kosár lejárt"
#: pretix/static/pretixpresale/js/ui/main.js:588
#: pretix/static/pretixpresale/js/ui/main.js:607
msgid "Time zone:"
msgstr ""
msgstr "Időzona:"
#: pretix/static/pretixpresale/js/ui/main.js:598
msgid "Your local time:"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17 -6
View File
@@ -80,28 +80,32 @@ class MultiDomainMiddleware(MiddlewareMixin):
request.port = int(port) if port else None
request.host = domain
if domain == default_domain:
request.domain_mode = "system"
request.urlconf = "pretix.multidomain.maindomain_urlconf"
elif domain:
cached = cache.get('pretix_multidomain_instance_{}'.format(domain))
cached = cache.get('pretix_multidomain_instances_{}'.format(domain))
if cached is None:
try:
kd = KnownDomain.objects.select_related('organizer', 'event').get(domainname=domain) # noqa
orga = kd.organizer
event = kd.event
mode = kd.mode
except KnownDomain.DoesNotExist:
orga = False
event = False
mode = "system"
cache.set(
'pretix_multidomain_instance_{}'.format(domain),
(orga.pk if orga else None, event.pk if event else None),
'pretix_multidomain_instances_{}'.format(domain),
(orga.pk if orga else None, event.pk if event else None, mode),
3600
)
else:
orga, event = cached
orga, event, mode = cached
if event:
if mode == KnownDomain.MODE_EVENT_DOMAIN:
request.event_domain = True
request.domain_mode = KnownDomain.MODE_EVENT_DOMAIN
if isinstance(event, Event):
request.organizer = orga
request.event = event
@@ -110,11 +114,18 @@ class MultiDomainMiddleware(MiddlewareMixin):
request.event = Event.objects.select_related('organizer').get(pk=event)
request.organizer = request.event.organizer
request.urlconf = "pretix.multidomain.event_domain_urlconf"
elif orga:
elif mode == KnownDomain.MODE_ORG_ALT_DOMAIN:
request.organizer_domain = True
request.domain_mode = KnownDomain.MODE_ORG_ALT_DOMAIN
request.organizer = orga if isinstance(orga, Organizer) else Organizer.objects.get(pk=orga)
request.urlconf = "pretix.multidomain.organizer_alternative_domain_urlconf"
elif mode == KnownDomain.MODE_ORG_DOMAIN:
request.organizer_domain = True
request.domain_mode = KnownDomain.MODE_ORG_DOMAIN
request.organizer = orga if isinstance(orga, Organizer) else Organizer.objects.get(pk=orga)
request.urlconf = "pretix.multidomain.organizer_domain_urlconf"
elif settings.DEBUG or domain in LOCAL_HOST_NAMES:
request.domain_mode = "system"
request.urlconf = "pretix.multidomain.maindomain_urlconf"
else:
with scopes_disabled():
@@ -0,0 +1,71 @@
# Generated by Django 4.2.16 on 2024-11-12 10:46
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0273_remove_checkinlist_auto_checkin_sales_channels"),
("pretixmultidomain", "0002_knowndomain_event"),
]
operations = [
migrations.CreateModel(
name="AlternativeDomainAssignment",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
],
),
migrations.AddField(
model_name="knowndomain",
name="mode",
field=models.CharField(default="organizer", max_length=255),
),
migrations.AlterField(
model_name="knowndomain",
name="event",
field=models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="domain",
to="pretixbase.event",
),
),
migrations.RunSQL(
sql="UPDATE pretixmultidomain_knowndomain SET mode = 'event' WHERE event_id IS NOT NULL",
reverse_sql=migrations.RunSQL.noop,
),
migrations.AddConstraint(
model_name="knowndomain",
constraint=models.UniqueConstraint(
condition=models.Q(("mode", "organizer")),
fields=("organizer",),
name="unique_organizer_domain",
),
),
migrations.AddField(
model_name="alternativedomainassignment",
name="domain",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="event_assignments",
to="pretixmultidomain.knowndomain",
),
),
migrations.AddField(
model_name="alternativedomainassignment",
name="event",
field=models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="alternative_domain_assignment",
to="pretixbase.event",
),
),
]
+104 -8
View File
@@ -21,6 +21,7 @@
#
from django.core.cache import cache
from django.db import models
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_scopes import scopes_disabled
@@ -28,39 +29,134 @@ from pretix.base.models import Event, Organizer
class KnownDomain(models.Model):
domainname = models.CharField(max_length=255, primary_key=True)
organizer = models.ForeignKey(Organizer, blank=True, null=True, related_name='domains', on_delete=models.CASCADE)
event = models.ForeignKey(Event, blank=True, null=True, related_name='domains', on_delete=models.PROTECT)
MODE_ORG_DOMAIN = "organizer"
MODE_ORG_ALT_DOMAIN = "organizer_alternative"
MODE_EVENT_DOMAIN = "event"
MODES = (
(MODE_ORG_DOMAIN, _("Organizer domain")),
(MODE_ORG_ALT_DOMAIN, _("Alternative organizer domain for a set of events")),
(MODE_EVENT_DOMAIN, _("Event domain")),
)
domainname = models.CharField(
max_length=255,
primary_key=True,
verbose_name=_("Domain name"),
)
mode = models.CharField(
max_length=255,
choices=MODES,
default=MODE_ORG_DOMAIN,
verbose_name=_("Mode"),
)
organizer = models.ForeignKey(
Organizer,
blank=True,
null=True,
related_name='domains',
on_delete=models.CASCADE
)
event = models.OneToOneField(
Event,
blank=True,
null=True,
related_name='domain',
on_delete=models.PROTECT,
verbose_name=_("Event"),
)
class Meta:
verbose_name = _("Known domain")
verbose_name_plural = _("Known domains")
constraints = [
models.UniqueConstraint(
fields=("organizer",),
name="unique_organizer_domain",
condition=Q(mode="organizer"),
),
]
ordering = ("-mode", "domainname")
def __str__(self):
return self.domainname
@scopes_disabled()
def save(self, *args, **kwargs):
if self.event:
self.mode = KnownDomain.MODE_EVENT_DOMAIN
elif self.mode == KnownDomain.MODE_EVENT_DOMAIN:
raise ValueError("Event domain needs event")
super().save(*args, **kwargs)
if self.event:
self.event.get_cache().clear()
try:
self.event.alternative_domain_assignment.delete()
except AlternativeDomainAssignment.DoesNotExist:
pass
elif self.organizer:
self.organizer.get_cache().clear()
for event in self.organizer.events.all():
event.get_cache().clear()
cache.delete('pretix_multidomain_organizer_{}'.format(self.domainname))
cache.delete('pretix_multidomain_instance_{}'.format(self.domainname))
cache.delete('pretix_multidomain_instances_{}'.format(self.domainname))
cache.delete('pretix_multidomain_event_{}'.format(self.domainname))
@scopes_disabled()
def delete(self, *args, **kwargs):
if self.event:
self.event.get_cache().clear()
self.event.cache.clear()
elif self.organizer:
self.organizer.get_cache().clear()
self.organizer.cache.clear()
for event in self.organizer.events.all():
event.get_cache().clear()
event.cache.clear()
cache.delete('pretix_multidomain_organizer_{}'.format(self.domainname))
cache.delete('pretix_multidomain_instance_{}'.format(self.domainname))
cache.delete('pretix_multidomain_instances_{}'.format(self.domainname))
cache.delete('pretix_multidomain_event_{}'.format(self.domainname))
super().delete(*args, **kwargs)
def _log_domain_action(self, user, data):
if self.event:
self.event.log_action(
'pretix.event.settings',
user=user,
data=data
)
else:
self.organizer.log_action(
'pretix.organizer.settings',
user=user,
data=data
)
def log_create(self, user):
self._log_domain_action(user, {'add_alt_domain': self.domainname} if self.mode == KnownDomain.MODE_ORG_ALT_DOMAIN else {'domain': self.domainname})
def log_delete(self, user):
self._log_domain_action(user, {'remove_alt_domain': self.domainname} if self.mode == KnownDomain.MODE_ORG_ALT_DOMAIN else {'domain': None})
class AlternativeDomainAssignment(models.Model):
domain = models.ForeignKey(
KnownDomain,
on_delete=models.CASCADE,
related_name="event_assignments",
)
event = models.OneToOneField(
Event,
related_name="alternative_domain_assignment",
on_delete=models.CASCADE,
)
@scopes_disabled()
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
self.event.cache.clear()
cache.delete('pretix_multidomain_instances_{}'.format(self.domain_id))
cache.delete('pretix_multidomain_event_{}'.format(self.domain_id))
@scopes_disabled()
def delete(self, *args, **kwargs):
self.event.cache.clear()
cache.delete('pretix_multidomain_instances_{}'.format(self.domain_id))
cache.delete('pretix_multidomain_event_{}'.format(self.domain_id))
super().delete(*args, **kwargs)
@@ -0,0 +1,63 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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 importlib.util
from django.apps import apps
from django.urls import include, re_path
from pretix.multidomain.plugin_handler import plugin_event_urls
from pretix.presale.urls import event_patterns, locale_patterns
from pretix.presale.views import organizer
from pretix.urls import common_patterns
presale_patterns = [
re_path(r'', include((locale_patterns + [
re_path(r'^$', organizer.RedirectToOrganizerIndex.as_view(), name='organizer.alt.index'),
re_path(r'^(?P<event>[^/]+)/', include(event_patterns)),
], 'presale')))
]
raw_plugin_patterns = []
for app in apps.get_app_configs():
if hasattr(app, 'PretixPluginMeta'):
if importlib.util.find_spec(app.name + '.urls'):
urlmod = importlib.import_module(app.name + '.urls')
if hasattr(urlmod, 'event_patterns'):
patterns = plugin_event_urls(urlmod.event_patterns, plugin=app.name)
raw_plugin_patterns.append(
re_path(r'^(?P<event>[^/]+)/', include((patterns, app.label)))
)
if hasattr(urlmod, 'organizer_patterns'):
patterns = plugin_event_urls(urlmod.organizer_patterns, plugin=app.name)
raw_plugin_patterns.append(
re_path(r'', include((patterns, app.label)))
)
plugin_patterns = [
re_path(r'', include((raw_plugin_patterns, 'plugins')))
]
# The presale namespace comes last, because it contains a wildcard catch
urlpatterns = common_patterns + plugin_patterns + presale_patterns
handler404 = 'pretix.base.views.errors.page_not_found'
handler500 = 'pretix.base.views.errors.server_error'
+29 -16
View File
@@ -43,28 +43,33 @@ from pretix.base.models import Event, Organizer
from .models import KnownDomain
def get_event_domain(event, fallback=False, return_info=False):
def get_event_domain(event, fallback=False, return_mode=False):
assert isinstance(event, Event)
if not event.pk:
# Can happen on the "event deleted" response
return (None, None) if return_info else None
suffix = ('_fallback' if fallback else '') + ('_info' if return_info else '')
return (None, None) if return_mode else None
suffix = ('_fallback' if fallback else '') + ('_mode' if return_mode else '')
domain = getattr(event, '_cached_domain' + suffix, None) or event.cache.get('domain' + suffix)
if domain is None:
domain = None, None
if fallback:
if hasattr(event, 'alternative_domain_assignment'):
domain = event.alternative_domain_assignment.domain_id, KnownDomain.MODE_ORG_ALT_DOMAIN
elif fallback:
domains = KnownDomain.objects.filter(
Q(event=event) | Q(organizer_id=event.organizer_id, event__isnull=True)
Q(event=event, mode=KnownDomain.MODE_EVENT_DOMAIN) |
Q(organizer_id=event.organizer_id, event__isnull=True, mode=KnownDomain.MODE_ORG_DOMAIN)
)
domains_event = [d for d in domains if d.event_id == event.pk]
domains_org = [d for d in domains if not d.event_id]
if domains_event:
domain = domains_event[0].domainname, "event"
domain = domains_event[0].domainname, KnownDomain.MODE_EVENT_DOMAIN
elif domains_org:
domain = domains_org[0].domainname, "organizer"
domain = domains_org[0].domainname, KnownDomain.MODE_ORG_DOMAIN
else:
domains = event.domains.all()
domain = domains[0].domainname if domains else None, "event"
try:
domain = event.domain.domainname, KnownDomain.MODE_EVENT_DOMAIN
except KnownDomain.DoesNotExist:
domain = None, None
event.cache.set('domain' + suffix, domain or 'none')
setattr(event, '_cached_domain' + suffix, domain or 'none')
elif domain == 'none':
@@ -72,7 +77,7 @@ def get_event_domain(event, fallback=False, return_info=False):
domain = None, None
else:
setattr(event, '_cached_domain' + suffix, domain)
return domain if return_info or not isinstance(domain, tuple) else domain[0]
return domain if return_mode else domain[0]
def get_organizer_domain(organizer):
@@ -81,7 +86,7 @@ def get_organizer_domain(organizer):
return None
domain = getattr(organizer, '_cached_domain', None) or organizer.cache.get('domain')
if domain is None:
domains = organizer.domains.filter(event__isnull=True)
domains = organizer.domains.filter(event__isnull=True, mode=KnownDomain.MODE_ORG_DOMAIN)
domain = domains[0].domainname if domains else None
organizer.cache.set('domain', domain or 'none')
organizer._cached_domain = domain or 'none'
@@ -131,7 +136,8 @@ def eventreverse(obj, name, kwargs=None):
:returns: An absolute or relative URL as a string
"""
from pretix.multidomain import (
event_domain_urlconf, maindomain_urlconf, organizer_domain_urlconf,
event_domain_urlconf, maindomain_urlconf,
organizer_alternative_domain_urlconf, organizer_domain_urlconf,
)
c = None
@@ -153,17 +159,24 @@ def eventreverse(obj, name, kwargs=None):
raise TypeError('obj should be Event or Organizer')
if event:
domain, domaintype = get_event_domain(obj, fallback=True, return_info=True)
domain, domaintype = get_event_domain(obj, fallback=True, return_mode=True)
else:
domain, domaintype = get_organizer_domain(organizer), "organizer"
domain, domaintype = get_organizer_domain(organizer), KnownDomain.MODE_ORG_DOMAIN
if domain:
if domaintype == "event" and 'event' in kwargs:
if domaintype == KnownDomain.MODE_EVENT_DOMAIN and 'event' in kwargs:
del kwargs['event']
if 'organizer' in kwargs:
del kwargs['organizer']
path = reverse(name, kwargs=kwargs, urlconf=event_domain_urlconf if domaintype == "event" else organizer_domain_urlconf)
if domaintype == KnownDomain.MODE_EVENT_DOMAIN:
urlconf = event_domain_urlconf
elif domaintype == KnownDomain.MODE_ORG_ALT_DOMAIN:
urlconf = organizer_alternative_domain_urlconf
else:
urlconf = organizer_domain_urlconf
path = reverse(name, kwargs=kwargs, urlconf=urlconf)
siteurlsplit = urlsplit(settings.SITE_URL)
if siteurlsplit.port and siteurlsplit.port not in (80, 443):
domain = '%s:%d' % (domain, siteurlsplit.port)
+4 -4
View File
@@ -79,8 +79,8 @@ class BaseMailForm(FormPlaceholderMixin, forms.Form):
widget=I18nMarkdownTextarea, required=True,
locales=event.settings.get('locales'),
)
self._set_field_placeholders('subject', context_parameters)
self._set_field_placeholders('message', context_parameters)
self._set_field_placeholders('subject', context_parameters, rich=False)
self._set_field_placeholders('message', context_parameters, rich=True)
class WaitinglistMailForm(BaseMailForm):
@@ -381,8 +381,8 @@ class RuleForm(FormPlaceholderMixin, I18nModelForm):
]
)
self._set_field_placeholders('subject', ['event', 'order'])
self._set_field_placeholders('template', ['event', 'order'])
self._set_field_placeholders('subject', ['event', 'order', 'event_or_subevent'])
self._set_field_placeholders('template', ['event', 'order', 'event_or_subevent'], rich=True)
choices = [(e, l) for e, l in Order.STATUS_CHOICE if e != 'n']
choices.insert(0, ('n__valid_if_pending', _('payment pending but already confirmed')))
@@ -0,0 +1,33 @@
# Generated by Django 4.2.16 on 2024-11-13 13:43
from django.db import migrations
from django.db.models import Q
from i18nfield.strings import LazyI18nString
def migrate_placeholders(apps, schema_editor):
Rule = apps.get_model("sendmail", "Rule")
for r in Rule.objects.filter(
Q(template__contains="{event}") | Q(subject__contains="{event}"),
event__has_subevents=True
):
r.template = LazyI18nString({
k: v.replace("{event}", "{event_series_name}") for k, v in r.template.data.items()
})
r.subject = LazyI18nString({
k: v.replace("{event}", "{event_series_name}") for k, v in r.subject.data.items()
})
r.save(update_fields=["template", "subject"])
class Migration(migrations.Migration):
dependencies = [
("sendmail", "008_remove_scheduled_mails"),
]
operations = [
migrations.RunPython(
migrate_placeholders,
migrations.RunPython.noop,
)
]
+19 -3
View File
@@ -174,7 +174,12 @@ class ScheduledMail(models.Model):
ia = InvoiceAddress(order=o)
if send_to_orders and o.email:
email_ctx = get_email_context(event=e, order=o, invoice_address=ia)
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
event_or_subevent=self.subevent or e,
)
try:
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
@@ -192,12 +197,23 @@ class ScheduledMail(models.Model):
for p in positions:
try:
if p.attendee_email and (p.attendee_email != o.email or not o_sent):
email_ctx = get_email_context(event=e, order=o, invoice_address=ia, position=p)
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
position=p,
event_or_subevent=self.subevent or e,
)
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
elif not o_sent and o.email:
email_ctx = get_email_context(event=e, order=o, invoice_address=ia)
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
event_or_subevent=self.subevent or e,
)
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
+14 -44
View File
@@ -46,12 +46,10 @@ from django.shortcuts import get_object_or_404, redirect
from django.template.loader import get_template
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, ngettext
from django.views.generic import DeleteView, FormView, ListView, TemplateView
from pretix.base.email import get_available_placeholders
from pretix.base.i18n import LazyI18nString, language
from pretix.base.models import Checkin, LogEntry, Order, OrderPosition
from pretix.base.models.event import SubEvent
@@ -63,7 +61,8 @@ from pretix.plugins.sendmail.tasks import (
)
from ...base.services.mail import prefix_subject
from ...helpers.format import format_map
from ...base.services.placeholders import get_sample_context
from ...helpers.format import SafeFormatter, format_map
from ...helpers.models import modelcopy
from . import forms
from .models import Rule, ScheduledMail
@@ -191,17 +190,15 @@ class BaseSenderView(EventPermissionRequiredMixin, FormView):
if self.request.POST.get("action") != "send":
for l in self.request.event.settings.locales:
with language(l, self.request.event.settings.region):
context_dict = {}
for k, v in get_available_placeholders(self.request.event, self.context_parameters).items():
context_dict[k] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
escape(v.render_sample(self.request.event))
)
context_dict = get_sample_context(self.request.event, self.context_parameters)
subject = bleach.clean(form.cleaned_data['subject'].localize(l), tags=set())
preview_subject = prefix_subject(self.request.event, format_map(subject, context_dict), highlight=True)
message = form.cleaned_data['message'].localize(l)
preview_text = markdown_compile_email(format_map(message, context_dict))
preview_text = format_map(
markdown_compile_email(format_map(message, context_dict)),
context_dict,
mode=SafeFormatter.MODE_RICH_TO_HTML,
)
self.output[l] = {
'subject': _('Subject: {subject}').format(subject=preview_subject),
@@ -603,31 +600,6 @@ class CreateRule(EventPermissionRequiredMixin, CreateView):
return super().form_invalid(form)
def form_valid(self, form):
self.output = {}
if self.request.POST.get("action") == "preview":
for l in self.request.event.settings.locales:
with language(l, self.request.event.settings.region):
context_dict = {}
for k, v in get_available_placeholders(self.request.event, ['event', 'order',
'position_or_address']).items():
context_dict[k] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
escape(v.render_sample(self.request.event))
)
subject = bleach.clean(form.cleaned_data['subject'].localize(l), tags=set())
preview_subject = prefix_subject(self.request.event, format_map(subject, context_dict), highlight=True)
template = form.cleaned_data['template'].localize(l)
preview_text = markdown_compile_email(format_map(template, context_dict))
self.output[l] = {
'subject': _('Subject: {subject}').format(subject=preview_subject),
'html': preview_text,
}
return self.get(self.request, *self.args, **self.kwargs)
messages.success(self.request, _('Your rule has been created.'))
form.instance.event = self.request.event
@@ -685,17 +657,15 @@ class UpdateRule(EventPermissionRequiredMixin, UpdateView):
for lang in self.request.event.settings.locales:
with language(lang, self.request.event.settings.region):
placeholders = {}
for k, v in get_available_placeholders(self.request.event, ['event', 'order', 'position_or_address']).items():
placeholders[k] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
escape(v.render_sample(self.request.event))
)
placeholders = get_sample_context(self.request.event, ['event', 'order', 'position_or_address'])
subject = bleach.clean(self.object.subject.localize(lang), tags=set())
preview_subject = prefix_subject(self.request.event, format_map(subject, placeholders), highlight=True)
template = self.object.template.localize(lang)
preview_text = markdown_compile_email(format_map(template, placeholders))
preview_text = format_map(
markdown_compile_email(format_map(template, placeholders)),
placeholders,
mode=SafeFormatter.MODE_RICH_TO_HTML,
)
o[lang] = {
'subject': _('Subject: {subject}'.format(subject=preview_subject)),
+7 -1
View File
@@ -492,12 +492,18 @@ class StripeSettingsHolder(BasePaymentProvider):
# label=_('PayPal'),
# disabled=self.event.currency not in [
# 'EUR', 'GBP', 'USD', 'CHF', 'CZK', 'DKK', 'NOK', 'PLN', 'SEK', 'AUD', 'CAD', 'HKD', 'NZD', 'SGD'
# ]
# ],
# help_text=_('Some payment methods might need to be enabled in the settings of your Stripe account '
# 'before they work properly.'),
# required=False,
# )),
('method_mobilepay',
forms.BooleanField(
label=_('MobilePay'),
disabled=self.event.currency not in ['DKK', 'EUR', 'NOK', 'SEK'],
help_text=_('Some payment methods might need to be enabled in the settings of your Stripe account '
'before they work properly.'),
required=False,
)),
] + extra_fields + list(super().settings_form_fields.items()) + moto_settings
)
+4 -4
View File
@@ -160,7 +160,7 @@ class BaseCheckoutFlowStep:
kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace']
return eventreverse(self.request.event, 'presale:event.index', kwargs=kwargs)
else:
return prev.get_step_url(request)
return prev.get_step_url(request) + '?dir=prev'
def get_next_url(self, request):
n = self.get_next_applicable(request)
@@ -662,7 +662,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
if 'async_id' in request.GET and settings.HAS_CELERY:
return self.get_result(request)
if len(self.forms) == 0 and len(self.cross_selling_data) == 0 and self.is_completed(request):
return redirect(self.get_next_url(request))
return redirect(self.get_prev_url(request) if request.GET.get('dir') == 'prev' else self.get_next_url(request))
return TemplateFlowStep.get(self, request)
def _clean_category(self, form, category):
@@ -1076,8 +1076,8 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_attendees_required', as_type=bool) \
and (cp.street is None or cp.city is None or cp.country is None):
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_addresses_required', as_type=bool) \
and (cp.street is None and cp.city is None and cp.country is None):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
+1
View File
@@ -133,6 +133,7 @@ class InvoiceAddressForm(BaseInvoiceAddressForm):
class InvoiceNameForm(InvoiceAddressForm):
address_validation = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -13,7 +13,7 @@
</p>
{% elif incomplete %}
<div class="alert alert-danger">
{% trans "A product in your cart is only sold in combination with add-on products that are no longer available. Please contact the event organizer." %}
{% trans "A product in your cart is only sold in combination with add-on products that are not available. Please contact the event organizer." %}
</div>
{% endif %}
<form class="form-horizontal" method="post" data-asynctask
@@ -1,31 +1,29 @@
{% load i18n %}
{% load bootstrap3 %}
{% load textbubble %}
{# Changes should be replicated in pretixcontrol/orders/fragment_order_status.html and in pretix/base/models/orders.py #}
{% if order.status == "n" %}
{% if order.require_approval %}
{% trans "Approval pending" %}
{% textbubble "warning" icon="exclamation-triangle" %}{% trans "Approval pending" %}{% endtextbubble %}
{% elif order.total == 0 %}
{% trans "Confirmation pending" context "order state" %}
{% textbubble "warning" icon="exclamation-triangle" %}{% trans "Confirmation pending" context "order state" %}{% endtextbubble %}
{% elif event.settings.payment_pending_hidden %}
{# intentionally left blank #}
{% elif order.valid_if_pending %}
{% trans "Confirmed" context "order state" %}
{% textbubble "info" icon="info-circle" %}{% trans "Confirmed" context "order state" %}{% endtextbubble %}
{% else %}
{% trans "Payment pending" %}
{% endif %}
{% if not event.settings.payment_pending_hidden %}
<i class="status-dot fa fa-circle {% if order.valid_if_pending %}text-info{% else %}text-warning{% endif %}" aria-hidden="true"></i>
{% textbubble "warning" icon="exclamation-triangle" %}{% trans "Payment pending" %}{% endtextbubble %}
{% endif %}
{% elif order.status == "p" %}
{% if order.count_positions == 0 %}
{% trans "Canceled (paid fee)" %} <i class="status-dot fa fa-info-circle text-info" aria-hidden="true"></i>
{% textbubble "info" icon="info-circle" %}{% trans "Canceled (paid fee)" %}{% endtextbubble %}
{% elif order.total == 0 %}
{% trans "Confirmed" context "order state" %} <i class="status-dot fa fa-check-circle text-success" aria-hidden="true"></i>
{% textbubble "success" icon="check" %}{% trans "Confirmed" context "order state" %}{% endtextbubble %}
{% else %}
{% trans "Paid" %} <i class="status-dot fa fa-check-circle text-success" aria-hidden="true"></i>
{% textbubble "success" icon="check" %}{% trans "Paid" %}{% endtextbubble %}
{% endif %}
{% elif order.status == "e" %}
{% trans "Expired" %} <i class="status-dot fa fa-minus-circle text-danger" aria-hidden="true"></i>
{% textbubble "danger" icon="minus" %}{% trans "Expired" %}{% endtextbubble %}
{% elif order.status == "c" %}
{% trans "Canceled" %} <i class="status-dot fa fa-times-circle text-danger" aria-hidden="true"></i>
{% textbubble "danger" icon="times" %}{% trans "Canceled" %}{% endtextbubble %}
{% endif %}
@@ -10,12 +10,12 @@
<section aria-labelledby="{{ form_prefix }}category-{{ category.id }}"{% if category.description %} aria-describedby="{{ form_prefix }}category-info-{{ category.id }}"{% endif %}>
<h{{ headline_level|default:3 }} class="h3" id="{{ form_prefix }}category-{{ category.id }}">{{ category.name }}
{% if category.subevent_name %}
<small class="text-muted"><i class="fa fa-calendar"></i> {{ category.subevent_name }}</small>
<small class="text-muted"><i class="fa fa-calendar" aria-hidden="true"></i> {{ category.subevent_name }}</small>
{% endif %}
{% if category.category_has_discount %}
<small class="text-success">
<i class="fa fa-star" aria-hidden="true"></i>
<span class="sr-only">Congratulations!</span>
<span class="sr-only">{% trans "Congratulations!" %}</span>
{% trans "Your order qualifies for a discount" %}
</small>
{% endif %}
@@ -2,7 +2,7 @@
{% load date_fast %}
{% load calendarhead %}
<div class="table-responsive">
<table class="table table-calendar">
<table class="table table-calendar" role="grid">
<caption class="sr-only">{% trans "Calendar" %}</caption>
<thead>
<tr>
@@ -18,7 +18,26 @@
{% if day %}
<td class="day {% if day.events %}has-events{% else %}no-events{% endif %}"
data-date="{{ day.date|date_fast:"SHORT_DATE_FORMAT" }}">
<p><time datetime="{{ day.date|date_fast:"Y-m-d" }}">{{ day.day }}</time></p>
<p>
{% if day.events %}
<a href="#selected-day" class="day-label event hidden-sm hidden-md hidden-lg">
<b aria-hidden="true">{{ day.day }}</b>
<time datetime="{{ day.date|date_fast:"Y-m-d" }}" class="sr-only">
{{ day.date|date_fast:"SHORT_DATE_FORMAT" }}
</time>
<span class="sr-only">
({% blocktrans trimmed count count=day.events|length %}
{{ count }} event
{% plural %}
{{ count }} events
{% endblocktrans %})
</span>
</a>
<time datetime="{{ day.date|date_fast:"Y-m-d" }}" class="hidden-xs">{{ day.day }}</time>
{% else %}
<time datetime="{{ day.date|date_fast:"Y-m-d" }}" class="day-label">{{ day.day }}</time>
{% endif %}
</p>
<ul class="events">
{% for event in day.events %}
<li><a class="event {% if event.continued %}continued{% endif %} {% spaceless %}
@@ -111,9 +130,7 @@
{% endfor %}
</tr>
{% endfor %}
<tr class="selected-day hidden">
<td colspan="7"></td>
</tr>
</tbody>
</table>
<div id="selected-day" aria-live="polite" class="table-calendar hidden-sm hidden-md hidden-lg"></div>
</div>
@@ -20,4 +20,5 @@
<script type="text/javascript" src="{% static "pretixpresale/js/ui/cart.js" %}"></script>
<script type="text/javascript" src="{% static "lightbox/js/lightbox.js" %}"></script>
<script type="text/javascript" src="{% static "pretixpresale/js/ui/iframe.js" %}"></script>
<script type="text/javascript" src="{% static "pretixbase/js/addressform.js" %}"></script>
{% endcompress %}
@@ -4,9 +4,9 @@
{% if request.organizer.settings.customer_accounts %}
<nav class="loginstatus" aria-label="{% trans "customer account" %}">
{% if request.customer %}
<a href="{% abseventurl request.organizer "presale:organizer.customer.profile" %}"
<a href="{% abseventurl request.organizer "presale:organizer.customer.index" %}"
aria-label="{% trans "View customer account" %}" data-placement="bottom"
title="{% trans "View user profile" %}" data-toggle="tooltip">
title="{% trans "View customer account" %}" data-toggle="tooltip">
<span class="fa fa-user" aria-hidden="true"></span>
{{ request.customer.name|default:request.customer.email }}</a>
<a href="{% if request.event_domain %}{% abseventurl request.event "presale:organizer.customer.logout" %}{% else %}{% abseventurl request.organizer "presale:organizer.customer.logout" %}{% endif %}?next={{ request.path|urlencode }}%3F{{ request.META.QUERY_STRING|urlencode }}"

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