diff --git a/src/pretix/base/migrations/0006_create_invoice_voucher.py b/src/pretix/base/migrations/0006_create_invoice_voucher.py new file mode 100644 index 000000000..9fb93c033 --- /dev/null +++ b/src/pretix/base/migrations/0006_create_invoice_voucher.py @@ -0,0 +1,73 @@ +# Generated by Django 5.1.3 on 2024-11-12 08:04 + +from django.db import migrations, models + +import pretix.base.models.base +import pretix.base.models.vouchers + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0005_page_alter_cachedcombinedticket_id_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="InvoiceVoucher", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ( + "code", + models.CharField( + db_index=True, + default=pretix.base.models.vouchers.generate_code, + max_length=255, + unique=True, + ), + ), + ("max_usages", models.PositiveIntegerField(default=1)), + ("redeemed", models.PositiveIntegerField(default=0)), + ( + "budget", + models.DecimalField(decimal_places=2, max_digits=10, null=True), + ), + ( + "valid_until", + models.DateTimeField(blank=True, db_index=True, null=True), + ), + ("price_mode", models.CharField(default="none", max_length=100)), + ( + "value", + models.DecimalField(decimal_places=2, max_digits=10, null=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("created_by", models.CharField(default="system", max_length=50)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("updated_by", models.CharField(default="system", max_length=50)), + ( + "limit_events", + models.ManyToManyField( + related_name="invoice_vouchers", to="pretixbase.event" + ), + ), + ( + "limit_organizer", + models.ManyToManyField( + related_name="invoice_vouchers", to="pretixbase.organizer" + ), + ), + ], + options={ + "verbose_name": "Invoice Voucher", + "verbose_name_plural": "Invoice Vouchers", + "ordering": ("code",), + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + ] diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py index 0e458054b..1be8f7f06 100644 --- a/src/pretix/base/models/vouchers.py +++ b/src/pretix/base/models/vouchers.py @@ -503,3 +503,83 @@ def budget_used(self): ] ).aggregate(s=Sum(F('price_before_voucher') - F('price')))['s'] or Decimal('0.00') return ops + + +class InvoiceVoucher(LoggedModel): + PRICE_MODES = ( + ('none', _('No effect')), + ('set', _('Set product price to')), + ('subtract', _('Subtract from product price')), + ('percent', _('Reduce product price by (%)')), + ) + code = models.CharField( + verbose_name=_("Voucher code"), + max_length=255, default=generate_code, + db_index=True, + validators=[MinLengthValidator(5)], + unique=True + ) + max_usages = models.PositiveIntegerField( + verbose_name=_("Maximum usages"), + help_text=_("Number of times this voucher can be redeemed."), + default=1 + ) + redeemed = models.PositiveIntegerField( + verbose_name=_("Redeemed"), + default=0 + ) + budget = models.DecimalField( + verbose_name=_("Maximum discount budget"), + help_text=_("This is the maximum monetary amount that will be " + "discounted using this voucher across all usages."), + decimal_places=2, max_digits=10, + null=True, blank=True + ) + valid_until = models.DateTimeField( + blank=True, null=True, db_index=True, + verbose_name=_("Valid until") + ) + price_mode = models.CharField( + verbose_name=_("Price mode"), + max_length=100, + choices=PRICE_MODES, + default='none' + ) + value = models.DecimalField( + verbose_name=_("Voucher value"), + decimal_places=2, max_digits=10, null=True, blank=True, + ) + + limit_events = models.ManyToManyField( + 'Event', + verbose_name=_("Limit to events"), + blank=True, + related_name='invoice_vouchers' + ) + + limit_organizer = models.ManyToManyField( + 'Organizer', + verbose_name=_("Limit to Organizer"), + blank=True, + related_name='invoice_vouchers' + ) + + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.CharField(max_length=50, default="system") + updated_at = models.DateTimeField(auto_now=True) + updated_by = models.CharField(max_length=50, default="system") + + class Meta: + verbose_name = _("Invoice Voucher") + verbose_name_plural = _("Invoice Vouchers") + ordering = ('code', ) + + def __str__(self): + return self.code + + def is_active(self): + if self.redeemed >= self.max_usages: + return False + if self.valid_until and self.valid_until < now(): + return False + return True diff --git a/src/pretix/control/forms/admin/__init__.py b/src/pretix/control/forms/admin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pretix/control/forms/admin/vouchers.py b/src/pretix/control/forms/admin/vouchers.py new file mode 100644 index 000000000..3b02d49f7 --- /dev/null +++ b/src/pretix/control/forms/admin/vouchers.py @@ -0,0 +1,67 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ +from django_scopes import scopes_disabled + +from pretix.base.forms import I18nModelForm +from pretix.base.forms.widgets import SplitDateTimePickerWidget +from pretix.base.models import Event, Organizer +from pretix.base.models.vouchers import InvoiceVoucher +from pretix.control.forms import SplitDateTimeField + + +class InvoiceVoucherForm(I18nModelForm): + event_effect = forms.ModelMultipleChoiceField( + queryset=Event.objects.none(), + widget=forms.CheckboxSelectMultiple, + required=False, + label=_("Event effect"), + help_text=_("The voucher will only be valid for the selected events.") + ) + organizer_effect = forms.ModelMultipleChoiceField( + queryset=Organizer.objects.none(), + widget=forms.CheckboxSelectMultiple, + required=False, + label=_("Organizer effect"), + help_text=_("The voucher will be valid for all events under the selected organizers.") + ) + + class Meta: + model = InvoiceVoucher + localized_fields = '__all__' + fields = [ + 'code', 'valid_until', 'value', 'max_usages', 'price_mode', 'budget', 'event_effect', 'organizer_effect' + ] + field_classes = { + 'valid_until': SplitDateTimeField, + } + widgets = { + 'valid_until': SplitDateTimePickerWidget(), + } + + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + super().__init__(*args, **kwargs) + if instance: + self.fields['event_effect'].initial = instance.limit_events.all() + self.fields['organizer_effect'].initial = instance.limit_organizer.all() + with scopes_disabled(): + self.fields['event_effect'].queryset = Event.objects.all() + self.fields['organizer_effect'].queryset = Organizer.objects.all() + + def clean(self): + data = super().clean() + return data + + def save(self, commit=True): + instance = super().save(commit=False) + + if commit: + instance.save() + + instance.limit_events.set(self.cleaned_data.get('event_effect', [])) + instance.limit_organizer.set(self.cleaned_data.get('organizer_effect', [])) + + if commit: + self.save_m2m() + + return instance diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index e403913d2..3a01e57a7 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -549,6 +549,12 @@ def get_admin_navigation(request): }, ] }, + { + 'label': _('Vouchers'), + 'url': reverse('control:admin.vouchers'), + 'active': 'vouchers' in url.url_name, + 'icon': 'tags', + }, { 'label': _('Global settings'), 'url': reverse('control:admin.global.settings'), diff --git a/src/pretix/control/templates/pretixcontrol/admin/vouchers/delete.html b/src/pretix/control/templates/pretixcontrol/admin/vouchers/delete.html new file mode 100644 index 000000000..bc0e0d8d4 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/admin/vouchers/delete.html @@ -0,0 +1,19 @@ +{% extends "pretixcontrol/admin/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Delete voucher" %}{% endblock %} +{% block content %} +

