diff --git a/src/pretix/base/migrations/0217_eventfooterlink_organizerfooterlink.py b/src/pretix/base/migrations/0217_eventfooterlink_organizerfooterlink.py new file mode 100644 index 0000000000..105e5ef503 --- /dev/null +++ b/src/pretix/base/migrations/0217_eventfooterlink_organizerfooterlink.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2.12 on 2022-06-15 08:10 + +import django.db.models.deletion +import i18nfield.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0216_checkin_forced_sent'), + ] + + operations = [ + migrations.CreateModel( + name='OrganizerFooterLink', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('label', i18nfield.fields.I18nCharField(max_length=200)), + ('url', models.URLField()), + ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='footer_links', to='pretixbase.organizer')), + ], + ), + migrations.CreateModel( + name='EventFooterLink', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('label', i18nfield.fields.I18nCharField(max_length=200)), + ('url', models.URLField()), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='footer_links', to='pretixbase.event')), + ], + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index ff0f86dbc7..c1a500c883 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -718,6 +718,11 @@ class Event(EventMixin, LoggedModel): self.save() self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk}) + for fl in EventFooterLink.objects.filter(event=other): + fl.pk = None + fl.event = self + fl.save(force_insert=True) + tax_map = {} for t in other.tax_rules.all(): tax_map[t.pk] = t @@ -1612,3 +1617,25 @@ class SubEventMetaValue(LoggedModel): super().save(*args, **kwargs) if self.subevent: self.subevent.event.cache.clear() + + +class EventFooterLink(models.Model): + """ + A footer link assigned to an event. + """ + event = models.ForeignKey('Event', on_delete=models.CASCADE, related_name='footer_links') + label = I18nCharField( + max_length=200, + verbose_name=_("Link text"), + ) + url = models.URLField( + verbose_name=_("Link URL"), + ) + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + self.event.cache.clear() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.event.cache.clear() diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index 96633995c5..9fb0ad89f2 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -46,6 +46,7 @@ from django.utils.crypto import get_random_string from django.utils.functional import cached_property from django.utils.timezone import get_current_timezone, make_aware, now from django.utils.translation import gettext_lazy as _ +from i18nfield.fields import I18nCharField from pretix.base.models.base import LoggedModel from pretix.base.validators import OrganizerSlugBanlistValidator @@ -464,3 +465,25 @@ class TeamAPIToken(models.Model): return self.get_events_with_any_permission() else: return self.team.organizer.events.none() + + +class OrganizerFooterLink(models.Model): + """ + A footer link assigned to an organizer. + """ + organizer = models.ForeignKey('Organizer', on_delete=models.CASCADE, related_name='footer_links') + label = I18nCharField( + max_length=200, + verbose_name=_("Link text"), + ) + url = models.URLField( + verbose_name=_("Link URL"), + ) + + def delete(self, *args, **kwargs): + super().delete(*args, **kwargs) + self.organizer.cache.clear() + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + self.organizer.cache.clear() diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 22aadcb416..4c479f259e 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -41,7 +41,9 @@ from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.db.models import Prefetch, Q, prefetch_related_objects -from django.forms import CheckboxSelectMultiple, formset_factory +from django.forms import ( + CheckboxSelectMultiple, formset_factory, inlineformset_factory, +) from django.urls import reverse from django.utils.functional import cached_property from django.utils.html import escape @@ -58,7 +60,7 @@ from pretix.base.channels import get_all_sales_channels from pretix.base.email import get_available_placeholders from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm from pretix.base.models import Event, Organizer, TaxRule, Team -from pretix.base.models.event import EventMetaValue, SubEvent +from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent from pretix.base.reldate import RelativeDateField, RelativeDateTimeField from pretix.base.settings import ( PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings, @@ -1484,3 +1486,25 @@ ConfirmTextFormset = formset_factory( formset=BaseConfirmTextFormSet, can_order=True, can_delete=True, extra=0 ) + + +class EventFooterLinkForm(I18nModelForm): + class Meta: + model = EventFooterLink + fields = ('label', 'url') + + +class BaseEventFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet): + def __init__(self, *args, **kwargs): + event = kwargs.pop('event', None) + if event: + kwargs['locales'] = event.settings.get('locales') + super().__init__(*args, **kwargs) + + +EventFooterLinkFormset = inlineformset_factory( + Event, EventFooterLink, + EventFooterLinkForm, + formset=BaseEventFooterLinkFormSet, + can_order=False, can_delete=True, extra=0 +) diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 576d5b07e6..d11be47a4d 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -39,12 +39,13 @@ from django import forms from django.conf import settings from django.core.exceptions import ValidationError from django.db.models import Q +from django.forms import inlineformset_factory from django.forms.utils import ErrorDict from django.utils.crypto import get_random_string from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _, pgettext_lazy from django_scopes.forms import SafeModelMultipleChoiceField -from i18nfield.forms import I18nFormField, I18nTextarea +from i18nfield.forms import I18nFormField, I18nFormSetMixin, I18nTextarea from phonenumber_field.formfields import PhoneNumberField from pytz import common_timezones @@ -60,6 +61,7 @@ from pretix.base.models import ( Customer, Device, EventMetaProperty, Gate, GiftCard, Membership, MembershipType, Organizer, Team, ) +from pretix.base.models.organizer import OrganizerFooterLink from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS from pretix.control.forms import ExtFileField, SplitDateTimeField from pretix.control.forms.event import ( @@ -682,3 +684,25 @@ class MembershipUpdateForm(forms.ModelForm): titles=self.instance.customer.organizer.settings.name_scheme_titles, label=_('Attendee name'), ) + + +class OrganizerFooterLinkForm(I18nModelForm): + class Meta: + model = OrganizerFooterLink + fields = ('label', 'url') + + +class BaseOrganizerFooterLinkFormSet(I18nFormSetMixin, forms.BaseInlineFormSet): + def __init__(self, *args, **kwargs): + organizer = kwargs.pop('organizer', None) + if organizer: + kwargs['locales'] = organizer.settings.get('locales') + super().__init__(*args, **kwargs) + + +OrganizerFooterLinkFormset = inlineformset_factory( + Organizer, OrganizerFooterLink, + OrganizerFooterLinkForm, + formset=BaseOrganizerFooterLinkFormSet, + can_order=False, can_delete=True, extra=0 +) diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index dd85f10ad0..e42349f4a0 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -314,6 +314,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.object.cloned': _('This object has been created by cloning.'), 'pretix.organizer.changed': _('The organizer has been changed.'), 'pretix.organizer.settings': _('The organizer settings have been changed.'), + 'pretix.organizer.footerlinks.changed': _('The footer links have been changed.'), 'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'), 'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'), 'pretix.webhook.created': _('The webhook has been created.'), @@ -468,6 +469,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.testmode.deactivated': _('The test mode has been disabled.'), 'pretix.event.added': _('The event has been created.'), 'pretix.event.changed': _('The event details have been changed.'), + 'pretix.event.footerlinks.changed': _('The footer links have been changed.'), 'pretix.event.question.option.added': _('An answer option has been added to the question.'), 'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'), 'pretix.event.question.option.changed': _('An answer option has been changed.'), diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 1c2784f653..92029cf9bc 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -69,7 +69,8 @@
- +
@@ -81,7 +82,8 @@

