diff --git a/src/pretix/base/migrations/0135_auto_20190910_2020.py b/src/pretix/base/migrations/0135_auto_20190910_2020.py index 8db9543e2a..ec4803180e 100644 --- a/src/pretix/base/migrations/0135_auto_20190910_2020.py +++ b/src/pretix/base/migrations/0135_auto_20190910_2020.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): ('issuance', models.DateTimeField(auto_now_add=True)), ('secret', models.CharField(db_index=True, default=pretix.base.models.giftcards.gen_giftcard_secret, max_length=190, unique=True)), ('currency', models.CharField(max_length=10)), - ('issued_in', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_gift_cards', to='pretixbase.OrderPosition')), + ('issued_in', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_gift_cards', to='pretixbase.OrderPosition', null=True, blank=True)), ('issuer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_gift_cards', to='pretixbase.Organizer')), ], ), diff --git a/src/pretix/base/migrations/0136_auto_20190918_1537.py b/src/pretix/base/migrations/0136_auto_20190918_1537.py new file mode 100644 index 0000000000..fe56026023 --- /dev/null +++ b/src/pretix/base/migrations/0136_auto_20190918_1537.py @@ -0,0 +1,29 @@ +# Generated by Django 2.2.1 on 2019-09-18 15:37 + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.fields + + +def fwd(app, schema_editor): + Team = app.get_model('pretixbase', 'Team') + Team.objects.filter(can_change_organizer_settings=True).update(can_manage_gift_cards=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0135_auto_20190910_2020'), + ] + + operations = [ + migrations.AddField( + model_name='team', + name='can_manage_gift_cards', + field=models.BooleanField(default=False), + ), + migrations.RunPython( + fwd, migrations.RunPython.noop + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index b47cd32b54..55ccff0ecd 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -730,7 +730,7 @@ class Event(EventMixin, LoggedModel): def has_payment_provider(self): result = False for provider in self.get_payment_providers().values(): - if provider.is_enabled and provider.identifier not in ('free', 'boxoffice', 'offsetting'): + if provider.is_enabled and provider.identifier not in ('free', 'boxoffice', 'offsetting', 'giftcard'): result = True break return result diff --git a/src/pretix/base/models/giftcards.py b/src/pretix/base/models/giftcards.py index ac68d6cdbf..83d6f36c8d 100644 --- a/src/pretix/base/models/giftcards.py +++ b/src/pretix/base/models/giftcards.py @@ -4,6 +4,9 @@ from django.conf import settings from django.db import models from django.db.models import Sum from django.utils.crypto import get_random_string +from django.utils.translation import ugettext_lazy as _ + +from pretix.base.models import LoggedModel def gen_giftcard_secret(): @@ -27,7 +30,7 @@ class GiftCardAcceptance(models.Model): ) -class GiftCard(models.Model): +class GiftCard(LoggedModel): issuer = models.ForeignKey( 'Organizer', related_name='issued_gift_cards', @@ -37,6 +40,7 @@ class GiftCard(models.Model): 'OrderPosition', related_name='issued_gift_cards', on_delete=models.PROTECT, + null=True, blank=True ) issuance = models.DateTimeField( auto_now_add=True, @@ -46,8 +50,13 @@ class GiftCard(models.Model): default=gen_giftcard_secret, unique=True, db_index=True, + verbose_name=_('Gift card code'), ) - currency = models.CharField(max_length=10) + CURRENCY_CHOICES = [(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES] + currency = models.CharField(max_length=10, choices=CURRENCY_CHOICES) + + def __str__(self): + return self.secret @property def value(self): @@ -88,3 +97,6 @@ class GiftCardTransaction(models.Model): blank=True, on_delete=models.PROTECT ) + + class Meta: + ordering = ("datetime",) diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index 5ca7ca8be3..7d9dc090a3 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -156,6 +156,10 @@ class Team(LoggedModel): help_text=_('Someone with this setting can get access to most data of all of your events, i.e. via privacy ' 'reports, so be careful who you add to this team!') ) + can_manage_gift_cards = models.BooleanField( + default=False, + verbose_name=_("Can manage gift cards") + ) can_change_event_settings = models.BooleanField( default=False, diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index a1c87926a5..3f103f23cc 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -502,6 +502,29 @@ class OrganizerFilterForm(FilterForm): return qs +class GiftCardFilterForm(FilterForm): + query = forms.CharField( + label=_('Search query'), + widget=forms.TextInput(attrs={ + 'placeholder': _('Search query'), + 'autofocus': 'autofocus' + }), + required=False + ) + + def __init__(self, *args, **kwargs): + kwargs.pop('request') + super().__init__(*args, **kwargs) + + def filter_qs(self, qs): + fdata = self.cleaned_data + + if fdata.get('query'): + query = fdata.get('query') + qs = qs.filter(secret__icontains=query) + return qs + + class EventFilterForm(FilterForm): orders = { 'slug': 'slug', diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index 8f7c965160..9164fc3a33 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -4,6 +4,7 @@ from django import forms from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import RegexValidator +from django.db.models import Q from django.utils.safestring import mark_safe from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django_scopes.forms import SafeModelMultipleChoiceField @@ -12,7 +13,7 @@ from i18nfield.forms import I18nFormField, I18nTextarea from pretix.api.models import WebHook from pretix.api.webhooks import get_all_webhook_events from pretix.base.forms import I18nModelForm, SettingsForm -from pretix.base.models import Device, Organizer, Team +from pretix.base.models import Device, GiftCard, Organizer, Team from pretix.control.forms import ( ExtFileField, FontSelect, MultipleLanguagesWidget, ) @@ -145,6 +146,7 @@ class TeamForm(forms.ModelForm): model = Team fields = ['name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams', 'can_change_organizer_settings', + 'can_manage_gift_cards', 'can_change_event_settings', 'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers', 'can_change_vouchers'] @@ -328,3 +330,29 @@ class WebHookForm(forms.ModelForm): field_classes = { 'limit_events': SafeModelMultipleChoiceField } + + +class GiftCardCreateForm(forms.ModelForm): + value = forms.DecimalField( + label=_('Gift card value') + ) + + def __init__(self, *args, **kwargs): + self.organizer = kwargs.pop('organizer') + super().__init__(*args, **kwargs) + + def clean_secret(self): + s = self.cleaned_data['secret'] + if GiftCard.objects.filter( + secret=s + ).filter( + Q(issuer=self.organizer) | Q(issuer__gift_card_collector_acceptance__collector=self.organizer) + ).exists(): + raise ValidationError( + _('A gift card with the same secret already exists in your or an affiliated organizer account.') + ) + return s + + class Meta: + model = GiftCard + fields = ['secret', 'currency'] diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index 8428741693..a7e4d13df4 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -437,6 +437,16 @@ def get_organizer_navigation(request): 'active': 'organizer.device' in url.url_name, 'icon': 'tablet', }) + if 'can_manage_gift_cards' in request.orgapermset: + nav.append({ + 'label': _('Gift cards'), + 'url': reverse('control:organizer.giftcards', kwargs={ + 'organizer': request.organizer.slug + }), + 'active': 'organizer.giftcard' in url.url_name, + 'icon': 'credit-card', + }) + if 'can_change_organizer_settings' in request.orgapermset: nav.append({ 'label': _('Webhooks'), 'url': reverse('control:organizer.webhooks', kwargs={ diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html new file mode 100644 index 0000000000..2f2cb8b3ba --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcard.html @@ -0,0 +1,80 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load money %} +{% block inner %} +

