Skip to content

Commit 87dc76f

Browse files
odkhanglcduong
andauthored
Implement voucher per event and for all events of an organizer | Create Voucher (#473)
* Implement voucher for invoise create/update pages * Implement invoice voucher delete view * show currency in invoice voucher page update * optimize import * change migration file name * fix isort, flake8 --------- Co-authored-by: lcduong <[email protected]>
1 parent 6746eef commit 87dc76f

File tree

12 files changed

+516
-2
lines changed

12 files changed

+516
-2
lines changed
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Generated by Django 5.1.3 on 2024-11-12 08:04
2+
3+
from django.db import migrations, models
4+
5+
import pretix.base.models.base
6+
import pretix.base.models.vouchers
7+
8+
9+
class Migration(migrations.Migration):
10+
11+
dependencies = [
12+
("pretixbase", "0005_page_alter_cachedcombinedticket_id_and_more"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="InvoiceVoucher",
18+
fields=[
19+
(
20+
"id",
21+
models.BigAutoField(
22+
auto_created=True, primary_key=True, serialize=False
23+
),
24+
),
25+
(
26+
"code",
27+
models.CharField(
28+
db_index=True,
29+
default=pretix.base.models.vouchers.generate_code,
30+
max_length=255,
31+
unique=True,
32+
),
33+
),
34+
("max_usages", models.PositiveIntegerField(default=1)),
35+
("redeemed", models.PositiveIntegerField(default=0)),
36+
(
37+
"budget",
38+
models.DecimalField(decimal_places=2, max_digits=10, null=True),
39+
),
40+
(
41+
"valid_until",
42+
models.DateTimeField(blank=True, db_index=True, null=True),
43+
),
44+
("price_mode", models.CharField(default="none", max_length=100)),
45+
(
46+
"value",
47+
models.DecimalField(decimal_places=2, max_digits=10, null=True),
48+
),
49+
("created_at", models.DateTimeField(auto_now_add=True)),
50+
("created_by", models.CharField(default="system", max_length=50)),
51+
("updated_at", models.DateTimeField(auto_now=True)),
52+
("updated_by", models.CharField(default="system", max_length=50)),
53+
(
54+
"limit_events",
55+
models.ManyToManyField(
56+
related_name="invoice_vouchers", to="pretixbase.event"
57+
),
58+
),
59+
(
60+
"limit_organizer",
61+
models.ManyToManyField(
62+
related_name="invoice_vouchers", to="pretixbase.organizer"
63+
),
64+
),
65+
],
66+
options={
67+
"verbose_name": "Invoice Voucher",
68+
"verbose_name_plural": "Invoice Vouchers",
69+
"ordering": ("code",),
70+
},
71+
bases=(models.Model, pretix.base.models.base.LoggingMixin),
72+
),
73+
]

src/pretix/base/models/vouchers.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,3 +503,83 @@ def budget_used(self):
503503
]
504504
).aggregate(s=Sum(F('price_before_voucher') - F('price')))['s'] or Decimal('0.00')
505505
return ops
506+
507+
508+
class InvoiceVoucher(LoggedModel):
509+
PRICE_MODES = (
510+
('none', _('No effect')),
511+
('set', _('Set product price to')),
512+
('subtract', _('Subtract from product price')),
513+
('percent', _('Reduce product price by (%)')),
514+
)
515+
code = models.CharField(
516+
verbose_name=_("Voucher code"),
517+
max_length=255, default=generate_code,
518+
db_index=True,
519+
validators=[MinLengthValidator(5)],
520+
unique=True
521+
)
522+
max_usages = models.PositiveIntegerField(
523+
verbose_name=_("Maximum usages"),
524+
help_text=_("Number of times this voucher can be redeemed."),
525+
default=1
526+
)
527+
redeemed = models.PositiveIntegerField(
528+
verbose_name=_("Redeemed"),
529+
default=0
530+
)
531+
budget = models.DecimalField(
532+
verbose_name=_("Maximum discount budget"),
533+
help_text=_("This is the maximum monetary amount that will be "
534+
"discounted using this voucher across all usages."),
535+
decimal_places=2, max_digits=10,
536+
null=True, blank=True
537+
)
538+
valid_until = models.DateTimeField(
539+
blank=True, null=True, db_index=True,
540+
verbose_name=_("Valid until")
541+
)
542+
price_mode = models.CharField(
543+
verbose_name=_("Price mode"),
544+
max_length=100,
545+
choices=PRICE_MODES,
546+
default='none'
547+
)
548+
value = models.DecimalField(
549+
verbose_name=_("Voucher value"),
550+
decimal_places=2, max_digits=10, null=True, blank=True,
551+
)
552+
553+
limit_events = models.ManyToManyField(
554+
'Event',
555+
verbose_name=_("Limit to events"),
556+
blank=True,
557+
related_name='invoice_vouchers'
558+
)
559+
560+
limit_organizer = models.ManyToManyField(
561+
'Organizer',
562+
verbose_name=_("Limit to Organizer"),
563+
blank=True,
564+
related_name='invoice_vouchers'
565+
)
566+
567+
created_at = models.DateTimeField(auto_now_add=True)
568+
created_by = models.CharField(max_length=50, default="system")
569+
updated_at = models.DateTimeField(auto_now=True)
570+
updated_by = models.CharField(max_length=50, default="system")
571+
572+
class Meta:
573+
verbose_name = _("Invoice Voucher")
574+
verbose_name_plural = _("Invoice Vouchers")
575+
ordering = ('code', )
576+
577+
def __str__(self):
578+
return self.code
579+
580+
def is_active(self):
581+
if self.redeemed >= self.max_usages:
582+
return False
583+
if self.valid_until and self.valid_until < now():
584+
return False
585+
return True