- + {% trans "See invoice settings" %}

@@ -101,7 +103,8 @@

- + {% trans "Manage questions" %}

@@ -232,10 +235,74 @@ {% bootstrap_field sform.display_net_prices layout="control" %} {% bootstrap_field sform.show_variations_expanded layout="control" %} {% bootstrap_field sform.hide_sold_out layout="control" %} - {% url "control:organizer.edit" organizer=request.organizer.slug as org_url %} - {% propagated request.event org_url "meta_noindex" %} - {% bootstrap_field sform.meta_noindex layout="control" %} - {% endpropagated %} + +
+ +
+

+ {% blocktrans trimmed %} + These links will be shown in the footer of your ticket shop. You could + for example link your terms of service here. Your contact address, imprint, and privacy + policy will be linked automatically (if you configured them), so you do not need to add + them here. + {% endblocktrans %} +

+
+ {{ footer_links_formset.management_form }} + {% bootstrap_formset_errors footer_links_formset %} +
+ {% for form in footer_links_formset %} +
+
+ {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} +
+
+ {% bootstrap_form_errors form %} + {% bootstrap_field form.label layout='inline' form_group_class="" %} +
+
+ {% bootstrap_field form.url layout='inline' form_group_class="" %} +
+
+ +
+
+ {% endfor %} +
+ +

+ +

