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>
This commit is contained in:
Raphael Michel
2024-12-02 15:58:50 +01:00
committed by GitHub
parent e2753686ee
commit e99ee91573
20 changed files with 747 additions and 153 deletions

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
@@ -363,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:
@@ -379,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

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):

View File

@@ -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" %}

View File

@@ -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">

View File

@@ -239,7 +239,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):

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
@@ -447,6 +447,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 +465,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 +489,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 +501,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 +528,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 +539,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()