src/pretix/control/forms/admin/__init__.py

Whitespace-only changes.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
from django import forms
2+
from django.utils.translation import gettext_lazy as _
3+
from django_scopes import scopes_disabled
4+
5+
from pretix.base.forms import I18nModelForm
6+
from pretix.base.forms.widgets import SplitDateTimePickerWidget
7+
from pretix.base.models import Event, Organizer
8+
from pretix.base.models.vouchers import InvoiceVoucher
9+
from pretix.control.forms import SplitDateTimeField
10+
11+
12+
class InvoiceVoucherForm(I18nModelForm):
13+
event_effect = forms.ModelMultipleChoiceField(
14+
queryset=Event.objects.none(),
15+
widget=forms.CheckboxSelectMultiple,
16+
required=False,
17+
label=_("Event effect"),
18+
help_text=_("The voucher will only be valid for the selected events.")
19+
)
20+
organizer_effect = forms.ModelMultipleChoiceField(
21+
queryset=Organizer.objects.none(),
22+
widget=forms.CheckboxSelectMultiple,
23+
required=False,
24+
label=_("Organizer effect"),
25+
help_text=_("The voucher will be valid for all events under the selected organizers.")
26+
)
27+
28+
class Meta:
29+
model = InvoiceVoucher
30+
localized_fields = '__all__'
31+
fields = [
32+
'code', 'valid_until', 'value', 'max_usages', 'price_mode', 'budget', 'event_effect', 'organizer_effect'
33+
]
34+
field_classes = {
35+
'valid_until': SplitDateTimeField,
36+
}
37+
widgets = {
38+
'valid_until': SplitDateTimePickerWidget(),
39+
}
40+
41+
def __init__(self, *args, **kwargs):
42+
instance = kwargs.get('instance')
43+
super().__init__(*args, **kwargs)
44+
if instance:
45+
self.fields['event_effect'].initial = instance.limit_events.all()
46+
self.fields['organizer_effect'].initial = instance.limit_organizer.all()
47+
with scopes_disabled():
48+
self.fields['event_effect'].queryset = Event.objects.all()
49+
self.fields['organizer_effect'].queryset = Organizer.objects.all()
50+
51+
def clean(self):
52+
data = super().clean()
53+
return data
54+
55+
def save(self, commit=True):
56+
instance = super().save(commit=False)
57+
58+
if commit:
59+
instance.save()
60+
61+
instance.limit_events.set(self.cleaned_data.get('event_effect', []))
62+
instance.limit_organizer.set(self.cleaned_data.get('organizer_effect', []))
63+
64+
if commit:
65+
self.save_m2m()
66+
67+
return instance