{% trans "Delete voucher" %}

+
+ {% csrf_token %} +

{% blocktrans %}Are you sure you want to delete the voucher {{ invoice_voucher }}?{% endblocktrans %}

+
+ + {% trans "Cancel" %} + + +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/admin/vouchers/detail.html b/src/pretix/control/templates/pretixcontrol/admin/vouchers/detail.html new file mode 100644 index 000000000..887dde6eb --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/admin/vouchers/detail.html @@ -0,0 +1,43 @@ +{% extends "pretixcontrol/admin/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Voucher" %}{% endblock %} +{% block content %} +

{% trans "Voucher" %}

+ {% if voucher.redeemed %} +
+ {% trans "This voucher already has been used. It is not recommended to modify it." %} +
+ {% endif %} +
+ {% csrf_token %} + {% bootstrap_form_errors form %} +
+
+
+ {% trans "Voucher details" %} + {% bootstrap_field form.code layout="control" %} + {% bootstrap_field form.max_usages layout="control" %} + {% bootstrap_field form.valid_until layout="control" %} +
+ +
+ {% bootstrap_field form.price_mode show_label=False form_group_class="" %} +
+
+ {% bootstrap_field form.value show_label=False form_group_class="" %} +
+
+ {% bootstrap_field form.budget addon_after=currency layout="control" %} + {% bootstrap_field form.event_effect layout="control" %} + {% bootstrap_field form.organizer_effect layout="control" %} +
+
+
+
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/admin/vouchers/index.html b/src/pretix/control/templates/pretixcontrol/admin/vouchers/index.html new file mode 100644 index 000000000..9d2779d47 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/admin/vouchers/index.html @@ -0,0 +1,76 @@ +{% extends "pretixcontrol/admin/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load urlreplace %} +{% load money %} +{% block title %}{% trans "Vouchers" %}{% endblock %} +{% block content %} +

{% trans "Vouchers" %}

+ {% if vouchers|length == 0 %} +
+

+ {% blocktrans trimmed %} + You haven't created any vouchers yet. + {% endblocktrans %} +

+ + {% trans "Create a new voucher" %} +
+ {% else %} +

+ {% trans "Create a new voucher" %} +

+
+ {% csrf_token %} +
+ + + + + + + + + + + {% for v in vouchers %} + + + + + + + {% endfor %} + +
+ {% trans "Voucher code" %} + + + + {% trans "Redemptions" %} + + + + {% trans "Expiry" %} + + +
+ {% if not v.is_active %} + + {% endif %} + {{ v.code }} + {% if not v.is_active %} + + {% endif %} + + {{ v.redeemed }} / {{ v.max_usages }} + {{ v.valid_until|date }} + +
+
+
+ {% include "pretixcontrol/pagination.html" %} + {% endif %} +{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 2ed3799a5..1f79c135c 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -347,6 +347,12 @@ url(r'^global/settings/$', global_settings.GlobalSettingsView.as_view(), name='admin.global.settings'), url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='admin.global.update'), url(r'^global/message/$', global_settings.MessageView.as_view(), name='admin.global.message'), + url(r'^vouchers/$', admin.VoucherList.as_view(), name='admin.vouchers'), + url(r'^vouchers/add$', admin.VoucherCreate.as_view(), name='admin.vouchers.add'), + url(r'^vouchers/(?P\d+)/$', admin.VoucherUpdate.as_view(), name='admin.voucher'), + url(r'^vouchers/(?P\d+)/delete$', admin.VoucherDelete.as_view(), + name='admin.voucher.delete'), + url(r'^global/sso/$', global_settings.SSOView.as_view(), name='admin.global.sso'), url(r'^global/sso/(?P\d+)/delete/$', global_settings.DeleteOAuthApplicationView.as_view(), name='admin.global.sso.delete'), url(r'^pages/$', pages.PageList.as_view(), name="admin.pages"), diff --git a/src/pretix/control/views/admin.py b/src/pretix/control/views/admin.py index 6508cdbab..ff4b204a5 100644 --- a/src/pretix/control/views/admin.py +++ b/src/pretix/control/views/admin.py @@ -3,15 +3,20 @@ from cron_descriptor import Options, get_description from django.conf import settings from django.contrib import messages -from django.http import HttpResponseRedirect +from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.urls import reverse from django.utils.formats import date_format from django.utils.functional import cached_property -from django.views.generic import ListView, TemplateView +from django.utils.translation import gettext_lazy as _ +from django.views.generic import ( + CreateView, DeleteView, ListView, TemplateView, UpdateView, +) from django_celery_beat.models import PeriodicTask, PeriodicTasks from pretix.base.models import Organizer +from pretix.base.models.vouchers import InvoiceVoucher +from pretix.control.forms.admin.vouchers import InvoiceVoucherForm from pretix.control.forms.filter import OrganizerFilterForm, TaskFilterForm from pretix.control.permissions import AdministratorPermissionRequiredMixin from pretix.control.views import PaginationMixin @@ -127,3 +132,113 @@ def post(self, request, *args, **kwargs): ) return HttpResponseRedirect(reverse('control:admin.task_management')) + + +class VoucherList(PaginationMixin, AdministratorPermissionRequiredMixin, ListView): + model = InvoiceVoucher + context_object_name = 'vouchers' + template_name = 'pretixcontrol/admin/vouchers/index.html' + + def get_queryset(self): + qs = InvoiceVoucher.objects.all() + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + return ctx + + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + +class VoucherCreate(AdministratorPermissionRequiredMixin, CreateView): + model = InvoiceVoucher + template_name = 'pretixcontrol/admin/vouchers/detail.html' + context_object_name = 'voucher' + + def get_form_class(self): + form_class = InvoiceVoucherForm + return form_class + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['currency'] = settings.DEFAULT_CURRENCY + return ctx + + def get_success_url(self) -> str: + return reverse('control:admin.vouchers') + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + return kwargs + + def form_valid(self, form): + req = super().form_valid(form) + return req + + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + + +class VoucherUpdate(AdministratorPermissionRequiredMixin, UpdateView): + model = InvoiceVoucher + template_name = 'pretixcontrol/admin/vouchers/detail.html' + context_object_name = 'voucher' + + def get_form_class(self): + form_class = InvoiceVoucherForm + return form_class + + def get_object(self, queryset=None) -> InvoiceVoucherForm: + try: + return InvoiceVoucher.objects.get( + id=self.kwargs['voucher'] + ) + except InvoiceVoucher.DoesNotExist: + raise Http404(_("The requested voucher does not exist.")) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['currency'] = settings.DEFAULT_CURRENCY + return ctx + + def form_valid(self, form): + messages.success(self.request, _('Your changes have been saved.')) + return super().form_valid(form) + + def get_success_url(self) -> str: + return reverse('control:admin.vouchers') + + +class VoucherDelete(AdministratorPermissionRequiredMixin, DeleteView): + model = InvoiceVoucher + template_name = 'pretixcontrol/admin/vouchers/delete.html' + context_object_name = 'invoice_voucher' + + def get_object(self, queryset=None) -> InvoiceVoucher: + try: + return InvoiceVoucher.objects.get( + id=self.kwargs['voucher'] + ) + except InvoiceVoucher.DoesNotExist: + raise Http404(_("The requested voucher does not exist.")) + + def get(self, request, *args, **kwargs): + if self.get_object().redeemed > 0: + messages.error(request, _('A voucher can not be deleted if it already has been redeemed.')) + return HttpResponseRedirect(self.get_success_url()) + return super().get(request, *args, **kwargs) + + def form_valid(self, form): + self.object = self.get_object() + success_url = self.get_success_url() + + if self.object.redeemed > 0: + messages.error(self.request, _('A voucher can not be deleted if it already has been redeemed.')) + else: + self.object.delete() + messages.success(self.request, _('The selected voucher has been deleted.')) + return HttpResponseRedirect(success_url) + + def get_success_url(self) -> str: + return reverse('control:admin.vouchers') diff --git a/src/pretix/static/eventyay-common/scss/_voucher.scss b/src/pretix/static/eventyay-common/scss/_voucher.scss new file mode 100644 index 000000000..97e814350 --- /dev/null +++ b/src/pretix/static/eventyay-common/scss/_voucher.scss @@ -0,0 +1,28 @@ +#id_event_effect, #id_organizer_effect { + width: 100%; + max-height: 200px; + border: 1px solid #ccc; + border-radius: 5px; + padding: 8px; + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 5px; + } + + &::-webkit-scrollbar-thumb { + background: #888; + border-radius: 5px; + } + + &::-webkit-scrollbar-thumb:hover { + background: #555; + } + + overflow-y: auto; + overflow-x: hidden; +} diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index 4495fb3fe..1f1acd722 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -16,6 +16,7 @@ @import "../../select2/select2.scss"; @import "../../select2/select2_bootstrap.scss"; @import "../../colorpicker/bootstrap-colorpicker.scss"; +@import "../../eventyay-common/scss/_voucher.scss"; @import "../../eventyay-common/scss/custom.scss"; /* See https://github.com/pretix/pretix/pull/761 */