+ {% blocktrans trimmed with card=card.secret %} + Gift card: {{ card }} + {% endblocktrans %} +

+
+
+

+ {% trans "Details" %} +

+
+
+
+
{% trans "Gift card code" %}
+
{{ card.secret }}
+
{% trans "Creation date" %}
+
{{ card.issuance|date:"SHORT_DATETIME_FORMAT" }}
+
{% trans "Current value" %}
+
{{ card.value|money:card.currency }}
+
{% trans "Currency" %}
+
{{ card.currency }}
+
+
+
+
+
+

+ {% trans "Transactions" %} +

+
+ + + + + + + + + + {% for t in card.transactions.all %} + + + + + + {% endfor %} + + + + + + + + + +
{% trans "Date" %}{% trans "Order" %}{% trans "Value" %}
{{ t.datetime|date:"SHORT_DATETIME_FORMAT" }} + {% if t.order %} + + {{ t.order.full_code }} + {% else %} + {% trans "Manual transaction" %} + {% endif %} + + {{ t.value|money:card.currency }} +
+
+ {% csrf_token %} + + +
+
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcard_create.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcard_create.html new file mode 100644 index 0000000000..bc81beb2a4 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcard_create.html @@ -0,0 +1,18 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block inner %} +

{% trans "Create a new gift card" %}

