From dd2a74557d436acb1288e33eb6acc8465fe0d214 Mon Sep 17 00:00:00 2001
From: sweenu
Date: Mon, 6 Jul 2026 18:49:55 +0300
Subject: [PATCH] Add contact URL setting (#6132)
* Add contact URL setting
* Apply suggestions from code review
Co-authored-by: Raphael Michel
* Apply suggestion from @raphaelm
---------
Co-authored-by: Raphael Michel
---
src/pretix/api/serializers/event.py | 1 +
src/pretix/api/serializers/organizer.py | 1 +
src/pretix/base/settings.py | 11 ++++++++++
src/pretix/control/forms/event.py | 7 ++++++
src/pretix/control/forms/organizer.py | 1 +
.../pretixcontrol/event/quick_setup.html | 1 +
.../pretixcontrol/event/settings.html | 1 +
.../pretixcontrol/organizers/edit.html | 1 +
.../templates/pretixpresale/event/base.html | 4 ++--
.../pretixpresale/organizers/base.html | 6 +++--
src/tests/api/test_events.py | 22 +++++++++++++++++++
src/tests/api/test_organizers.py | 21 ++++++++++++++++++
src/tests/presale/test_event.py | 10 +++++++++
src/tests/presale/test_organizer_page.py | 11 ++++++++++
14 files changed, 94 insertions(+), 4 deletions(-)
diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py
index c6fef3d69c..4276b756b9 100644
--- a/src/pretix/api/serializers/event.py
+++ b/src/pretix/api/serializers/event.py
@@ -747,6 +747,7 @@ class EventSettingsSerializer(SettingsSerializer):
'max_items_per_order',
'reservation_time',
'contact_mail',
+ 'contact_url',
'show_variations_expanded',
'hide_sold_out',
'meta_noindex',
diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py
index 9df859701b..c62b91a3d8 100644
--- a/src/pretix/api/serializers/organizer.py
+++ b/src/pretix/api/serializers/organizer.py
@@ -576,6 +576,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed',
'contact_mail',
+ 'contact_url',
'imprint_url',
'organizer_info_text',
'event_list_type',
diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py
index 53fbbfda37..ae679d186c 100644
--- a/src/pretix/base/settings.py
+++ b/src/pretix/base/settings.py
@@ -2284,6 +2284,17 @@ DEFAULTS = {
help_text=_("We'll show this publicly to allow attendees to contact you.")
)
},
+ 'contact_url': {
+ 'default': None,
+ 'type': str,
+ 'serializer_class': serializers.URLField,
+ 'form_class': forms.URLField,
+ 'form_kwargs': dict(
+ label=_("Contact URL"),
+ help_text=_("If you set this, the footer contact link will point here instead of using the email address above. "
+ "Please note that you still need to add a contact email address that will be shared with all emails you send.")
+ )
+ },
'imprint_url': {
'default': None,
'type': str,
diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py
index 588ee56a82..5d86c0f943 100644
--- a/src/pretix/control/forms/event.py
+++ b/src/pretix/control/forms/event.py
@@ -625,6 +625,7 @@ class EventSettingsForm(EventSettingsValidationMixin, FormPlaceholderMixin, Sett
'max_items_per_order',
'reservation_time',
'contact_mail',
+ 'contact_url',
'show_variations_expanded',
'hide_sold_out',
'meta_noindex',
@@ -1904,6 +1905,12 @@ class QuickSetupForm(I18nForm):
required=False,
help_text=_("We'll show this publicly to allow attendees to contact you.")
)
+ contact_url = forms.URLField(
+ label=_("Contact URL"),
+ required=False,
+ help_text=_("If you set this, the footer contact link will point here instead of using the email address above. "
+ "Please note that you still need to add a contact email address that will be shared with all emails you send.")
+ )
total_quota = forms.IntegerField(
label=_("Total capacity"),
min_value=0,
diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py
index 0078562506..2bafd2d420 100644
--- a/src/pretix/control/forms/organizer.py
+++ b/src/pretix/control/forms/organizer.py
@@ -604,6 +604,7 @@ class OrganizerSettingsForm(SettingsForm):
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed',
'contact_mail',
+ 'contact_url',
'imprint_url',
'organizer_info_text',
'event_list_type',
diff --git a/src/pretix/control/templates/pretixcontrol/event/quick_setup.html b/src/pretix/control/templates/pretixcontrol/event/quick_setup.html
index 571c1c5e3d..733ebd08a1 100644
--- a/src/pretix/control/templates/pretixcontrol/event/quick_setup.html
+++ b/src/pretix/control/templates/pretixcontrol/event/quick_setup.html
@@ -193,6 +193,7 @@
{% endblocktrans %}
{% bootstrap_field form.contact_mail layout="control" %}
+ {% bootstrap_field form.contact_url layout="control" %}
{% bootstrap_field form.imprint_url layout="control" %}
diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html
index 8d9a46f732..8077e57189 100644
--- a/src/pretix/control/templates/pretixcontrol/event/settings.html
+++ b/src/pretix/control/templates/pretixcontrol/event/settings.html
@@ -30,6 +30,7 @@
{% bootstrap_field form.date_admission layout="control" %}
{% bootstrap_field form.currency layout="control" %}
{% bootstrap_field sform.contact_mail layout="control" %}
+ {% bootstrap_field sform.contact_url layout="control" %}
{% bootstrap_field sform.imprint_url layout="control" %}
{% bootstrap_field form.is_public layout="control" %}
{% bootstrap_field form.all_sales_channels layout="control" %}
diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html
index 960c6fb4f5..0c2090d38e 100644
--- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html
+++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html
@@ -34,6 +34,7 @@
{% endif %}
{% bootstrap_field sform.imprint_url layout="control" %}
{% bootstrap_field sform.contact_mail layout="control" %}
+ {% bootstrap_field sform.contact_url layout="control" %}
{% bootstrap_field sform.organizer_info_text layout="control" %}
{% bootstrap_field sform.event_team_provisioning layout="control" %}
{% if sform.allowed_restricted_plugins %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/base.html b/src/pretix/presale/templates/pretixpresale/event/base.html
index b3f566c837..660ce73868 100644
--- a/src/pretix/presale/templates/pretixpresale/event/base.html
+++ b/src/pretix/presale/templates/pretixpresale/event/base.html
@@ -219,8 +219,8 @@
{% endif %}
{% endblock %}
{% block footernav %}
- {% if request.event.settings.contact_mail %}
- {% trans "Contact" %}
+ {% if request.event.settings.contact_url or request.event.settings.contact_mail %}
+ {% trans "Contact" %}
{% endif %}
{% if request.event.settings.privacy_url %}
{% trans "Privacy policy" %}
diff --git a/src/pretix/presale/templates/pretixpresale/organizers/base.html b/src/pretix/presale/templates/pretixpresale/organizers/base.html
index da77df7cd2..2f9affeae6 100644
--- a/src/pretix/presale/templates/pretixpresale/organizers/base.html
+++ b/src/pretix/presale/templates/pretixpresale/organizers/base.html
@@ -97,8 +97,10 @@
{% endblock %}
{% endblock %}
{% block footernav %}
- {% if not request.event and request.organizer.settings.contact_mail %}
- {% trans "Contact" %}
+ {% if not request.event %}
+ {% if request.organizer.settings.contact_url or request.organizer.settings.contact_mail %}
+ {% trans "Contact" %}
+ {% endif %}
{% endif %}
{% if not request.event and request.organizer.settings.privacy_url %}
{% trans "Privacy policy" %}
diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py
index fd19291855..3787216fc7 100644
--- a/src/tests/api/test_events.py
+++ b/src/tests/api/test_events.py
@@ -1413,11 +1413,13 @@ def test_event_create_with_seating_maps(token_client, organizer, event, meta_pro
@pytest.mark.django_db
def test_get_event_settings(token_client, organizer, event):
event.settings.imprint_url = "https://example.org"
+ event.settings.contact_url = "https://example.org/contact"
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug),
)
assert resp.status_code == 200
assert resp.data['imprint_url'] == "https://example.org"
+ assert resp.data['contact_url'] == "https://example.org/contact"
assert resp.data['seating_allow_blocked_seats_for_channel'] == []
resp = token_client.get(
@@ -1443,9 +1445,11 @@ def test_patch_event_settings(token_client, organizer, event, team):
team.save()
organizer.settings.imprint_url = 'https://example.org'
+ organizer.settings.contact_url = 'https://example.org/contact'
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug),
{
+ 'contact_url': 'https://example.com/contact',
'imprint_url': 'https://example.com',
'confirm_texts': [
{
@@ -1458,10 +1462,12 @@ def test_patch_event_settings(token_client, organizer, event, team):
format='json'
)
assert resp.status_code == 200
+ assert resp.data['contact_url'] == "https://example.com/contact"
assert resp.data['imprint_url'] == "https://example.com"
assert resp.data['seating_allow_blocked_seats_for_channel'] == ['web']
assert not resp.data['reusable_media_active']
event.settings.flush()
+ assert event.settings.contact_url == 'https://example.com/contact'
assert event.settings.imprint_url == 'https://example.com'
assert event.settings.seating_allow_blocked_seats_for_channel == ['web']
assert not event.settings.reusable_media_active
@@ -1471,6 +1477,7 @@ def test_patch_event_settings(token_client, organizer, event, team):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug),
{
+ 'contact_url': 'https://example.com/contact',
'imprint_url': 'https://example.com',
'confirm_texts': [
{
@@ -1513,13 +1520,16 @@ def test_patch_event_settings(token_client, organizer, event, team):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug),
{
+ 'contact_url': None,
'imprint_url': None,
},
format='json'
)
assert resp.status_code == 200
+ assert resp.data['contact_url'] == "https://example.org/contact"
assert resp.data['imprint_url'] == "https://example.org"
event.settings.flush()
+ assert event.settings.contact_url == 'https://example.org/contact'
assert event.settings.imprint_url == 'https://example.org'
resp = token_client.put(
@@ -1583,6 +1593,18 @@ def test_patch_event_settings(token_client, organizer, event, team):
@pytest.mark.django_db
def test_patch_event_settings_validation(token_client, organizer, event):
+ resp = token_client.patch(
+ '/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug),
+ {
+ 'contact_url': 'invalid',
+ },
+ format='json'
+ )
+ assert resp.status_code == 400
+ assert resp.data == {
+ 'contact_url': ['Enter a valid URL.']
+ }
+
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug),
{
diff --git a/src/tests/api/test_organizers.py b/src/tests/api/test_organizers.py
index 2109e8de18..1ec00a5ad5 100644
--- a/src/tests/api/test_organizers.py
+++ b/src/tests/api/test_organizers.py
@@ -148,6 +148,27 @@ def test_patch_settings(token_client, organizer):
)
assert resp.status_code == 400
+ resp = token_client.patch(
+ '/api/v1/organizers/{}/settings/'.format(organizer.slug),
+ {
+ 'contact_url': 'invalid'
+ },
+ format='json'
+ )
+ assert resp.status_code == 400
+
+ resp = token_client.patch(
+ '/api/v1/organizers/{}/settings/'.format(organizer.slug),
+ {
+ 'contact_url': 'https://example.org/contact',
+ },
+ format='json'
+ )
+ assert resp.status_code == 200
+ assert resp.data['contact_url'] == 'https://example.org/contact'
+ organizer.settings.flush()
+ assert organizer.settings.contact_url == 'https://example.org/contact'
+
resp = token_client.patch(
'/api/v1/organizers/{}/settings/'.format(organizer.slug),
{
diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py
index 5ffd28db39..70dade2bed 100644
--- a/src/tests/presale/test_event.py
+++ b/src/tests/presale/test_event.py
@@ -81,6 +81,16 @@ class EventMiddlewareTest(EventTestMixin, SoupTest):
doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug))
self.assertIn(str(self.event.name), doc.find("h1").text)
+ def test_footer_contact_url_overrides_mailto(self):
+ self.event.settings.contact_mail = 'orga@example.org'
+ self.event.settings.contact_url = 'https://example.org/contact'
+
+ doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug))
+ contact_link = doc.find('a', string='Contact')
+
+ self.assertIsNotNone(contact_link)
+ self.assertTrue(contact_link['href'].startswith('/redirect/?url=https%3A//example.org/contact'))
+
def test_no_session_cookie_set_on_event_index_view(self):
resp = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug))
self.assertEqual(resp.status_code, 200)
diff --git a/src/tests/presale/test_organizer_page.py b/src/tests/presale/test_organizer_page.py
index ead641455d..263d97f51f 100644
--- a/src/tests/presale/test_organizer_page.py
+++ b/src/tests/presale/test_organizer_page.py
@@ -46,6 +46,17 @@ def test_organizer_page_shown(env, client):
assert 'MRMCD e.V.' in r.rendered_content
+@pytest.mark.django_db
+def test_organizer_footer_contact_url_overrides_mailto(env, client):
+ env[0].settings.contact_mail = 'orga@example.org'
+ env[0].settings.contact_url = 'https://example.org/contact'
+
+ r = client.get('/mrmcd/')
+
+ assert '/redirect/?url=https%3A//example.org/contact' in r.rendered_content
+ assert 'mailto:orga@example.org' not in r.rendered_content
+
+
@pytest.mark.django_db
def test_public_event_on_page(env, client):
env[1].is_public = True