src/pretix/control/navigation.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,12 @@ def get_admin_navigation(request):
549549
},
550550
]
551551
},
552+
{
553+
'label': _('Vouchers'),
554+
'url': reverse('control:admin.vouchers'),
555+
'active': 'vouchers' in url.url_name,
556+
'icon': 'tags',
557+
},
552558
{
553559
'label': _('Global settings'),
554560
'url': reverse('control:admin.global.settings'),
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{% extends "pretixcontrol/admin/base.html" %}
2+
{% load i18n %}
3+
{% load bootstrap3 %}
4+
{% block title %}{% trans "Delete voucher" %}{% endblock %}
5+
{% block content %}
6+
<h1>{% trans "Delete voucher" %}</h1>
7+
<form action="" method="post" class="form-horizontal">
8+
{% csrf_token %}
9+
<p>{% blocktrans %}Are you sure you want to delete the voucher <strong>{{ invoice_voucher }}</strong>?{% endblocktrans %}</p>
10+
<div class="form-group submit-group">
11+
<a href='{% url "control:admin.vouchers" %}' class="btn btn-default btn-cancel">
12+
{% trans "Cancel" %}
13+
</a>
14+
<button type="submit" class="btn btn-delete btn-danger btn-save">
15+
{% trans "Delete" %}
16+
</button>
17+
</div>
18+
</form>
19+
{% endblock %}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{% extends "pretixcontrol/admin/base.html" %}
2+
{% load i18n %}
3+
{% load bootstrap3 %}
4+
{% block title %}{% trans "Voucher" %}{% endblock %}
5+
{% block content %}
6+
<h1>{% trans "Voucher" %}</h1>
7+
{% if voucher.redeemed %}
8+
<div class="alert alert-warning">
9+
{% trans "This voucher already has been used. It is not recommended to modify it." %}
10+
</div>
11+
{% endif %}
12+
<form action="" method="post" class="form-horizontal">
13+
{% csrf_token %}
14+
{% bootstrap_form_errors form %}
15+
<div class="row">
16+
<div class="col-xs-12 col-lg-10">
17+
<fieldset>
18+
<legend>{% trans "Voucher details" %}</legend>
19+
{% bootstrap_field form.code layout="control" %}
20+
{% bootstrap_field form.max_usages layout="control" %}
21+
{% bootstrap_field form.valid_until layout="control" %}
22+
<div class="form-group">
23+
<label class="col-md-3 control-label" >{% trans "Price effect" %}</label>
24+
<div class="col-md-5">
25+
{% bootstrap_field form.price_mode show_label=False form_group_class="" %}
26+
</div>
27+
<div class="col-md-4">
28+
{% bootstrap_field form.value show_label=False form_group_class="" %}
29+
</div>
30+
</div>
31+
{% bootstrap_field form.budget addon_after=currency layout="control" %}
32+
{% bootstrap_field form.event_effect layout="control" %}
33+
{% bootstrap_field form.organizer_effect layout="control" %}
34+
</fieldset>
35+
</div>
36+
</div>
37+
<div class="form-group submit-group">
38+
<button type="submit" class="btn btn-primary btn-save">
39+
{% trans "Save" %}
40+
</button>
41+
</div>
42+
</form>
43+
{% endblock %}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
{% extends "pretixcontrol/admin/base.html" %}
2+
{% load i18n %}
3+
{% load bootstrap3 %}
4+
{% load urlreplace %}
5+
{% load money %}
6+
{% block title %}{% trans "Vouchers" %}{% endblock %}
7+
{% block content %}
8+
<h1>{% trans "Vouchers" %}</h1>
9+
{% if vouchers|length == 0 %}
10+
<div class="empty-collection">
11+
<p>
12+
{% blocktrans trimmed %}
13+
You haven't created any vouchers yet.
14+
{% endblocktrans %}
15+
</p>
16+
17+
<a href='{% url "control:admin.vouchers.add" %}'
18+
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new voucher" %}</a>
19+
</div>
20+
{% else %}
21+
<p>
22+
<a href='{% url "control:admin.vouchers.add" %}'
23+
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new voucher" %}</a>
24+
</p>
25+
<form action='{% url "control:admin.vouchers" %}' method="post">
26+
{% csrf_token %}
27+
<div class="table-responsive">
28+
<table class="table table-hover table-quotas">
29+
<thead>
30+
<tr>
31+
<th>
32+
{% trans "Voucher code" %}
33+
<a href="?{% url_replace request 'ordering' '-code' %}"><i class="fa fa-caret-down"></i></a>
34+
<a href="?{% url_replace request 'ordering' 'code' %}"><i class="fa fa-caret-up"></i></a>
35+
</th>
36+
<th>
37+
{% trans "Redemptions" %}
38+
<a href="?{% url_replace request 'ordering' '-redeemed' %}"><i class="fa fa-caret-down"></i></a>
39+
<a href="?{% url_replace request 'ordering' 'redeemed' %}"><i class="fa fa-caret-up"></i></a>
40+
</th>
41+
<th>
42+
{% trans "Expiry" %}
43+
<a href="?{% url_replace request 'ordering' '-valid_until' %}"><i class="fa fa-caret-down"></i></a>
44+
<a href="?{% url_replace request 'ordering' 'valid_until' %}"><i class="fa fa-caret-up"></i></a>
45+
</th>
46+
<th></th>
47+
</tr>
48+
</thead>
49+
<tbody>
50+
{% for v in vouchers %}
51+
<tr>
52+
<td>
53+
{% if not v.is_active %}
54+
<del>
55+
{% endif %}
56+
<strong><a href='{% url "control:admin.voucher" voucher=v.id %}'>{{ v.code }}</a></strong>
57+
{% if not v.is_active %}
58+
</del>
59+
{% endif %}
60+
</td>
61+
<td>
62+
{{ v.redeemed }} / {{ v.max_usages }}
63+
</td>
64+
<td>{{ v.valid_until|date }}</td>
65+
<td class="text-right flip">
66+
<a href='{% url "control:admin.voucher.delete" voucher=v.id %}' class="btn btn-delete btn-danger btn-sm"><i class="fa fa-trash"></i></a>
67+
</td>
68+
</tr>
69+
{% endfor %}
70+
</tbody>
71+
</table>
72+
</div>
73+
</form>
74+
{% include "pretixcontrol/pagination.html" %}
75+
{% endif %}
76+
{% endblock %}

src/pretix/control/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,12 @@
347347
url(r'^global/settings/$', global_settings.GlobalSettingsView.as_view(), name='admin.global.settings'),
348348
url(r'^global/update/$', global_settings.UpdateCheckView.as_view(), name='admin.global.update'),
349349
url(r'^global/message/$', global_settings.MessageView.as_view(), name='admin.global.message'),
350+
url(r'^vouchers/$', admin.VoucherList.as_view(), name='admin.vouchers'),
351+
url(r'^vouchers/add$', admin.VoucherCreate.as_view(), name='admin.vouchers.add'),
352+
url(r'^vouchers/(?P<voucher>\d+)/$', admin.VoucherUpdate.as_view(), name='admin.voucher'),
353+
url(r'^vouchers/(?P<voucher>\d+)/delete$', admin.VoucherDelete.as_view(),
354+
name='admin.voucher.delete'),
355+
350356
url(r'^global/sso/$', global_settings.SSOView.as_view(), name='admin.global.sso'),
351357
url(r'^global/sso/(?P<pk>\d+)/delete/$', global_settings.DeleteOAuthApplicationView.as_view(), name='admin.global.sso.delete'),
352358
url(r'^pages/$', pages.PageList.as_view(), name="admin.pages"),

0 commit comments

Comments
 (0)