+
+ {% csrf_token %} + {% bootstrap_form_errors form %} + {% bootstrap_field form.secret layout="control" %} + {% bootstrap_field form.value layout="control" %} + {% bootstrap_field form.currency layout="control" %} +
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html new file mode 100644 index 0000000000..34ce6ccf15 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/organizers/giftcards.html @@ -0,0 +1,74 @@ +{% extends "pretixcontrol/organizers/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% load money %} +{% block inner %} +

+ {% trans "Issued gift cards" %} +

+ {% if giftcards|length == 0 and not filter_form.filtered %} +
+

+ {% blocktrans trimmed %} + You haven't issued any gift cards yet. You can either set up a product in an event shop to sell gift cards, + or you can manually issue gift cards. + {% endblocktrans %} +

+ + {% trans "Manually issue a gift card" %} +
+ {% else %} +
+
+ {% bootstrap_field filter_form.query layout='inline' %} +
+
+ +
+
+

+ {% trans "Manually issue a gift card" %} +

+
+ + + + + + + + + + + {% for g in giftcards %} + + + + + + + {% endfor %} + +
{% trans "Gift card code" %}{% trans "Creation date" %}{% trans "Current value" %}
+ + {{ g.secret }} + + {{ g.issuance|date:"SHORT_DATETIME_FORMAT" }} + {{ g.cached_value|money:g.currency }} + + + + +
+
+ {% include "pretixcontrol/pagination.html" %} + {% endif %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html b/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html index 3c332f57f9..519e58ec4b 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/team_edit.html @@ -22,6 +22,7 @@
{% trans "Organizer permissions" %} {% bootstrap_field form.can_create_events layout="control" %} + {% bootstrap_field form.can_manage_gift_cards layout="control" %} {% bootstrap_field form.can_change_teams layout="control" %} {% bootstrap_field form.can_change_organizer_settings layout="control" %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 33fa0c2628..0f44387415 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -75,6 +75,9 @@ urlpatterns = [ url(r'^organizer/(?P[^/]+)/delete$', organizer.OrganizerDelete.as_view(), name='organizer.delete'), url(r'^organizer/(?P[^/]+)/settings/display$', organizer.OrganizerDisplaySettings.as_view(), name='organizer.display'), + url(r'^organizer/(?P[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'), + url(r'^organizer/(?P[^/]+)/giftcard/add$', organizer.GiftCardCreateView.as_view(), name='organizer.giftcard.add'), + url(r'^organizer/(?P[^/]+)/giftcard/(?P[^/]+)/$', organizer.GiftCardDetailView.as_view(), name='organizer.giftcard'), url(r'^organizer/(?P[^/]+)/webhooks$', organizer.WebHookListView.as_view(), name='organizer.webhooks'), url(r'^organizer/(?P[^/]+)/webhook/add$', organizer.WebHookCreateView.as_view(), name='organizer.webhook.add'), diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index 9edd28c7be..e7bd96f310 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -1,12 +1,13 @@ import json +from decimal import Decimal from django import forms from django.conf import settings from django.contrib import messages -from django.core.exceptions import PermissionDenied +from django.core.exceptions import PermissionDenied, ValidationError from django.core.files import File from django.db import transaction -from django.db.models import Count, Max, Min, ProtectedError +from django.db.models import Count, DecimalField, Max, Min, ProtectedError, Sum from django.db.models.functions import Coalesce, Greatest from django.forms import inlineformset_factory from django.http import JsonResponse @@ -21,14 +22,17 @@ from django.views.generic import ( from pretix.api.models import WebHook from pretix.base.auth import get_auth_backends -from pretix.base.models import Device, Organizer, Team, TeamInvite, User +from pretix.base.models import ( + Device, GiftCard, Organizer, Team, TeamInvite, User, +) from pretix.base.models.event import Event, EventMetaProperty from pretix.base.models.organizer import TeamAPIToken from pretix.base.services.mail import SendMailException, mail -from pretix.control.forms.filter import OrganizerFilterForm +from pretix.control.forms.filter import GiftCardFilterForm, OrganizerFilterForm from pretix.control.forms.organizer import ( - DeviceForm, EventMetaPropertyForm, OrganizerDeleteForm, OrganizerForm, - OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, WebHookForm, + DeviceForm, EventMetaPropertyForm, GiftCardCreateForm, OrganizerDeleteForm, + OrganizerForm, OrganizerSettingsForm, OrganizerUpdateForm, TeamForm, + WebHookForm, ) from pretix.control.permissions import ( AdministratorPermissionRequiredMixin, OrganizerPermissionRequiredMixin, @@ -337,7 +341,7 @@ class OrganizerCreate(CreateView): ret = super().form_valid(form) t = Team.objects.create( organizer=form.instance, name=_('Administrators'), - all_events=True, can_create_events=True, can_change_teams=True, + all_events=True, can_create_events=True, can_change_teams=True, can_manage_gift_cards=True, can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True, can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True ) @@ -898,3 +902,109 @@ class WebHookLogsView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin def get_queryset(self): return self.webhook.calls.order_by('-datetime') + + +class GiftCardListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView): + model = GiftCard + template_name = 'pretixcontrol/organizers/giftcards.html' + permission = 'can_manage_gift_cards' + context_object_name = 'giftcards' + + def get_queryset(self): + qs = self.request.organizer.issued_gift_cards.annotate( + cached_value=Sum('transactions__value') + ) + if self.filter_form.is_valid(): + qs = self.filter_form.filter_qs(qs) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['filter_form'] = self.filter_form + return ctx + + @cached_property + def filter_form(self): + return GiftCardFilterForm(data=self.request.GET, request=self.request) + + +class GiftCardDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView): + template_name = 'pretixcontrol/organizers/giftcard.html' + permission = 'can_manage_gift_cards' + context_object_name = 'card' + + def get_object(self, queryset=None) -> Organizer: + return get_object_or_404( + self.request.organizer.issued_gift_cards, + pk=self.kwargs.get('giftcard') + ) + + @transaction.atomic() + def post(self, request, *args, **kwargs): + self.object = self.get_object() + if 'value' in request.POST: + try: + value = DecimalField().to_python(request.POST.get('value')) + except ValidationError: + messages.error(request, _('Your input was invalid, please try again.')) + else: + if self.object.value + value < Decimal('0.00'): + messages.error(request, _('Gift cards are not allowed to have negative values.')) + else: + self.object.transactions.create( + value=value + ) + self.object.log_action( + 'pretix.giftcards.transaction.manual', + data={ + 'value': value + }, + user=self.request.user, + ) + messages.success(request, _('The manual transaction has been saved.')) + return redirect(reverse( + 'control:organizer.giftcard', + kwargs={ + 'organizer': request.organizer.slug, + 'giftcard': self.object.pk + } + )) + return self.get(request, *args, **kwargs) + + +class GiftCardCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView): + template_name = 'pretixcontrol/organizers/giftcard_create.html' + permission = 'can_manage_gift_cards' + form_class = GiftCardCreateForm + success_url = 'invalid' + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + any_event = self.request.organizer.events.first() + kwargs['initial'] = { + 'currency': any_event.currency if any_event else settings.DEFAULT_CURRENCY + } + kwargs['organizer'] = self.request.organizer + return kwargs + + @transaction.atomic() + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + + def form_valid(self, form): + messages.success(self.request, _('The gift card has been created and can now be used.')) + form.instance.issuer = self.request.organizer + super().form_valid(form) + form.instance.transactions.create( + value=form.cleaned_data['value'] + ) + form.instance.log_action('pretix.giftcards.transaction.manual', user=self.request.user, data={ + 'value': form.cleaned_data['value'] + }) + return redirect(reverse( + 'control:organizer.giftcard', + kwargs={ + 'organizer': self.request.organizer.slug, + 'giftcard': self.object.pk + } + ))