+
+
+
+ {% if sform.frontpage_subevent_ordering %} {% bootstrap_field sform.frontpage_subevent_ordering layout="control" %} {% endif %} @@ -245,6 +312,11 @@ {% if sform.event_list_available_only %} {% bootstrap_field sform.event_list_available_only layout="control" %} {% endif %} + + {% url "control:organizer.edit" organizer=request.organizer.slug as org_url %} + {% propagated request.event org_url "meta_noindex" %} + {% bootstrap_field sform.meta_noindex layout="control" %} + {% endpropagated %}
{% trans "Cart" %} @@ -262,13 +334,15 @@
{% blocktrans trimmed %} - The waiting list determines availability mainly based on quotas. If you use a seating plan and your + The waiting list determines availability mainly based on quotas. If you use a seating plan and + your number of available seats is less than the available quota, you might run into situations where people are sent an email from the waiting list but still are unable to book a seat. {% endblocktrans %} {% blocktrans trimmed %} - Specifically, this means the waiting list is not safe to use together with the minimum distance + Specifically, this means the waiting list is not safe to use together with the minimum + distance feature of our seating plan module. {% endblocktrans %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html index 4018db1bc9..eaa525b1cc 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html @@ -50,6 +50,77 @@ {% bootstrap_field sform.event_list_availability layout="control" %} {% bootstrap_field sform.organizer_link_back layout="control" %} {% bootstrap_field sform.meta_noindex layout="control" %} +
+ +
+

+ {% blocktrans trimmed %} + These links will be shown in the footer of your ticket shop. You could + for example link your terms of service here. Your contact address, imprint, and privacy + policy will be linked automatically (if you configured them), so you do not need to add + them here. + {% endblocktrans %} +

+

+ {% blocktrans trimmed %} + The links you configure here will also be shown on all of your events. + {% endblocktrans %} +

+
+ {{ footer_links_formset.management_form }} + {% bootstrap_formset_errors footer_links_formset %} +
+ {% for form in footer_links_formset %} +
+
+ {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} +
+
+ {% bootstrap_form_errors form %} + {% bootstrap_field form.label layout='inline' form_group_class="" %} +
+
+ {% bootstrap_field form.url layout='inline' form_group_class="" %} +
+
+ +
+
+ {% endfor %} +
+ +

+ +

+
+
+
{% trans "Localization" %} diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 3569ae1ed1..bd5b69e3ed 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -74,9 +74,9 @@ from pretix.base.signals import register_ticket_outputs from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.control.forms.event import ( CancelSettingsForm, CommentForm, ConfirmTextFormset, EventDeleteForm, - EventMetaValueForm, EventSettingsForm, EventUpdateForm, - InvoiceSettingsForm, ItemMetaPropertyForm, MailSettingsForm, - PaymentSettingsForm, ProviderForm, QuickSetupForm, + EventFooterLinkFormset, EventMetaValueForm, EventSettingsForm, + EventUpdateForm, InvoiceSettingsForm, ItemMetaPropertyForm, + MailSettingsForm, PaymentSettingsForm, ProviderForm, QuickSetupForm, QuickSetupProductFormSet, TaxRuleForm, TaxRuleLineFormSet, TicketSettingsForm, WidgetCodeForm, ) @@ -186,6 +186,7 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired context['meta_forms'] = self.meta_forms context['item_meta_property_formset'] = self.item_meta_property_formset context['confirm_texts_formset'] = self.confirm_texts_formset + context['footer_links_formset'] = self.footer_links_formset return context @transaction.atomic @@ -195,6 +196,7 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired self.save_meta() self.save_item_meta_property_formset(self.object) self.save_confirm_texts_formset(self.object) + self.save_footer_links_formset(self.object) change_css = False if self.sform.has_changed() or self.confirm_texts_formset.has_changed(): @@ -204,6 +206,10 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired self.request.event.log_action('pretix.event.settings', user=self.request.user, data=data) if any(p in self.sform.changed_data for p in SETTINGS_AFFECTING_CSS): change_css = True + if self.footer_links_formset.has_changed(): + self.request.event.log_action('pretix.event.footerlinks.changed', user=self.request.user, data={ + 'data': self.footer_links_formset.cleaned_data + }) if form.has_changed(): self.request.event.log_action('pretix.event.changed', user=self.request.user, data={ k: (form.cleaned_data.get(k).name @@ -238,7 +244,8 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired def post(self, request, *args, **kwargs): form = self.get_form() if form.is_valid() and self.sform.is_valid() and all([f.is_valid() for f in self.meta_forms]) and \ - self.item_meta_property_formset.is_valid() and self.confirm_texts_formset.is_valid(): + self.item_meta_property_formset.is_valid() and self.confirm_texts_formset.is_valid() and \ + self.footer_links_formset.is_valid(): # reset timezone zone = timezone(self.sform.cleaned_data['timezone']) event = form.instance @@ -292,10 +299,18 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired def save_confirm_texts_formset(self, obj): obj.settings.confirm_texts = LazyI18nStringList( form_data['text'].data - for form_data in sorted(self.confirm_texts_formset.cleaned_data, key=operator.itemgetter("ORDER")) - if not form_data.get("DELETE", False) + for form_data in sorted((d for d in self.confirm_texts_formset.cleaned_data if d), key=operator.itemgetter("ORDER")) + if form_data and not form_data.get("DELETE", False) ) + @cached_property + def footer_links_formset(self): + return EventFooterLinkFormset(self.request.POST if self.request.method == "POST" else None, event=self.object, + prefix="footer-links", instance=self.object) + + def save_footer_links_formset(self, obj): + self.footer_links_formset.save() + class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, TemplateView, SingleObjectMixin): model = Event diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 26a0c97bd4..54463ffbf2 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -92,8 +92,8 @@ from pretix.control.forms.organizer import ( CustomerCreateForm, CustomerUpdateForm, DeviceBulkEditForm, DeviceForm, EventMetaPropertyForm, GateForm, GiftCardCreateForm, GiftCardUpdateForm, MailSettingsForm, MembershipTypeForm, MembershipUpdateForm, - OrganizerDeleteForm, OrganizerForm, OrganizerSettingsForm, - OrganizerUpdateForm, TeamForm, WebHookForm, + OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm, + OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, WebHookForm, ) from pretix.control.logdisplay import OVERVIEW_BANLIST from pretix.control.permissions import ( @@ -416,11 +416,13 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView): def get_context_data(self, *args, **kwargs) -> dict: context = super().get_context_data(*args, **kwargs) context['sform'] = self.sform + context['footer_links_formset'] = self.footer_links_formset return context @transaction.atomic def form_valid(self, form): self.sform.save() + self.save_footer_links_formset(self.object) change_css = False if self.sform.has_changed(): self.request.organizer.log_action( @@ -435,6 +437,10 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView): ) if any(p in self.sform.changed_data for p in SETTINGS_AFFECTING_CSS): change_css = True + if self.footer_links_formset.has_changed(): + self.request.organizer.log_action('pretix.organizer.footerlinks.changed', user=self.request.user, data={ + 'data': self.footer_links_formset.cleaned_data + }) if form.has_changed(): self.request.organizer.log_action( 'pretix.organizer.changed', @@ -466,11 +472,19 @@ 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(): + if form.is_valid() and self.sform.is_valid() and self.footer_links_formset.is_valid(): return self.form_valid(form) else: return self.form_invalid(form) + @cached_property + def footer_links_formset(self): + return OrganizerFooterLinkFormset(self.request.POST if self.request.method == "POST" else None, organizer=self.object, + prefix="footer-links", instance=self.object) + + def save_footer_links_formset(self, obj): + self.footer_links_formset.save() + class OrganizerCreate(CreateView): model = Organizer diff --git a/src/pretix/presale/context.py b/src/pretix/presale/context.py index 3a6d94da55..84068f3688 100644 --- a/src/pretix/presale/context.py +++ b/src/pretix/presale/context.py @@ -118,6 +118,10 @@ def _default_context(request): _footer += response else: _footer.append(response) + _footer += request.event.cache.get_or_set('footer_links', lambda: [ + {'url': fl.url, 'label': fl.label} + for fl in request.event.footer_links.all() + ], timeout=300) if request.event.settings.presale_css_file: ctx['css_file'] = default_storage.url(request.event.settings.presale_css_file) @@ -158,6 +162,10 @@ def _default_context(request): ctx['organizer_logo'] = request.organizer.settings.get('organizer_logo_image', as_type=str, default='')[7:] ctx['organizer_homepage_text'] = request.organizer.settings.get('organizer_homepage_text', as_type=LazyI18nString) ctx['organizer'] = request.organizer + _footer += request.organizer.cache.get_or_set('footer_links', lambda: [ + {'url': fl.url, 'label': fl.label} + for fl in request.organizer.footer_links.all() + ], timeout=300) ctx['html_head'] = "".join(h for h in _html_head if h) ctx['html_foot'] = "".join(h for h in _html_foot if h)