Repository: django-oscar/django-oscar

Issue:
Refactor voucher creation in the dashboard to allow greater flexibility
### Issue Summary

The voucher app provides a very flexible implementation of voucher functionality, where multiple offers can be associated with a voucher. But the dashboard interface for managing vouchers hides this flexibility and only allows the creation of vouchers with a single offer. Meanwhile, on the offer side, only site-wide offers can be created and edited through the dashboard.

### Proposal

I propose to refactor the voucher functionality as follows:

1. Allow CRUD of all types of offer in the dashboard, making it easy to identify the type of the offer (site-wide, voucher, session, user). We can provide a tabbed interface to see the offers of each type. 

    (By default, session and user offers are not applied anywhere - a future enhancement would be to allow associating user offers with particular users/groups of users, instead of requiring projects to implement this themselves.)

2. Refactor the existing voucher creation functionality so that instead of creating a single offer on the fly, the administrator instead selects one or more existing offers (of type voucher) to add to the voucher. This allows the creation of complex vouchers with a combination of offers.

This provides a much more powerful and flexible interface to the voucher functionality that already exists. 
TypeError creating voucher without end-date
In oscar 3.0 beta/master, creating a coupon/voucher , not specifying an end date results in

> TypeError: '>' not supported between instances of 'datetime.datetime' and 'NoneType'


Discussion:
Agree.
Yes that would be nice


Fix patch:
diff --git a/src/oscar/apps/dashboard/offers/apps.py b/src/oscar/apps/dashboard/offers/apps.py
--- a/src/oscar/apps/dashboard/offers/apps.py
+++ b/src/oscar/apps/dashboard/offers/apps.py
@@ -26,12 +26,12 @@ def get_urls(self):
         urls = [
             path('', self.list_view.as_view(), name='offer-list'),
             # Creation
-            path('new/name-and-description/', self.metadata_view.as_view(), name='offer-metadata'),
+            path('new/metadata/', self.metadata_view.as_view(), name='offer-metadata'),
             path('new/condition/', self.condition_view.as_view(), name='offer-condition'),
             path('new/incentive/', self.benefit_view.as_view(), name='offer-benefit'),
             path('new/restrictions/', self.restrictions_view.as_view(), name='offer-restrictions'),
             # Update
-            path('<int:pk>/name-and-description/', self.metadata_view.as_view(update=True), name='offer-metadata'),
+            path('<int:pk>/metadata/', self.metadata_view.as_view(update=True), name='offer-metadata'),
             path('<int:pk>/condition/', self.condition_view.as_view(update=True), name='offer-condition'),
             path('<int:pk>/incentive/', self.benefit_view.as_view(update=True), name='offer-benefit'),
             path('<int:pk>/restrictions/', self.restrictions_view.as_view(update=True), name='offer-restrictions'),
diff --git a/src/oscar/apps/dashboard/offers/forms.py b/src/oscar/apps/dashboard/offers/forms.py
--- a/src/oscar/apps/dashboard/offers/forms.py
+++ b/src/oscar/apps/dashboard/offers/forms.py
@@ -1,6 +1,6 @@
-import datetime
-
 from django import forms
+from django.conf import settings
+from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
 from oscar.core.loading import get_model
@@ -11,10 +11,26 @@
 Benefit = get_model('offer', 'Benefit')
 
 
+def get_offer_type_choices():
+    return (("", "---------"),) + tuple(choice for choice in ConditionalOffer.TYPE_CHOICES
+                                        if choice[0] in [getattr(ConditionalOffer, const_name)
+                                                         for const_name in settings.OSCAR_OFFERS_IMPLEMENTED_TYPES])
+
+
 class MetaDataForm(forms.ModelForm):
+
+    offer_type = forms.ChoiceField(label=_("Type"), choices=get_offer_type_choices)
+
     class Meta:
         model = ConditionalOffer
-        fields = ('name', 'description',)
+        fields = ('name', 'description', 'offer_type')
+
+    def clean_offer_type(self):
+        data = self.cleaned_data['offer_type']
+        if (self.instance.pk is not None) and (self.instance.offer_type == ConditionalOffer.VOUCHER) and \
+           ('offer_type' in self.changed_data) and self.instance.vouchers.exists():
+            raise forms.ValidationError(_("This can only be changed if it has no vouchers attached to it"))
+        return data
 
 
 class RestrictionsForm(forms.ModelForm):
@@ -28,8 +44,7 @@ class RestrictionsForm(forms.ModelForm):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        today = datetime.date.today()
-        self.fields['start_datetime'].initial = today
+        self.fields['start_datetime'].initial = timezone.now()
 
     class Meta:
         model = ConditionalOffer
@@ -188,4 +203,16 @@ def save(self, *args, **kwargs):
 
 class OfferSearchForm(forms.Form):
     name = forms.CharField(required=False, label=_("Offer name"))
-    is_active = forms.BooleanField(required=False, label=_("Is active?"))
+    is_active = forms.NullBooleanField(required=False, label=_("Is active?"), widget=widgets.NullBooleanSelect)
+    offer_type = forms.ChoiceField(required=False, label=_("Offer type"), choices=get_offer_type_choices)
+    has_vouchers = forms.NullBooleanField(required=False, label=_("Has vouchers?"), widget=widgets.NullBooleanSelect)
+    voucher_code = forms.CharField(required=False, label=_("Voucher code"))
+
+    basic_fields = [
+        'name',
+        'is_active',
+    ]
+
+    @property
+    def is_voucher_offer_type(self):
+        return self.is_bound and (self.cleaned_data['offer_type'] == ConditionalOffer.VOUCHER)
diff --git a/src/oscar/apps/dashboard/offers/views.py b/src/oscar/apps/dashboard/offers/views.py
--- a/src/oscar/apps/dashboard/offers/views.py
+++ b/src/oscar/apps/dashboard/offers/views.py
@@ -5,7 +5,7 @@
 from django.core import serializers
 from django.core.serializers.json import DjangoJSONEncoder
 from django.http import HttpResponseRedirect
-from django.shortcuts import get_object_or_404
+from django.shortcuts import get_object_or_404, redirect
 from django.urls import reverse
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
@@ -36,39 +36,53 @@ class OfferListView(ListView):
     paginate_by = settings.OSCAR_DASHBOARD_ITEMS_PER_PAGE
 
     def get_queryset(self):
-        qs = self.model._default_manager.exclude(
-            offer_type=ConditionalOffer.VOUCHER)
-        qs = sort_queryset(qs, self.request,
-                           ['name', 'start_datetime', 'end_datetime',
-                            'num_applications', 'total_discount'])
+        self.search_filters = []
+        qs = self.model._default_manager.all()
+        qs = sort_queryset(qs, self.request, ['name', 'offer_type', 'start_datetime', 'end_datetime',
+                                              'num_applications', 'total_discount'])
 
-        self.description = _("All offers")
-
-        # We track whether the queryset is filtered to determine whether we
-        # show the search form 'reset' button.
-        self.is_filtered = False
         self.form = self.form_class(self.request.GET)
-        if not self.form.is_valid():
+        # This form is exactly the same as the other one, apart from having
+        # fields with different IDs, so that they are unique within the page
+        # (non-unique field IDs also break Select2)
+        self.advanced_form = self.form_class(self.request.GET, auto_id='id_advanced_%s')
+        if not all([self.form.is_valid(), self.advanced_form.is_valid()]):
             return qs
 
-        data = self.form.cleaned_data
-
-        if data['name']:
-            qs = qs.filter(name__icontains=data['name'])
-            self.description = _("Offers matching '%s'") % data['name']
-            self.is_filtered = True
-        if data['is_active']:
-            self.is_filtered = True
-            today = timezone.now()
-            qs = qs.filter(start_datetime__lte=today, end_datetime__gte=today)
+        name = self.form.cleaned_data['name']
+        offer_type = self.form.cleaned_data['offer_type']
+        is_active = self.form.cleaned_data['is_active']
+        has_vouchers = self.form.cleaned_data['has_vouchers']
+        voucher_code = self.form.cleaned_data['voucher_code']
+
+        if name:
+            qs = qs.filter(name__icontains=name)
+            self.search_filters.append(_('Name matches "%s"') % name)
+        if is_active is not None:
+            now = timezone.now()
+            if is_active:
+                qs = qs.filter(start_datetime__lte=now, end_datetime__gte=now)
+                self.search_filters.append(_("Is active"))
+            else:
+                qs = qs.filter(end_datetime__lt=now)
+                self.search_filters.append(_("Is inactive"))
+        if offer_type:
+            qs = qs.filter(offer_type=offer_type)
+            self.search_filters.append(_('Is of type "%s"') % dict(ConditionalOffer.TYPE_CHOICES)[offer_type])
+        if has_vouchers is not None:
+            qs = qs.filter(vouchers__isnull=not has_vouchers).distinct()
+            self.search_filters.append(_("Has vouchers") if has_vouchers else _("Has no vouchers"))
+        if voucher_code:
+            qs = qs.filter(vouchers__code__icontains=voucher_code).distinct()
+            self.search_filters.append(_('Voucher code matches "%s"') % voucher_code)
 
         return qs
 
     def get_context_data(self, **kwargs):
         ctx = super().get_context_data(**kwargs)
-        ctx['queryset_description'] = self.description
         ctx['form'] = self.form
-        ctx['is_filtered'] = self.is_filtered
+        ctx['advanced_form'] = self.advanced_form
+        ctx['search_filters'] = self.search_filters
         return ctx
 
 
@@ -224,10 +238,11 @@ def form_valid(self, form):
             return super().form_valid(form)
 
     def save_offer(self, offer):
-        # We update the offer with the name/description from step 1
+        # We update the offer with the name/description/offer_type from step 1
         session_offer = self._fetch_session_offer()
         offer.name = session_offer.name
         offer.description = session_offer.description
+        offer.offer_type = session_offer.offer_type
 
         # Save the related models, then save the offer.
         # Note than you can save already on the first page of the wizard,
@@ -279,7 +294,7 @@ def get_instance(self):
         return self.offer
 
     def get_title(self):
-        return _("Name and description")
+        return _("Name, description and type")
 
 
 class OfferBenefitView(OfferWizardStepView):
@@ -333,6 +348,13 @@ class OfferDeleteView(DeleteView):
     template_name = 'oscar/dashboard/offers/offer_delete.html'
     context_object_name = 'offer'
 
+    def dispatch(self, request, *args, **kwargs):
+        offer = self.get_object()
+        if offer.vouchers.exists():
+            messages.warning(request, _("This offer can only be deleted if it has no vouchers attached to it"))
+            return redirect('dashboard:offer-detail', pk=offer.pk)
+        return super().dispatch(request, *args, **kwargs)
+
     def get_success_url(self):
         messages.success(self.request, _("Offer deleted!"))
         return reverse('dashboard:offer-list')
diff --git a/src/oscar/apps/dashboard/views.py b/src/oscar/apps/dashboard/views.py
--- a/src/oscar/apps/dashboard/views.py
+++ b/src/oscar/apps/dashboard/views.py
@@ -46,15 +46,6 @@ def get_context_data(self, **kwargs):
         ctx.update(self.get_stats())
         return ctx
 
-    def get_active_site_offers(self):
-        """
-        Return active conditional offers of type "site offer". The returned
-        ``Queryset`` of site offers is filtered by end date greater then
-        the current date.
-        """
-        return ConditionalOffer.objects.filter(
-            end_datetime__gt=now(), offer_type=ConditionalOffer.SITE)
-
     def get_active_vouchers(self):
         """
         Get all active vouchers. The returned ``Queryset`` of vouchers
@@ -188,7 +179,10 @@ def get_stats(self):
         }
         if user.is_staff:
             stats.update(
-                total_site_offers=self.get_active_site_offers().count(),
+                offer_maps=(ConditionalOffer.objects.filter(end_datetime__gt=now())
+                                                    .values('offer_type')
+                                                    .annotate(count=Count('id'))
+                                                    .order_by('offer_type')),
                 total_vouchers=self.get_active_vouchers().count(),
             )
         return stats
diff --git a/src/oscar/apps/dashboard/vouchers/apps.py b/src/oscar/apps/dashboard/vouchers/apps.py
--- a/src/oscar/apps/dashboard/vouchers/apps.py
+++ b/src/oscar/apps/dashboard/vouchers/apps.py
@@ -29,6 +29,8 @@ def ready(self):
             'dashboard.vouchers.views', 'VoucherSetDetailView')
         self.set_download_view = get_class(
             'dashboard.vouchers.views', 'VoucherSetDownloadView')
+        self.set_delete_view = get_class(
+            'dashboard.vouchers.views', 'VoucherSetDeleteView')
 
     def get_urls(self):
         urls = [
@@ -41,7 +43,8 @@ def get_urls(self):
             path('sets/', self.set_list_view.as_view(), name='voucher-set-list'),
             path('sets/create/', self.set_create_view.as_view(), name='voucher-set-create'),
             path('sets/update/<int:pk>/', self.set_update_view.as_view(), name='voucher-set-update'),
-            path('sets/<int:pk>/', self.set_detail_view.as_view(), name='voucher-set'),
-            path('sets/<int:pk>/download/', self.set_download_view.as_view(), name='voucher-set-download'),
+            path('sets/detail/<int:pk>/', self.set_detail_view.as_view(), name='voucher-set-detail'),
+            path('sets/download/<int:pk>/', self.set_download_view.as_view(), name='voucher-set-download'),
+            path('sets/delete/<int:pk>/', self.set_delete_view.as_view(), name='voucher-set-delete'),
         ]
         return self.post_process_urls(urls)
diff --git a/src/oscar/apps/dashboard/vouchers/forms.py b/src/oscar/apps/dashboard/vouchers/forms.py
--- a/src/oscar/apps/dashboard/vouchers/forms.py
+++ b/src/oscar/apps/dashboard/vouchers/forms.py
@@ -1,99 +1,73 @@
 from django import forms
+from django.db import transaction
+from django.urls import reverse
+from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
 
+from oscar.apps.voucher.utils import get_unused_code
 from oscar.core.loading import get_model
 from oscar.forms import widgets
 
 Voucher = get_model('voucher', 'Voucher')
 VoucherSet = get_model('voucher', 'VoucherSet')
-Benefit = get_model('offer', 'Benefit')
-Range = get_model('offer', 'Range')
+ConditionalOffer = get_model('offer', 'ConditionalOffer')
 
 
-class VoucherForm(forms.Form):
+class VoucherForm(forms.ModelForm):
     """
-    A specialised form for creating a voucher and offer
-    model.
+    A specialised form for creating a voucher model, and capturing the offers
+    that apply to it.
     """
-    name = forms.CharField(label=_("Name"))
-    code = forms.CharField(label=_("Code"))
-
-    start_datetime = forms.DateTimeField(
-        widget=widgets.DateTimePickerInput(),
-        label=_("Start datetime"))
-    end_datetime = forms.DateTimeField(
-        widget=widgets.DateTimePickerInput(),
-        label=_("End datetime"))
-
-    usage = forms.ChoiceField(choices=Voucher.USAGE_CHOICES, label=_("Usage"))
-
-    benefit_range = forms.ModelChoiceField(
-        label=_('Which products get a discount?'),
-        queryset=Range.objects.all(),
+    offers = forms.ModelMultipleChoiceField(
+        label=_("Which offers apply for this voucher?"),
+        queryset=ConditionalOffer.objects.filter(offer_type=ConditionalOffer.VOUCHER),
     )
-    benefit_type = forms.ChoiceField(
-        choices=Benefit.TYPE_CHOICES,
-        label=_('Discount type'),
-    )
-    benefit_value = forms.DecimalField(
-        label=_('Discount value'))
-
-    exclusive = forms.BooleanField(
-        required=False,
-        label=_("Exclusive offers cannot be combined on the same items"))
-
-    def __init__(self, voucher=None, *args, **kwargs):
-        self.voucher = voucher
-        super().__init__(*args, **kwargs)
-
-    def clean_name(self):
-        name = self.cleaned_data['name']
-        try:
-            voucher = Voucher.objects.get(name=name)
-        except Voucher.DoesNotExist:
-            pass
-        else:
-            if (not self.voucher) or (voucher.id != self.voucher.id):
-                raise forms.ValidationError(_("The name '%s' is already in"
-                                              " use") % name)
-        return name
+
+    class Meta:
+        model = Voucher
+        fields = [
+            'name',
+            'code',
+            'start_datetime',
+            'end_datetime',
+            'usage',
+        ]
+        widgets = {
+            'start_datetime': widgets.DateTimePickerInput(),
+            'end_datetime': widgets.DateTimePickerInput(),
+        }
 
     def clean_code(self):
-        code = self.cleaned_data['code'].strip().upper()
-        if not code:
-            raise forms.ValidationError(_("Please enter a voucher code"))
-        try:
-            voucher = Voucher.objects.get(code=code)
-        except Voucher.DoesNotExist:
-            pass
-        else:
-            if (not self.voucher) or (voucher.id != self.voucher.id):
-                raise forms.ValidationError(_("The code '%s' is already in"
-                                              " use") % code)
-        return code
-
-    def clean(self):
-        cleaned_data = super().clean()
-        start_datetime = cleaned_data.get('start_datetime')
-        end_datetime = cleaned_data.get('end_datetime')
-        if start_datetime and end_datetime and end_datetime < start_datetime:
-            raise forms.ValidationError(_("The start date must be before the"
-                                          " end date"))
-        return cleaned_data
+        return self.cleaned_data['code'].strip().upper()
 
 
 class VoucherSearchForm(forms.Form):
     name = forms.CharField(required=False, label=_("Name"))
     code = forms.CharField(required=False, label=_("Code"))
-    is_active = forms.BooleanField(required=False, label=_("Is Active?"))
-    in_set = forms.BooleanField(
-        required=False, label=_("In Voucherset?"))
+    offer_name = forms.CharField(required=False, label=_("Offer name"))
+    is_active = forms.NullBooleanField(required=False, label=_("Is active?"), widget=widgets.NullBooleanSelect)
+    in_set = forms.NullBooleanField(required=False, label=_("In voucher set?"), widget=widgets.NullBooleanSelect)
+    has_offers = forms.NullBooleanField(required=False, label=_("Has offers?"), widget=widgets.NullBooleanSelect)
+
+    basic_fields = [
+        'name',
+        'code',
+        'is_active',
+        'in_set',
+    ]
 
     def clean_code(self):
         return self.cleaned_data['code'].upper()
 
 
 class VoucherSetForm(forms.ModelForm):
+    usage = forms.ChoiceField(choices=(("", "---------"),) + Voucher.USAGE_CHOICES, label=_("Usage"))
+
+    offers = forms.ModelMultipleChoiceField(
+        label=_("Which offers apply for this voucher set?"),
+        queryset=ConditionalOffer.objects.filter(offer_type=ConditionalOffer.VOUCHER),
+    )
+
     class Meta:
         model = VoucherSet
         fields = [
@@ -109,27 +83,48 @@ class Meta:
             'end_datetime': widgets.DateTimePickerInput(),
         }
 
-    benefit_range = forms.ModelChoiceField(
-        label=_('Which products get a discount?'),
-        queryset=Range.objects.all(),
-    )
-    benefit_type = forms.ChoiceField(
-        choices=Benefit.TYPE_CHOICES,
-        label=_('Discount type'),
-    )
-    benefit_value = forms.DecimalField(
-        label=_('Discount value'))
+    def clean_count(self):
+        data = self.cleaned_data['count']
+        if (self.instance.pk is not None) and (data < self.instance.count):
+            detail_url = reverse('dashboard:voucher-set-detail', kwargs={'pk': self.instance.pk})
+            raise forms.ValidationError(mark_safe(
+                _('This cannot be used to delete vouchers (currently %s) in this set. '
+                  'You can do that on the <a href="%s">detail</a> page.') % (self.instance.count, detail_url)))
+        return data
 
+    @transaction.atomic
     def save(self, commit=True):
         instance = super().save(commit)
         if commit:
-            instance.generate_vouchers()
+            usage = self.cleaned_data['usage']
+            offers = self.cleaned_data['offers']
+            if instance is not None:
+                # Update vouchers in this set
+                for i, voucher in enumerate(instance.vouchers.order_by('date_created')):
+                    voucher.name = "%s - %d" % (instance.name, i + 1)
+                    voucher.usage = usage
+                    voucher.start_datetime = instance.start_datetime
+                    voucher.end_datetime = instance.end_datetime
+                    voucher.save()
+                    voucher.offers.set(offers)
+            # Add vouchers to this set
+            vouchers_added = False
+            for i in range(instance.vouchers.count(), instance.count):
+                voucher = Voucher.objects.create(name="%s - %d" % (instance.name, i + 1),
+                                                 code=get_unused_code(length=instance.code_length),
+                                                 voucher_set=instance,
+                                                 usage=usage,
+                                                 start_datetime=instance.start_datetime,
+                                                 end_datetime=instance.end_datetime)
+                voucher.offers.add(*offers)
+                vouchers_added = True
+            if vouchers_added:
+                instance.update_count()
         return instance
 
 
 class VoucherSetSearchForm(forms.Form):
     code = forms.CharField(required=False, label=_("Code"))
-    is_active = forms.BooleanField(required=False, label=_("Is Active?"))
 
     def clean_code(self):
         return self.cleaned_data['code'].upper()
diff --git a/src/oscar/apps/dashboard/vouchers/views.py b/src/oscar/apps/dashboard/vouchers/views.py
--- a/src/oscar/apps/dashboard/vouchers/views.py
+++ b/src/oscar/apps/dashboard/vouchers/views.py
@@ -3,14 +3,13 @@
 from django.conf import settings
 from django.contrib import messages
 from django.db import transaction
-from django.http import HttpResponse, HttpResponseRedirect
-from django.shortcuts import get_object_or_404
-from django.urls import reverse
+from django.http import HttpResponse
+from django.shortcuts import get_object_or_404, redirect
+from django.urls import reverse, reverse_lazy
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from django.views import generic
 
-from oscar.apps.voucher.utils import get_offer_name
 from oscar.core.loading import get_class, get_model
 from oscar.core.utils import slugify
 from oscar.views import sort_queryset
@@ -21,9 +20,6 @@
 VoucherSearchForm = get_class('dashboard.vouchers.forms', 'VoucherSearchForm')
 Voucher = get_model('voucher', 'Voucher')
 VoucherSet = get_model('voucher', 'VoucherSet')
-ConditionalOffer = get_model('offer', 'ConditionalOffer')
-Benefit = get_model('offer', 'Benefit')
-Condition = get_model('offer', 'Condition')
 OrderDiscount = get_model('order', 'OrderDiscount')
 
 
@@ -32,62 +28,80 @@ class VoucherListView(generic.ListView):
     context_object_name = 'vouchers'
     template_name = 'oscar/dashboard/vouchers/voucher_list.html'
     form_class = VoucherSearchForm
-    description_template = _("%(main_filter)s %(name_filter)s %(code_filter)s")
     paginate_by = settings.OSCAR_DASHBOARD_ITEMS_PER_PAGE
 
     def get_queryset(self):
-        qs = self.model.objects.all().order_by('-date_created')
+        self.search_filters = []
+        qs = self.model._default_manager.all()
         qs = sort_queryset(qs, self.request,
                            ['num_basket_additions', 'num_orders',
                             'date_created'],
                            '-date_created')
-        self.description_ctx = {'main_filter': _('All vouchers'),
-                                'name_filter': '',
-                                'code_filter': ''}
 
-        # If form not submitted, return early
-        is_form_submitted = 'name' in self.request.GET
-        if not is_form_submitted:
-            self.form = self.form_class()
+        # If form is not submitted, perform a default filter, and return early
+        if not self.request.GET:
+            self.form = self.form_class(initial={'in_set': False})
+            # This form is exactly the same as the other one, apart from having
+            # fields with different IDs, so that they are unique within the page
+            # (non-unique field IDs also break Select2)
+            self.advanced_form = self.form_class(initial={'in_set': False}, auto_id='id_advanced_%s')
+            self.search_filters.append(_("Not in a set"))
             return qs.filter(voucher_set__isnull=True)
 
         self.form = self.form_class(self.request.GET)
-        if not self.form.is_valid():
+        # This form is exactly the same as the other one, apart from having
+        # fields with different IDs, so that they are unique within the page
+        # (non-unique field IDs also break Select2)
+        self.advanced_form = self.form_class(self.request.GET, auto_id='id_advanced_%s')
+        if not all([self.form.is_valid(), self.advanced_form.is_valid()]):
             return qs
 
-        data = self.form.cleaned_data
-        if data['name']:
-            qs = qs.filter(name__icontains=data['name'])
-            self.description_ctx['name_filter'] \
-                = _("with name matching '%s'") % data['name']
-        if data['code']:
-            qs = qs.filter(code=data['code'])
-            self.description_ctx['code_filter'] \
-                = _("with code '%s'") % data['code']
-        if data['is_active']:
+        name = self.form.cleaned_data['name']
+        code = self.form.cleaned_data['code']
+        offer_name = self.form.cleaned_data['offer_name']
+        is_active = self.form.cleaned_data['is_active']
+        in_set = self.form.cleaned_data['in_set']
+        has_offers = self.form.cleaned_data['has_offers']
+
+        if name:
+            qs = qs.filter(name__icontains=name)
+            self.search_filters.append(_('Name matches "%s"') % name)
+        if code:
+            qs = qs.filter(code=code)
+            self.search_filters.append(_('Code is "%s"') % code)
+        if offer_name:
+            qs = qs.filter(offers__name__icontains=offer_name)
+            self.search_filters.append(_('Offer name matches "%s"') % offer_name)
+        if is_active is not None:
             now = timezone.now()
-            qs = qs.filter(start_datetime__lte=now, end_datetime__gte=now)
-            self.description_ctx['main_filter'] = _('Active vouchers')
-        if not data['in_set']:
-            qs = qs.filter(voucher_set__isnull=True)
+            if is_active:
+                qs = qs.filter(start_datetime__lte=now, end_datetime__gte=now)
+                self.search_filters.append(_("Is active"))
+            else:
+                qs = qs.filter(end_datetime__lt=now)
+                self.search_filters.append(_("Is inactive"))
+        if in_set is not None:
+            qs = qs.filter(voucher_set__isnull=not in_set)
+            self.search_filters.append(_("In a set") if in_set else _("Not in a set"))
+        if has_offers is not None:
+            qs = qs.filter(offers__isnull=not has_offers).distinct()
+            self.search_filters.append(_("Has offers") if has_offers else _("Has no offers"))
 
         return qs
 
     def get_context_data(self, **kwargs):
         ctx = super().get_context_data(**kwargs)
-        if self.form.is_bound:
-            description = self.description_template % self.description_ctx
-        else:
-            description = _("Vouchers")
-        ctx['description'] = description
         ctx['form'] = self.form
+        ctx['advanced_form'] = self.advanced_form
+        ctx['search_filters'] = self.search_filters
         return ctx
 
 
-class VoucherCreateView(generic.FormView):
+class VoucherCreateView(generic.CreateView):
     model = Voucher
     template_name = 'oscar/dashboard/vouchers/voucher_form.html'
     form_class = VoucherForm
+    success_url = reverse_lazy('dashboard:voucher-list')
 
     def get_context_data(self, **kwargs):
         ctx = super().get_context_data(**kwargs)
@@ -95,44 +109,19 @@ def get_context_data(self, **kwargs):
         return ctx
 
     def get_initial(self):
-        return dict(
-            exclusive=True
-        )
+        initial = super().get_initial()
+        initial['start_datetime'] = timezone.now()
+        return initial
 
     @transaction.atomic()
     def form_valid(self, form):
-        # Create offer and benefit
-        condition = Condition.objects.create(
-            range=form.cleaned_data['benefit_range'],
-            type=Condition.COUNT,
-            value=1
-        )
-        benefit = Benefit.objects.create(
-            range=form.cleaned_data['benefit_range'],
-            type=form.cleaned_data['benefit_type'],
-            value=form.cleaned_data['benefit_value']
-        )
-        name = form.cleaned_data['name']
-        offer = ConditionalOffer.objects.create(
-            name=get_offer_name(name),
-            offer_type=ConditionalOffer.VOUCHER,
-            benefit=benefit,
-            condition=condition,
-            exclusive=form.cleaned_data['exclusive'],
-        )
-        voucher = Voucher.objects.create(
-            name=name,
-            code=form.cleaned_data['code'],
-            usage=form.cleaned_data['usage'],
-            start_datetime=form.cleaned_data['start_datetime'],
-            end_datetime=form.cleaned_data['end_datetime'],
-        )
-        voucher.offers.add(offer)
-        return HttpResponseRedirect(self.get_success_url())
+        response = super().form_valid(form)
+        self.object.offers.add(*form.cleaned_data['offers'])
+        return response
 
     def get_success_url(self):
         messages.success(self.request, _("Voucher created"))
-        return reverse('dashboard:voucher-list')
+        return super().get_success_url()
 
 
 class VoucherStatsView(generic.DetailView):
@@ -148,72 +137,39 @@ def get_context_data(self, **kwargs):
         return ctx
 
 
-class VoucherUpdateView(generic.FormView):
+class VoucherUpdateView(generic.UpdateView):
     template_name = 'oscar/dashboard/vouchers/voucher_form.html'
+    context_object_name = 'voucher'
     model = Voucher
     form_class = VoucherForm
+    success_url = reverse_lazy('dashboard:voucher-list')
 
-    def get_voucher(self):
-        if not hasattr(self, 'voucher'):
-            self.voucher = Voucher.objects.get(id=self.kwargs['pk'])
-        return self.voucher
+    def dispatch(self, request, *args, **kwargs):
+        voucher_set = self.get_object().voucher_set
+        if voucher_set is not None:
+            messages.warning(request, _("The voucher can only be edited as part of its set"))
+            return redirect('dashboard:voucher-set-update', pk=voucher_set.pk)
+        return super().dispatch(request, *args, **kwargs)
 
     def get_context_data(self, **kwargs):
         ctx = super().get_context_data(**kwargs)
-        ctx['title'] = self.voucher.name
-        ctx['voucher'] = self.voucher
+        ctx['title'] = self.object.name
         return ctx
 
-    def get_form_kwargs(self):
-        kwargs = super().get_form_kwargs()
-        kwargs['voucher'] = self.get_voucher()
-        return kwargs
-
     def get_initial(self):
-        voucher = self.get_voucher()
-        offer = voucher.offers.first()
-        benefit = offer.benefit
-        return {
-            'name': voucher.name,
-            'code': voucher.code,
-            'start_datetime': voucher.start_datetime,
-            'end_datetime': voucher.end_datetime,
-            'usage': voucher.usage,
-            'benefit_type': benefit.type,
-            'benefit_range': benefit.range,
-            'benefit_value': benefit.value,
-            'exclusive': offer.exclusive,
-        }
+        initial = super().get_initial()
+        initial['offers'] = self.object.offers.all()
+        return initial
 
     @transaction.atomic()
     def form_valid(self, form):
-        voucher = self.get_voucher()
-        voucher.name = form.cleaned_data['name']
-        voucher.code = form.cleaned_data['code']
-        voucher.usage = form.cleaned_data['usage']
-        voucher.start_datetime = form.cleaned_data['start_datetime']
-        voucher.end_datetime = form.cleaned_data['end_datetime']
-        voucher.save()
-
-        offer = voucher.offers.first()
-        offer.condition.range = form.cleaned_data['benefit_range']
-        offer.condition.save()
-
-        offer.exclusive = form.cleaned_data['exclusive']
-        offer.name = get_offer_name(voucher.name)
-        offer.save()
-
-        benefit = voucher.benefit
-        benefit.range = form.cleaned_data['benefit_range']
-        benefit.type = form.cleaned_data['benefit_type']
-        benefit.value = form.cleaned_data['benefit_value']
-        benefit.save()
-
-        return HttpResponseRedirect(self.get_success_url())
+        response = super().form_valid(form)
+        self.object.offers.set(form.cleaned_data['offers'])
+        return response
 
     def get_success_url(self):
         messages.success(self.request, _("Voucher updated"))
-        return reverse('dashboard:voucher-list')
+        return super().get_success_url()
 
 
 class VoucherDeleteView(generic.DeleteView):
@@ -221,9 +177,19 @@ class VoucherDeleteView(generic.DeleteView):
     template_name = 'oscar/dashboard/vouchers/voucher_delete.html'
     context_object_name = 'voucher'
 
+    @transaction.atomic
+    def delete(self, request, *args, **kwargs):
+        response = super().delete(request, *args, **kwargs)
+        if self.object.voucher_set is not None:
+            self.object.voucher_set.update_count()
+        return response
+
     def get_success_url(self):
         messages.warning(self.request, _("Voucher deleted"))
-        return reverse('dashboard:voucher-list')
+        if self.object.voucher_set is not None:
+            return reverse('dashboard:voucher-set-detail', kwargs={'pk': self.object.voucher_set.pk})
+        else:
+            return reverse('dashboard:voucher-list')
 
 
 class VoucherSetCreateView(generic.CreateView):
@@ -237,40 +203,9 @@ def get_context_data(self, **kwargs):
         return ctx
 
     def get_initial(self):
-        return {
-            'start_datetime': timezone.now(),
-            'end_datetime': timezone.now()
-        }
-
-    def form_valid(self, form):
-        condition = Condition.objects.create(
-            range=form.cleaned_data['benefit_range'],
-            type=Condition.COUNT,
-            value=1
-        )
-        benefit = Benefit.objects.create(
-            range=form.cleaned_data['benefit_range'],
-            type=form.cleaned_data['benefit_type'],
-            value=form.cleaned_data['benefit_value']
-        )
-        name = form.cleaned_data['name']
-        offer = ConditionalOffer.objects.create(
-            name=get_offer_name(name),
-            offer_type=ConditionalOffer.VOUCHER,
-            benefit=benefit,
-            condition=condition,
-        )
-
-        VoucherSet.objects.create(
-            name=name,
-            count=form.cleaned_data['count'],
-            code_length=form.cleaned_data['code_length'],
-            description=form.cleaned_data['description'],
-            start_datetime=form.cleaned_data['start_datetime'],
-            end_datetime=form.cleaned_data['end_datetime'],
-            offer=offer,
-        )
-        return HttpResponseRedirect(self.get_success_url())
+        initial = super().get_initial()
+        initial['start_datetime'] = timezone.now()
+        return initial
 
     def get_success_url(self):
         messages.success(self.request, _("Voucher set created"))
@@ -280,78 +215,27 @@ def get_success_url(self):
 class VoucherSetUpdateView(generic.UpdateView):
     template_name = 'oscar/dashboard/vouchers/voucher_set_form.html'
     model = VoucherSet
+    context_object_name = 'voucher_set'
     form_class = VoucherSetForm
 
     def get_context_data(self, **kwargs):
         ctx = super().get_context_data(**kwargs)
         ctx['title'] = self.object.name
-        ctx['voucher'] = self.object
         return ctx
 
-    def get_voucherset(self):
-        if not hasattr(self, 'voucherset'):
-            self.voucherset = VoucherSet.objects.get(id=self.kwargs['pk'])
-        return self.voucherset
-
     def get_initial(self):
-        voucherset = self.get_voucherset()
-        offer = voucherset.offer
-        benefit = offer.benefit
-        return {
-            'name': voucherset.name,
-            'count': voucherset.count,
-            'code_length': voucherset.code_length,
-            'start_datetime': voucherset.start_datetime,
-            'end_datetime': voucherset.end_datetime,
-            'description': voucherset.description,
-            'benefit_type': benefit.type,
-            'benefit_range': benefit.range,
-            'benefit_value': benefit.value,
-        }
-
-    def form_valid(self, form):
-        voucherset = form.save()
-        if not voucherset.offer:
-            condition = Condition.objects.create(
-                range=form.cleaned_data['benefit_range'],
-                type=Condition.COUNT,
-                value=1
-            )
-            benefit = Benefit.objects.create(
-                range=form.cleaned_data['benefit_range'],
-                type=form.cleaned_data['benefit_type'],
-                value=form.cleaned_data['benefit_value']
-            )
-            name = form.cleaned_data['name']
-            offer, __ = ConditionalOffer.objects.update_or_create(
-                name=get_offer_name(name),
-                defaults=dict(
-                    offer_type=ConditionalOffer.VOUCHER,
-                    benefit=benefit,
-                    condition=condition,
-                )
-            )
-            voucherset.offer = offer
-            for voucher in voucherset.vouchers.all():
-                if offer not in voucher.offers.all():
-                    voucher.offers.add(offer)
-
-        else:
-            benefit = voucherset.offer.benefit
-            benefit.range = form.cleaned_data['benefit_range']
-            benefit.type = form.cleaned_data['benefit_type']
-            benefit.value = form.cleaned_data['benefit_value']
-            benefit.save()
-            condition = voucherset.offer.condition
-            condition.range = form.cleaned_data['benefit_range']
-            condition.save()
-        voucherset.save()
-
-        return HttpResponseRedirect(self.get_success_url())
+        initial = super().get_initial()
+        # All vouchers in the set have the same "usage" and "offers", so we use
+        # the first one
+        voucher = self.object.vouchers.first()
+        if voucher is not None:
+            initial['usage'] = voucher.usage
+            initial['offers'] = voucher.offers.all()
+        return initial
 
     def get_success_url(self):
         messages.success(self.request, _("Voucher updated"))
-        return reverse('dashboard:voucher-set', kwargs={'pk': self.object.pk})
+        return reverse('dashboard:voucher-set-detail', kwargs={'pk': self.object.pk})
 
 
 class VoucherSetDetailView(generic.ListView):
@@ -360,7 +244,6 @@ class VoucherSetDetailView(generic.ListView):
     context_object_name = 'vouchers'
     template_name = 'oscar/dashboard/vouchers/voucher_set_detail.html'
     form_class = VoucherSetSearchForm
-    description_template = _("%(main_filter)s %(name_filter)s %(code_filter)s")
     paginate_by = 50
 
     def dispatch(self, request, *args, **kwargs):
@@ -368,6 +251,7 @@ def dispatch(self, request, *args, **kwargs):
         return super().dispatch(request, *args, **kwargs)
 
     def get_queryset(self):
+        self.search_filters = []
         qs = (
             self.model.objects
             .filter(voucher_set=self.voucher_set)
@@ -377,9 +261,6 @@ def get_queryset(self):
                            ['num_basket_additions', 'num_orders',
                             'date_created'],
                            '-date_created')
-        self.description_ctx = {'main_filter': _('All vouchers'),
-                                'name_filter': '',
-                                'code_filter': ''}
 
         # If form not submitted, return early
         is_form_submitted = (
@@ -396,28 +277,22 @@ def get_queryset(self):
         data = self.form.cleaned_data
         if data['code']:
             qs = qs.filter(code__icontains=data['code'])
-            self.description_ctx['code_filter'] \
-                = _("with code '%s'") % data['code']
-        if data['is_active']:
-            now = timezone.now()
-            qs = qs.filter(start_datetime__lte=now, end_datetime__gt=now)
-            self.description_ctx['main_filter'] = _('Active vouchers')
+            self.search_filters.append(_('Code matches "%s"') % data['code'])
 
         return qs
 
     def get_context_data(self, **kwargs):
         ctx = super().get_context_data(**kwargs)
         ctx['voucher_set'] = self.voucher_set
-        ctx['description'] = self.voucher_set.name
         ctx['form'] = self.form
+        ctx['search_filters'] = self.search_filters
         return ctx
 
 
 class VoucherSetListView(generic.ListView):
     model = VoucherSet
-    context_object_name = 'vouchers'
+    context_object_name = 'voucher_sets'
     template_name = 'oscar/dashboard/vouchers/voucher_set_list.html'
-    description_template = _("%(main_filter)s %(name_filter)s %(code_filter)s")
     paginate_by = settings.OSCAR_DASHBOARD_ITEMS_PER_PAGE
 
     def get_queryset(self):
@@ -451,3 +326,13 @@ def get(self, request, *args, **kwargs):
             writer.writerow([code])
 
         return response
+
+
+class VoucherSetDeleteView(generic.DeleteView):
+    model = VoucherSet
+    template_name = 'oscar/dashboard/vouchers/voucher_set_delete.html'
+    context_object_name = 'voucher_set'
+
+    def get_success_url(self):
+        messages.warning(self.request, _("Voucher set deleted"))
+        return reverse('dashboard:voucher-set-list')
diff --git a/src/oscar/apps/offer/abstract_models.py b/src/oscar/apps/offer/abstract_models.py
--- a/src/oscar/apps/offer/abstract_models.py
+++ b/src/oscar/apps/offer/abstract_models.py
@@ -248,6 +248,10 @@ def clean(self):
             raise exceptions.ValidationError(
                 _('End date should be later than start date'))
 
+    @property
+    def is_voucher_offer_type(self):
+        return self.offer_type == self.VOUCHER
+
     @property
     def is_open(self):
         return self.status == self.OPEN
diff --git a/src/oscar/apps/offer/apps.py b/src/oscar/apps/offer/apps.py
--- a/src/oscar/apps/offer/apps.py
+++ b/src/oscar/apps/offer/apps.py
@@ -13,7 +13,7 @@ class OfferConfig(OscarConfig):
     namespace = 'offer'
 
     def ready(self):
-        from . import signals  # noqa
+        from . import receivers  # noqa
 
         self.detail_view = get_class('offer.views', 'OfferDetailView')
         self.list_view = get_class('offer.views', 'OfferListView')
diff --git a/src/oscar/apps/offer/signals.py b/src/oscar/apps/offer/receivers.py
similarity index 100%
rename from src/oscar/apps/offer/signals.py
rename to src/oscar/apps/offer/receivers.py
diff --git a/src/oscar/apps/voucher/abstract_models.py b/src/oscar/apps/voucher/abstract_models.py
--- a/src/oscar/apps/voucher/abstract_models.py
+++ b/src/oscar/apps/voucher/abstract_models.py
@@ -1,14 +1,13 @@
 from decimal import Decimal
 
 from django.core import exceptions
-from django.db import models, transaction
+from django.db import models
 from django.db.models import Sum
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
-from oscar.apps.voucher.utils import get_unused_code
 from oscar.core.compat import AUTH_USER_MODEL
-from oscar.core.loading import get_model
+from oscar.core.decorators import deprecated
 
 
 class AbstractVoucherSet(models.Model):
@@ -17,7 +16,7 @@ class AbstractVoucherSet(models.Model):
     a VoucherSet is a group of voucher that are generated
     automatically.
 
-    - count: the minimum number of vouchers in the set. If this is kept at
+    - count: the number of vouchers in the set. If this is kept at
     zero, vouchers are created when and as needed.
 
     - code_length: the length of the voucher code. Codes are by default created
@@ -28,7 +27,7 @@ class AbstractVoucherSet(models.Model):
       range for all vouchers in the set.
     """
 
-    name = models.CharField(verbose_name=_('Name'), max_length=100)
+    name = models.CharField(verbose_name=_('Name'), max_length=100, unique=True)
     count = models.PositiveIntegerField(verbose_name=_('Number of vouchers'))
     code_length = models.IntegerField(
         verbose_name=_('Length of Code'), default=12)
@@ -37,11 +36,6 @@ class AbstractVoucherSet(models.Model):
     start_datetime = models.DateTimeField(_('Start datetime'))
     end_datetime = models.DateTimeField(_('End datetime'))
 
-    offer = models.OneToOneField(
-        'offer.ConditionalOffer', related_name='voucher_set',
-        verbose_name=_("Offer"), limit_choices_to={'offer_type': "Voucher"},
-        on_delete=models.CASCADE, null=True, blank=True)
-
     class Meta:
         abstract = True
         app_label = 'voucher'
@@ -53,44 +47,21 @@ class Meta:
     def __str__(self):
         return self.name
 
-    def generate_vouchers(self):
-        """Generate vouchers for this set"""
-        current_count = self.vouchers.count()
-        for i in range(current_count, self.count):
-            self.add_new()
-
-    def add_new(self):
-        """Add a new voucher to this set"""
-        Voucher = get_model('voucher', 'Voucher')
-        code = get_unused_code(length=self.code_length)
-        voucher = Voucher.objects.create(
-            name=self.name,
-            code=code,
-            voucher_set=self,
-            usage=Voucher.SINGLE_USE,
-            start_datetime=self.start_datetime,
-            end_datetime=self.end_datetime)
-
-        if self.offer:
-            voucher.offers.add(self.offer)
-
-        return voucher
+    def clean(self):
+        if self.start_datetime and self.end_datetime and (self.start_datetime > self.end_datetime):
+            raise exceptions.ValidationError(_('End date should be later than start date'))
+
+    def update_count(self):
+        vouchers_count = self.vouchers.count()
+        if self.count != vouchers_count:
+            self.count = vouchers_count
+            self.save()
 
     def is_active(self, test_datetime=None):
         """Test whether this voucher set is currently active. """
         test_datetime = test_datetime or timezone.now()
         return self.start_datetime <= test_datetime <= self.end_datetime
 
-    def save(self, *args, **kwargs):
-        self.count = max(self.count, self.vouchers.count())
-        with transaction.atomic():
-            super().save(*args, **kwargs)
-            self.generate_vouchers()
-            self.vouchers.update(
-                start_datetime=self.start_datetime,
-                end_datetime=self.end_datetime
-            )
-
     @property
     def num_basket_additions(self):
         value = self.vouchers.aggregate(result=Sum('num_basket_additions'))
@@ -119,7 +90,7 @@ class AbstractVoucher(models.Model):
     Oscar enforces those modes by creating VoucherApplication
     instances when a voucher is used for an order.
     """
-    name = models.CharField(_("Name"), max_length=128,
+    name = models.CharField(_("Name"), max_length=128, unique=True,
                             help_text=_("This will be shown in the checkout"
                                         " and basket once the voucher is"
                                         " entered"))
@@ -170,8 +141,7 @@ def __str__(self):
         return self.name
 
     def clean(self):
-        if all([self.start_datetime, self.end_datetime,
-                self.start_datetime > self.end_datetime]):
+        if self.start_datetime and self.end_datetime and (self.start_datetime > self.end_datetime):
             raise exceptions.ValidationError(
                 _('End date should be later than start date'))
 
@@ -260,6 +230,7 @@ def record_discount(self, discount):
     record_discount.alters_data = True
 
     @property
+    @deprecated
     def benefit(self):
         """
         Returns the first offer's benefit instance.
diff --git a/src/oscar/apps/voucher/apps.py b/src/oscar/apps/voucher/apps.py
--- a/src/oscar/apps/voucher/apps.py
+++ b/src/oscar/apps/voucher/apps.py
@@ -10,4 +10,3 @@ class VoucherConfig(OscarConfig):
 
     def ready(self):
         from . import receivers  # noqa
-        from . import signals  # noqa
diff --git a/src/oscar/apps/voucher/migrations/0009_make_voucher_names_unique.py b/src/oscar/apps/voucher/migrations/0009_make_voucher_names_unique.py
new file mode 100644
--- /dev/null
+++ b/src/oscar/apps/voucher/migrations/0009_make_voucher_names_unique.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.1.2 on 2021-02-24 06:18
+
+from django.db import migrations
+
+
+def make_voucher_names_unique(apps, schema_editor):
+    """
+    Appends a number to non-unique voucher names.
+    """
+    Voucher = apps.get_model('voucher', 'Voucher')
+    vouchers = Voucher.objects.order_by('date_created')
+    # Find vouchers with non-unique names
+    vouchers_for_name = {}
+    for voucher in vouchers:
+        if voucher.name not in vouchers_for_name:
+            vouchers_for_name[voucher.name] = []
+        vouchers_for_name[voucher.name].append(voucher.id)
+    # Change names for vouchers with non-unique names
+    for voucher in vouchers:
+        if len(vouchers_for_name[voucher.name]) > 1:
+            voucher.name = "%s - %d" % (voucher.name, vouchers_for_name[voucher.name].index(voucher.id) + 1)
+            voucher.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('voucher', '0008_auto_20200801_0817'),
+    ]
+
+    operations = [
+        migrations.RunPython(make_voucher_names_unique, migrations.RunPython.noop),
+    ]
diff --git a/src/oscar/apps/voucher/migrations/0010_auto_20210224_0712.py b/src/oscar/apps/voucher/migrations/0010_auto_20210224_0712.py
new file mode 100644
--- /dev/null
+++ b/src/oscar/apps/voucher/migrations/0010_auto_20210224_0712.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.1.2 on 2021-02-24 07:12
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('voucher', '0009_make_voucher_names_unique'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='voucher',
+            name='name',
+            field=models.CharField(help_text='This will be shown in the checkout and basket once the voucher is entered', max_length=128, unique=True, verbose_name='Name'),
+        ),
+        migrations.AlterField(
+            model_name='voucherset',
+            name='name',
+            field=models.CharField(max_length=100, unique=True, verbose_name='Name'),
+        ),
+        migrations.RemoveField(
+            model_name='voucherset',
+            name='offer',
+        ),
+    ]
diff --git a/src/oscar/apps/voucher/signals.py b/src/oscar/apps/voucher/signals.py
deleted file mode 100644
--- a/src/oscar/apps/voucher/signals.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from django.db.models.signals import post_delete
-from django.dispatch import receiver
-
-from oscar.apps.voucher.utils import get_offer_name
-from oscar.core.loading import get_model
-
-Voucher = get_model('voucher', 'Voucher')
-ConditionalOffer = get_model('offer', 'ConditionalOffer')
-
-
-@receiver(post_delete, sender=Voucher)
-def delete_unused_related_conditional_offer(instance, **kwargs):
-    voucher = instance  # the object is no longer in the database
-
-    try:
-        conditional_offer = ConditionalOffer.objects.get(
-            name=get_offer_name(voucher.name),
-            offer_type=ConditionalOffer.VOUCHER
-        )
-    except (ConditionalOffer.DoesNotExist, ConditionalOffer.MultipleObjectsReturned):
-        pass
-    else:
-        # Only delete if not used by other vouchers
-        if not conditional_offer.vouchers.exists():
-            conditional_offer.delete()
diff --git a/src/oscar/apps/voucher/utils.py b/src/oscar/apps/voucher/utils.py
--- a/src/oscar/apps/voucher/utils.py
+++ b/src/oscar/apps/voucher/utils.py
@@ -1,7 +1,6 @@
 from itertools import zip_longest
 
 from django.utils.crypto import get_random_string
-from django.utils.translation import gettext_lazy as _
 
 from oscar.core.loading import get_model
 
@@ -34,11 +33,3 @@ def get_unused_code(length=12, group_length=4, separator='-'):
                              separator=separator)
         if not Voucher.objects.filter(code=code).exists():
             return code
-
-
-def get_offer_name(voucher_name):
-    """
-    Return the name used for the auto-generated offer created
-    when a voucher is created through the dashboard.
-    """
-    return _("Offer for voucher '%s'") % voucher_name
diff --git a/src/oscar/defaults.py b/src/oscar/defaults.py
--- a/src/oscar/defaults.py
+++ b/src/oscar/defaults.py
@@ -82,6 +82,12 @@
 
 # Offers
 OSCAR_OFFERS_INCL_TAX = False
+# Values (using the names of the model constants) from
+# "offer.ConditionalOffer.TYPE_CHOICES"
+OSCAR_OFFERS_IMPLEMENTED_TYPES = [
+    'SITE',
+    'VOUCHER',
+]
 
 # Hidden Oscar features, e.g. wishlists or reviews
 OSCAR_HIDDEN_FEATURES = []
diff --git a/src/oscar/forms/widgets.py b/src/oscar/forms/widgets.py
--- a/src/oscar/forms/widgets.py
+++ b/src/oscar/forms/widgets.py
@@ -6,6 +6,7 @@
 from django.forms.widgets import FileInput
 from django.utils import formats
 from django.utils.encoding import force_str
+from django.utils.translation import gettext as _
 
 
 class ImageInput(FileInput):
@@ -286,3 +287,18 @@ def optgroups(self, name, value, attrs=None):
 
 class MultipleRemoteSelect(RemoteSelect):
     allow_multiple_selected = True
+
+
+class NullBooleanSelect(forms.NullBooleanSelect):
+    """
+    Customised NullBooleanSelect widget that gives the "unknown" choice a more
+    meaningful label than the default of "Unknown".
+    """
+
+    def __init__(self, attrs=None):
+        super().__init__(attrs)
+        self.choices = (
+            ('unknown', _('---------')),
+            ('true', _('Yes')),
+            ('false', _('No')),
+        )


Test patch:
diff --git a/src/oscar/test/factories/offer.py b/src/oscar/test/factories/offer.py
--- a/src/oscar/test/factories/offer.py
+++ b/src/oscar/test/factories/offer.py
@@ -2,6 +2,8 @@
 
 from oscar.core.loading import get_model
 
+ConditionalOffer = get_model('offer', 'ConditionalOffer')
+
 __all__ = [
     'RangeFactory', 'ConditionFactory', 'BenefitFactory',
     'ConditionalOfferFactory',
@@ -47,6 +49,7 @@ class Meta:
 
 class ConditionalOfferFactory(factory.django.DjangoModelFactory):
     name = factory.Sequence(lambda n: 'Test offer %d' % n)
+    offer_type = ConditionalOffer.SITE
     benefit = factory.SubFactory(BenefitFactory)
     condition = factory.SubFactory(ConditionFactory)
 
diff --git a/src/oscar/test/factories/voucher.py b/src/oscar/test/factories/voucher.py
--- a/src/oscar/test/factories/voucher.py
+++ b/src/oscar/test/factories/voucher.py
@@ -3,15 +3,20 @@
 import factory
 from django.utils.timezone import now
 
+from oscar.apps.voucher.utils import get_unused_code
 from oscar.core.loading import get_model
 from oscar.test.factories import ConditionalOfferFactory
 
+ConditionalOffer = get_model('offer', 'ConditionalOffer')
+Voucher = get_model('voucher', 'Voucher')
+
 __all__ = ['VoucherFactory', 'VoucherSetFactory']
 
 
 class VoucherFactory(factory.django.DjangoModelFactory):
     name = "My voucher"
     code = "MYVOUCHER"
+    usage = Voucher.MULTI_USE
 
     start_datetime = now() - datetime.timedelta(days=1)
     end_datetime = now() + datetime.timedelta(days=10)
@@ -24,9 +29,23 @@ class VoucherSetFactory(factory.django.DjangoModelFactory):
     name = factory.Sequence(lambda n: 'Voucher Set %d' % n)
     count = 100
     code_length = 12
+    description = "Dummy description"
     start_datetime = now() - datetime.timedelta(days=1)
     end_datetime = now() + datetime.timedelta(days=10)
-    offer = factory.SubFactory(ConditionalOfferFactory)
 
     class Meta:
         model = get_model('voucher', 'VoucherSet')
+
+    @factory.post_generation
+    def vouchers(obj, create, extracted, **kwargs):
+        if not create:
+            return
+        offer = ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER)
+        for i in range(0, obj.count):
+            voucher = Voucher.objects.create(name="%s - %d" % (obj.name, i + 1),
+                                             code=get_unused_code(length=obj.code_length),
+                                             voucher_set=obj,
+                                             usage=Voucher.MULTI_USE,
+                                             start_datetime=obj.start_datetime,
+                                             end_datetime=obj.end_datetime)
+            voucher.offers.add(offer)
diff --git a/tests/functional/dashboard/test_dashboard.py b/tests/functional/dashboard/test_dashboard.py
--- a/tests/functional/dashboard/test_dashboard.py
+++ b/tests/functional/dashboard/test_dashboard.py
@@ -32,7 +32,7 @@
     'order_status_breakdown',
 )
 STAFF_STATS_KEYS = (
-    'total_site_offers',
+    'offer_maps',
     'total_vouchers',
 )
 
diff --git a/tests/functional/dashboard/test_offer.py b/tests/functional/dashboard/test_offer.py
--- a/tests/functional/dashboard/test_offer.py
+++ b/tests/functional/dashboard/test_offer.py
@@ -20,6 +20,7 @@ def test_can_create_an_offer(self):
         metadata_page = list_page.click('Create new offer')
         metadata_form = metadata_page.form
         metadata_form['name'] = "Test offer"
+        metadata_form['offer_type'] = models.ConditionalOffer.SITE
 
         benefit_page = metadata_form.submit().follow()
         benefit_form = benefit_page.form
@@ -56,7 +57,7 @@ def test_offer_list_page(self):
         res = form.submit()
         self.assertFalse("No offers found" in res.text)
 
-        form['is_active'] = True
+        form['is_active'] = "true"
         res = form.submit()
         self.assertFalse("No offers found" in res.text)
 
@@ -64,7 +65,19 @@ def test_offer_list_page(self):
         offer.end_datetime = yesterday
         offer.save()
 
-        form['is_active'] = True
+        form['is_active'] = "true"
+        res = form.submit()
+        self.assertTrue("No offers found" in res.text)
+
+        tomorrow = timezone.now() + timezone.timedelta(days=1)
+        offer.end_datetime = tomorrow
+        offer.save()
+
+        form['offer_type'] = "Site"
+        res = form.submit()
+        self.assertFalse("No offers found" in res.text)
+
+        form['offer_type'] = "Voucher"
         res = form.submit()
         self.assertTrue("No offers found" in res.text)
 
@@ -77,6 +90,7 @@ def test_can_update_an_existing_offer(self):
         metadata_page = detail_page.click(linkid="edit_metadata")
         metadata_form = metadata_page.form
         metadata_form['name'] = "Offer A+"
+        metadata_form['offer_type'] = models.ConditionalOffer.SITE
 
         benefit_page = metadata_form.submit().follow()
         benefit_form = benefit_page.form
@@ -153,6 +167,7 @@ def test_jump_back_to_incentive_step_for_new_offer(self):
         metadata_page = list_page.click('Create new offer')
         metadata_form = metadata_page.form
         metadata_form['name'] = "Test offer"
+        metadata_form['offer_type'] = models.ConditionalOffer.SITE
 
         benefit_page = metadata_form.submit().follow()
         benefit_form = benefit_page.form
@@ -174,6 +189,7 @@ def test_jump_back_to_condition_step_for_new_offer(self):
         metadata_page = list_page.click('Create new offer')
         metadata_form = metadata_page.form
         metadata_form['name'] = "Test offer"
+        metadata_form['offer_type'] = models.ConditionalOffer.SITE
 
         benefit_page = metadata_form.submit().follow()
         benefit_form = benefit_page.form
@@ -210,3 +226,66 @@ def test_jump_to_condition_step_for_existing_offer(self):
 
         self.assertFalse('range' in condition_page.errors)
         self.assertEqual(len(condition_page.errors), 0)
+
+
+class TestOfferListSearch(testcases.WebTestCase):
+    is_staff = True
+
+    TEST_CASES = [
+        ({}, []),
+        (
+            {'name': 'Bob Smith'},
+            ['Name matches "Bob Smith"']
+        ),
+        (
+            {'is_active': True},
+            ['Is active']
+        ),
+        (
+            {'is_active': False},
+            ['Is inactive']
+        ),
+        (
+            {'offer_type': 'Site'},
+            ['Is of type "Site offer - available to all users"']
+        ),
+        (
+            {'has_vouchers': True},
+            ['Has vouchers']
+        ),
+        (
+            {'has_vouchers': False},
+            ['Has no vouchers']
+        ),
+        (
+            {'voucher_code': 'abcd1234'},
+            ['Voucher code matches "abcd1234"']
+        ),
+        (
+            {
+                'name': 'Bob Smith',
+                'is_active': True,
+                'offer_type': 'Site',
+                'has_vouchers': True,
+                'voucher_code': 'abcd1234',
+            },
+            [
+                'Name matches "Bob Smith"',
+                'Is active',
+                'Is of type "Site offer - available to all users"',
+                'Has vouchers',
+                'Voucher code matches "abcd1234"',
+            ]
+        ),
+    ]
+
+    def test_search_filter_descriptions(self):
+        url = reverse('dashboard:offer-list')
+        for params, expected_filters in self.TEST_CASES:
+            response = self.get(url, params=params)
+            self.assertEqual(response.status_code, 200)
+            applied_filters = [
+                el.text.strip() for el in
+                response.html.select('.search-filter-list .badge')
+            ]
+            self.assertEqual(applied_filters, expected_filters)
diff --git a/tests/functional/dashboard/test_voucher.py b/tests/functional/dashboard/test_voucher.py
new file mode 100644
--- /dev/null
+++ b/tests/functional/dashboard/test_voucher.py
@@ -0,0 +1,76 @@
+from django.urls import reverse
+
+from oscar.test.testcases import WebTestCase
+
+
+class TestVoucherListSearch(WebTestCase):
+    is_staff = True
+
+    TEST_CASES = [
+        ({}, ['Not in a set']),
+        (
+            {'name': 'Bob Smith'},
+            ['Name matches "Bob Smith"']
+        ),
+        (
+            {'code': 'abcd1234'},
+            ['Code is "ABCD1234"']
+        ),
+        (
+            {'offer_name': 'Shipping offer'},
+            ['Offer name matches "Shipping offer"']
+        ),
+        (
+            {'is_active': True},
+            ['Is active']
+        ),
+        (
+            {'is_active': False},
+            ['Is inactive']
+        ),
+        (
+            {'in_set': True},
+            ['In a set']
+        ),
+        (
+            {'in_set': False},
+            ['Not in a set']
+        ),
+        (
+            {'has_offers': True},
+            ['Has offers']
+        ),
+        (
+            {'has_offers': False},
+            ['Has no offers']
+        ),
+        (
+            {
+                'name': 'Bob Smith',
+                'code': 'abcd1234',
+                'offer_name': 'Shipping offer',
+                'is_active': True,
+                'in_set': True,
+                'has_offers': True,
+            },
+            [
+                'Name matches "Bob Smith"',
+                'Code is "ABCD1234"',
+                'Offer name matches "Shipping offer"',
+                'Is active',
+                'In a set',
+                'Has offers',
+            ]
+        ),
+    ]
+
+    def test_search_filter_descriptions(self):
+        url = reverse('dashboard:voucher-list')
+        for params, expected_filters in self.TEST_CASES:
+            response = self.get(url, params=params)
+            self.assertEqual(response.status_code, 200)
+            applied_filters = [
+                el.text.strip() for el in
+                response.html.select('.search-filter-list .badge')
+            ]
+            self.assertEqual(applied_filters, expected_filters)
diff --git a/tests/integration/dashboard/test_offer_views.py b/tests/integration/dashboard/test_offer_views.py
--- a/tests/integration/dashboard/test_offer_views.py
+++ b/tests/integration/dashboard/test_offer_views.py
@@ -1,10 +1,14 @@
-
 import pytest
+from django.contrib.messages import get_messages
+from django.urls import reverse
 
 from oscar.apps.dashboard.offers import views as offer_views
 from oscar.apps.dashboard.ranges import views as range_views
 from oscar.core.loading import get_model
-from oscar.test.factories import catalogue, offer
+from oscar.test.factories.catalogue import ProductFactory
+from oscar.test.factories.offer import ConditionalOfferFactory, RangeFactory
+from oscar.test.factories.voucher import VoucherFactory
+from tests.fixtures import RequestFactory
 
 Range = get_model('offer', 'Range')
 ConditionalOffer = get_model('offer', 'ConditionalOffer')
@@ -13,23 +17,23 @@
 @pytest.fixture
 def many_ranges():
     for i in range(0, 30):
-        offer.RangeFactory()
+        RangeFactory()
     return Range.objects.all()
 
 
 @pytest.fixture
 def many_offers():
     for i in range(0, 30):
-        offer.ConditionalOfferFactory(
+        ConditionalOfferFactory(
             name='Test offer %d' % i
         )
 
 
 @pytest.fixture
 def range_with_products():
-    productrange = offer.RangeFactory()
+    productrange = RangeFactory()
     for i in range(0, 30):
-        product = catalogue.ProductFactory()
+        product = ProductFactory()
         productrange.add_product(product)
     return productrange
 
@@ -55,6 +59,43 @@ def test_offer_list_view(self, rf, many_offers):
         assert response.context_data['page_obj']
         assert response.status_code == 200
 
+    def test_offer_delete_view_for_voucher_offer_without_vouchers(self):
+        offer = ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER)
+
+        view = offer_views.OfferDeleteView.as_view()
+
+        request = RequestFactory().get('/')
+        response = view(request, pk=offer.pk)
+        assert response.status_code == 200
+
+        request = RequestFactory().post('/')
+        response = view(request, pk=offer.pk)
+        assert response.status_code == 302
+        assert response.url == reverse('dashboard:offer-list')
+        assert [(m.level_tag, str(m.message)) for m in get_messages(request)][0] == ('success', "Offer deleted!")
+        assert not ConditionalOffer.objects.exists()
+
+    def test_offer_delete_view_for_voucher_offer_with_vouchers(self):
+        offer = ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER)
+        VoucherFactory().offers.add(offer)
+
+        view = offer_views.OfferDeleteView.as_view()
+
+        request = RequestFactory().get('/')
+        response = view(request, pk=offer.pk)
+        assert response.status_code == 302
+        assert response.url == reverse('dashboard:offer-detail', kwargs={'pk': offer.pk})
+        assert [(m.level_tag, str(m.message)) for m in get_messages(request)][0] == (
+            'warning', "This offer can only be deleted if it has no vouchers attached to it")
+
+        request = RequestFactory().post('/')
+        response = view(request, pk=offer.pk)
+        assert response.status_code == 302
+        assert response.url == reverse('dashboard:offer-detail', kwargs={'pk': offer.pk})
+        assert [(m.level_tag, str(m.message)) for m in get_messages(request)][0] == (
+            'warning', "This offer can only be deleted if it has no vouchers attached to it")
+        assert ConditionalOffer.objects.exists()
+
     def test_range_product_list_view(self, rf, range_with_products):
         view = range_views.RangeProductListView.as_view()
         pk = range_with_products.pk
diff --git a/tests/integration/dashboard/test_voucher_form.py b/tests/integration/dashboard/test_voucher_form.py
--- a/tests/integration/dashboard/test_voucher_form.py
+++ b/tests/integration/dashboard/test_voucher_form.py
@@ -2,10 +2,17 @@
 
 import pytest
 from django import test
+from django.urls import reverse
 from django.utils import timezone
 
 from oscar.apps.dashboard.vouchers import forms
-from oscar.test.factories.offer import RangeFactory
+from oscar.core.loading import get_model
+from oscar.test.factories.offer import (
+    BenefitFactory, ConditionalOfferFactory, ConditionFactory, RangeFactory)
+from oscar.test.factories.voucher import VoucherSetFactory
+
+ConditionalOffer = get_model('offer', 'ConditionalOffer')
+Voucher = get_model('voucher', 'Voucher')
 
 
 class TestVoucherForm(test.TestCase):
@@ -16,14 +23,16 @@ def test_doesnt_crash_on_empty_date_fields(self):
         exception (instead of just failing validation) when being called with
         empty fields. This tests exists to prevent a regression.
         """
+        offer = ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER,
+                                        benefit=BenefitFactory(range=None),
+                                        condition=ConditionFactory(range=None, value=1))
         data = {
             'code': '',
             'name': '',
-            'start_date': '',
-            'end_date': '',
-            'benefit_range': '',
-            'benefit_type': 'Percentage',
+            'start_datetime': '',
+            'end_datetime': '',
             'usage': 'Single use',
+            'offers': [offer.pk],
         }
         form = forms.VoucherForm(data=data)
         try:
@@ -40,7 +49,9 @@ class TestVoucherSetForm:
 
     def test_valid_form(self):
         a_range = RangeFactory(includes_all_products=True)
-
+        offer = ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER,
+                                        benefit=BenefitFactory(range=a_range),
+                                        condition=ConditionFactory(range=a_range, value=1))
         start = timezone.now()
         end = start + timedelta(days=1)
         data = {
@@ -50,9 +61,8 @@ def test_valid_form(self):
             'start_datetime': start,
             'end_datetime': end,
             'count': 10,
-            'benefit_range': a_range.pk,
-            'benefit_type': 'Percentage',
-            'benefit_value': 10,
+            'usage': Voucher.MULTI_USE,
+            'offers': [offer.pk],
         }
         form = forms.VoucherSetForm(data=data)
         assert form.is_valid()
@@ -60,3 +70,23 @@ def test_valid_form(self):
         assert instance.count == instance.vouchers.count()
         assert instance.start_datetime == start
         assert instance.end_datetime == end
+
+    def test_valid_form_reduced_count(self):
+        voucher_set = VoucherSetFactory(count=5)
+        voucher = voucher_set.vouchers.first()
+        data = {
+            'name': voucher_set.name,
+            'code_length': voucher_set.code_length,
+            'description': voucher_set.description,
+            'start_datetime': voucher_set.start_datetime,
+            'end_datetime': voucher_set.end_datetime,
+            'count': 4,
+            'usage': voucher.usage,
+            'offers': voucher.offers.all(),
+        }
+        form = forms.VoucherSetForm(data, instance=voucher_set)
+        assert not form.is_valid()
+        assert form.errors['count'][0] == (
+            'This cannot be used to delete vouchers (currently 5) in this set. '
+            'You can do that on the <a href="%s">detail</a> page.') % reverse('dashboard:voucher-set-detail',
+                                                                              kwargs={'pk': voucher_set.pk})
diff --git a/tests/integration/dashboard/test_voucher_views.py b/tests/integration/dashboard/test_voucher_views.py
--- a/tests/integration/dashboard/test_voucher_views.py
+++ b/tests/integration/dashboard/test_voucher_views.py
@@ -1,9 +1,15 @@
 import pytest
+from django.contrib.messages import get_messages
+from django.urls import reverse
 
 from oscar.apps.dashboard.vouchers import views
 from oscar.core.loading import get_model
 from oscar.test.factories import voucher
+from oscar.test.factories.offer import ConditionalOfferFactory
+from tests.fixtures import RequestFactory
 
+ConditionalOffer = get_model('offer', 'ConditionalOffer')
+Voucher = get_model('voucher', 'Voucher')
 VoucherSet = get_model('voucher', 'VoucherSet')
 
 
@@ -13,6 +19,66 @@ def many_voucher_sets():
     return VoucherSet.objects.all()
 
 
+@pytest.mark.django_db
+class TestDashboardVouchers:
+
+    def test_voucher_update_view_for_voucher_in_set(self):
+        vs = voucher.VoucherSetFactory(count=10)
+        v = vs.vouchers.first()
+
+        view = views.VoucherUpdateView.as_view()
+
+        request = RequestFactory().get('/')
+        response = view(request, pk=v.pk)
+        assert response.status_code == 302
+        assert response.url == reverse('dashboard:voucher-set-update', kwargs={'pk': vs.pk})
+        assert [(m.level_tag, str(m.message)) for m in get_messages(request)][0] == (
+            'warning', "The voucher can only be edited as part of its set")
+
+        data = {
+            'code': v.code,
+            'name': "New name",
+            'start_datetime': v.start_datetime,
+            'end_datetime': v.end_datetime,
+            'usage': v.usage,
+            'offers': [v.offers],
+        }
+        request = RequestFactory().post('/', data=data)
+        response = view(request, pk=v.pk)
+        assert response.status_code == 302
+        assert response.url == reverse('dashboard:voucher-set-update', kwargs={'pk': vs.pk})
+        assert [(m.level_tag, str(m.message)) for m in get_messages(request)][0] == (
+            'warning', "The voucher can only be edited as part of its set")
+        v.refresh_from_db()
+        assert v.name != "New name"
+
+    def test_voucher_delete_view(self):
+        v = voucher.VoucherFactory()
+        v.offers.add(ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER))
+        assert Voucher.objects.count() == 1
+        assert ConditionalOffer.objects.count() == 1
+        request = RequestFactory().post('/')
+        response = views.VoucherDeleteView.as_view()(request, pk=v.pk)
+        assert Voucher.objects.count() == 0
+        # Related offer is not deleted
+        assert ConditionalOffer.objects.count() == 1
+        assert response.status_code == 302
+        assert response.url == reverse('dashboard:voucher-list')
+        assert [(m.level_tag, str(m.message)) for m in get_messages(request)][0] == ('warning', "Voucher deleted")
+
+    def test_voucher_delete_view_for_voucher_in_set(self):
+        vs = voucher.VoucherSetFactory(count=10)
+        assert Voucher.objects.count() == 10
+        request = RequestFactory().post('/')
+        response = views.VoucherDeleteView.as_view()(request, pk=vs.vouchers.first().pk)
+        vs.refresh_from_db()
+        assert vs.count == 9  # "count" is updated
+        assert Voucher.objects.count() == 9
+        assert response.status_code == 302
+        assert response.url == reverse('dashboard:voucher-set-detail', kwargs={'pk': vs.pk})
+        assert [(m.level_tag, str(m.message)) for m in get_messages(request)][0] == ('warning', "Voucher deleted")
+
+
 @pytest.mark.django_db
 class TestDashboardVoucherSets:
 
@@ -33,3 +99,15 @@ def test_voucher_set_detail_view(self, rf):
         # The view should only list vouchers for vs2
         assert len(response.context_data['vouchers']) == 15
         assert response.status_code == 200
+
+    def test_voucher_set_delete_view(self):
+        vs = voucher.VoucherSetFactory(count=10)
+        assert VoucherSet.objects.count() == 1
+        assert Voucher.objects.count() == 10
+        request = RequestFactory().post('/')
+        response = views.VoucherSetDeleteView.as_view()(request, pk=vs.pk)
+        assert VoucherSet.objects.count() == 0
+        assert Voucher.objects.count() == 0
+        assert response.status_code == 302
+        assert response.url == reverse('dashboard:voucher-set-list')
+        assert [(m.level_tag, str(m.message)) for m in get_messages(request)][0] == ('warning', "Voucher set deleted")
diff --git a/tests/integration/forms/test_widget.py b/tests/integration/forms/test_widget.py
--- a/tests/integration/forms/test_widget.py
+++ b/tests/integration/forms/test_widget.py
@@ -193,3 +193,9 @@ def test_multiselect_widget_attrs(self):
         field = self._get_multiselect_form_field()
         attrs = field.widget.get_context(name='my_field', value=None, attrs={})['widget']['attrs']
         self.assertEqual(attrs['data-multiple'], 'multiple')
+
+
+class NullBooleanSelectTestCase(TestCase):
+
+    def test_unknown_choice_label(self):
+        self.assertEqual(dict(widgets.NullBooleanSelect().choices)['unknown'], '---------')
diff --git a/tests/integration/offer/test_absolute_benefit.py b/tests/integration/offer/test_absolute_benefit.py
--- a/tests/integration/offer/test_absolute_benefit.py
+++ b/tests/integration/offer/test_absolute_benefit.py
@@ -364,12 +364,14 @@ def test_non_negative_basket_lines_values(self):
             value=D('10'))
         models.ConditionalOffer.objects.create(
             name='offer1',
+            offer_type=models.ConditionalOffer.SITE,
             benefit=benefit1,
             condition=condition,
             exclusive=False
         )
         models.ConditionalOffer.objects.create(
             name='offer2',
+            offer_type=models.ConditionalOffer.SITE,
             benefit=benefit2,
             condition=condition,
             exclusive=False
diff --git a/tests/integration/offer/test_forms.py b/tests/integration/offer/test_forms.py
--- a/tests/integration/offer/test_forms.py
+++ b/tests/integration/offer/test_forms.py
@@ -4,6 +4,34 @@
 from django.utils.timezone import now
 
 from oscar.apps.dashboard.offers import forms
+from oscar.apps.offer.models import ConditionalOffer
+from oscar.test.factories import ConditionalOfferFactory, VoucherFactory
+
+
+class TestMetaDataForm(TestCase):
+
+    def test_changing_offer_type_for_voucher_offer_without_vouchers(self):
+        offer = ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER)
+        data = {
+            'name': offer.name,
+            'description': offer.description,
+            'offer_type': ConditionalOffer.SITE,
+        }
+        form = forms.MetaDataForm(data, instance=offer)
+        self.assertTrue(form.is_valid())
+
+    def test_changing_offer_type_for_voucher_offer_with_vouchers(self):
+        offer = ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER)
+        VoucherFactory().offers.add(offer)
+        data = {
+            'name': offer.name,
+            'description': offer.description,
+            'offer_type': ConditionalOffer.SITE,
+        }
+        form = forms.MetaDataForm(data, instance=offer)
+        self.assertFalse(form.is_valid())
+        self.assertEqual(form.errors['offer_type'][0],
+                         "This can only be changed if it has no vouchers attached to it")
 
 
 class TestRestrictionsFormEnforces(TestCase):
diff --git a/tests/integration/offer/test_priority_offers.py b/tests/integration/offer/test_priority_offers.py
--- a/tests/integration/offer/test_priority_offers.py
+++ b/tests/integration/offer/test_priority_offers.py
@@ -27,6 +27,7 @@ def test_basket_offers_are_ordered(self):
         voucher = Voucher.objects.create(
             name="Test voucher",
             code="test",
+            usage=Voucher.MULTI_USE,
             start_datetime=timezone.now(),
             end_datetime=timezone.now() + datetime.timedelta(days=12))
 
diff --git a/tests/integration/voucher/test_forms.py b/tests/integration/voucher/test_forms.py
--- a/tests/integration/voucher/test_forms.py
+++ b/tests/integration/voucher/test_forms.py
@@ -1,15 +1,27 @@
+import datetime
+
 import pytest
+from django.utils import timezone
 from django.utils.datastructures import MultiValueDict
 
 from oscar.apps.dashboard.vouchers import forms
-from oscar.test.factories.offer import RangeFactory
+from oscar.core.loading import get_model
+from oscar.test.factories.offer import (
+    BenefitFactory, ConditionalOfferFactory, ConditionFactory, RangeFactory)
+from oscar.test.factories.voucher import VoucherSetFactory
+
+ConditionalOffer = get_model('offer', 'ConditionalOffer')
+Voucher = get_model('voucher', 'Voucher')
 
 
 @pytest.mark.django_db
-def test_voucherform_set_create():
+def test_voucher_set_form_create():
     a_range = RangeFactory(
         includes_all_products=True
     )
+    offer = ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER,
+                                    benefit=BenefitFactory(range=a_range),
+                                    condition=ConditionFactory(range=a_range, value=1))
     data = MultiValueDict({
         'name': ['10% Discount'],
         'code_length': ['10'],
@@ -17,11 +29,89 @@ def test_voucherform_set_create():
         'description': ['This is a 10% discount for mailing X'],
         'start_datetime': ['2014-10-01'],
         'end_datetime': ['2018-10-01'],
-        'benefit_range': [a_range.pk],
-        'benefit_type': ['Percentage'],
-        'benefit_value': ['10'],
+        'usage': [Voucher.MULTI_USE],
+        'offers': [offer.pk],
     })
     form = forms.VoucherSetForm(data)
     assert form.is_valid(), form.errors
     voucher_set = form.save()
     assert voucher_set.vouchers.count() == 10
+
+
+@pytest.mark.django_db
+def test_voucher_set_form_update_with_unchanged_count():
+    tzinfo = timezone.get_current_timezone()
+    voucher_set = VoucherSetFactory(name="Dummy name",
+                                    count=5,
+                                    code_length=12,
+                                    description="Dummy description",
+                                    start_datetime=datetime.datetime(2021, 2, 1, tzinfo=tzinfo),
+                                    end_datetime=datetime.datetime(2021, 2, 28, tzinfo=tzinfo))
+    voucher = voucher_set.vouchers.first()
+    assert voucher.usage == Voucher.MULTI_USE
+    new_offers = [ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER),
+                  ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER)]
+    data = {
+        'name': "New name",
+        'code_length': 10,
+        'description': "New description",
+        'start_datetime': datetime.datetime(2021, 3, 1, tzinfo=tzinfo),
+        'end_datetime': datetime.datetime(2021, 3, 31, tzinfo=tzinfo),
+        'count': voucher_set.count,
+        'usage': Voucher.SINGLE_USE,
+        'offers': new_offers,
+    }
+    form = forms.VoucherSetForm(data, instance=voucher_set)
+    assert form.is_valid(), form.errors
+    voucher_set = form.save()
+    assert voucher_set.vouchers.count() == 5
+    for i, v in enumerate(voucher_set.vouchers.order_by('date_created')):
+        assert v.name == "New name - %d" % (i + 1)
+        assert len(v.code) == 14  # The code is not modified
+        assert v.start_datetime == datetime.datetime(2021, 3, 1, tzinfo=tzinfo)
+        assert v.end_datetime == datetime.datetime(2021, 3, 31, tzinfo=tzinfo)
+        assert v.usage == Voucher.SINGLE_USE
+        assert list(v.offers.all()) == new_offers
+
+
+@pytest.mark.django_db
+def test_voucher_set_form_update_with_changed_count():
+    tzinfo = timezone.get_current_timezone()
+    voucher_set = VoucherSetFactory(name="Dummy name",
+                                    count=5,
+                                    code_length=12,
+                                    description="Dummy description",
+                                    start_datetime=datetime.datetime(2021, 2, 1, tzinfo=tzinfo),
+                                    end_datetime=datetime.datetime(2021, 2, 28, tzinfo=tzinfo))
+    voucher = voucher_set.vouchers.first()
+    assert voucher.usage == Voucher.MULTI_USE
+    new_offers = [ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER),
+                  ConditionalOfferFactory(offer_type=ConditionalOffer.VOUCHER)]
+    data = {
+        'name': "New name",
+        'code_length': 10,
+        'description': "New description",
+        'start_datetime': datetime.datetime(2021, 3, 1, tzinfo=tzinfo),
+        'end_datetime': datetime.datetime(2021, 3, 31, tzinfo=tzinfo),
+        'count': 10,
+        'usage': Voucher.SINGLE_USE,
+        'offers': new_offers,
+    }
+    form = forms.VoucherSetForm(data, instance=voucher_set)
+    assert form.is_valid(), form.errors
+    voucher_set = form.save()
+    voucher_set.refresh_from_db()
+    assert voucher_set.count == 10  # "count" is updated
+    assert voucher_set.vouchers.count() == 10
+    for i, v in enumerate(voucher_set.vouchers.order_by('date_created')):
+        assert v.name == "New name - %d" % (i + 1)
+        if i < 5:
+            # Original vouchers
+            assert len(v.code) == 14  # The code is not modified
+        else:
+            # New vouchers
+            assert len(v.code) == 12
+        assert v.start_datetime == datetime.datetime(2021, 3, 1, tzinfo=tzinfo)
+        assert v.end_datetime == datetime.datetime(2021, 3, 31, tzinfo=tzinfo)
+        assert v.usage == Voucher.SINGLE_USE
+        assert list(v.offers.all()) == new_offers
diff --git a/tests/integration/voucher/test_models.py b/tests/integration/voucher/test_models.py
--- a/tests/integration/voucher/test_models.py
+++ b/tests/integration/voucher/test_models.py
@@ -5,7 +5,6 @@
 from django.core import exceptions
 from django.test import TestCase
 from django.utils.timezone import utc
-from django.utils.translation import gettext_lazy as _
 
 from oscar.apps.voucher.models import Voucher
 from oscar.core.compat import get_user_model
@@ -108,85 +107,6 @@ def setUp(self):
         self.offer_range = RangeFactory(products=[product])
         self.offer_condition = ConditionFactory(range=self.offer_range, value=2)
 
-    def test_related_offer_deleted(self):
-        # Voucher with offer name corresponding to it as used in the dashboard
-        voucher_name = "Voucher"
-        voucher = VoucherFactory(name=voucher_name, code="VOUCHER")
-        voucher.offers.add(
-            create_offer(
-                name=_("Offer for voucher '%s'") % voucher_name,
-                offer_type='Voucher',
-                range=self.offer_range,
-                condition=self.offer_condition
-            )
-        )
-
-        voucher.delete()
-        self.assertFalse(
-            ConditionalOffer.objects.filter(
-                name=_("Offer for voucher '%s'") % voucher_name,
-                offer_type=ConditionalOffer.VOUCHER
-            ).exists())
-
-    def test_related_offer_different_name_not_deleted(self):
-        # Voucher with offer named differently
-        voucher = VoucherFactory(name="Voucher", code="VOUCHER")
-        voucher.offers.add(
-            create_offer(
-                name="Different name test",
-                offer_type='Voucher',
-                range=self.offer_range,
-                condition=self.offer_condition
-            )
-        )
-
-        offer_ids = list(voucher.offers.all().values_list('pk', flat=True))
-
-        voucher.delete()
-        count_offers = ConditionalOffer.objects.filter(id__in=offer_ids).count()
-        assert len(offer_ids) == count_offers
-
-    def test_related_offer_different_type_not_deleted(self):
-        # Voucher with offer not of type "Voucher"
-        voucher_name = "Voucher"
-        voucher = VoucherFactory(name=voucher_name, code="VOUCHER")
-        voucher.offers.add(
-            create_offer(
-                name=_("Offer for voucher '%s'") % voucher_name,
-                offer_type='Site',
-                range=self.offer_range,
-                condition=self.offer_condition
-            )
-        )
-
-        offer_ids = list(voucher.offers.all().values_list('pk', flat=True))
-
-        voucher.delete()
-        count_offers = ConditionalOffer.objects.filter(id__in=offer_ids).count()
-        assert len(offer_ids) == count_offers
-
-    def test_multiple_related_offers_not_deleted(self):
-        # Voucher with already used offer
-        voucher_name = "Voucher 1"
-        offer = create_offer(
-            name=_("Offer for voucher '%s'") % voucher_name,
-            offer_type='Voucher',
-            range=self.offer_range,
-            condition=self.offer_condition
-        )
-
-        voucher1 = VoucherFactory(name=voucher_name, code="VOUCHER1")
-        voucher1.offers.add(offer)
-
-        voucher2 = VoucherFactory(name="Voucher 2", code="VOUCHER2")
-        voucher2.offers.add(offer)
-
-        offer_ids = list(voucher1.offers.all().values_list('pk', flat=True))
-
-        voucher1.delete()
-        count_offers = ConditionalOffer.objects.filter(id__in=offer_ids).count()
-        assert len(offer_ids) == count_offers
-
 
 class TestAvailableForBasket(TestCase):
 
@@ -214,19 +134,21 @@ class TestVoucherSet(object):
     def test_factory(self):
         voucherset = VoucherSetFactory()
         assert voucherset.count == voucherset.vouchers.count()
-        code = voucherset.vouchers.first().code
-        assert len(code) == 14
-        assert code.count('-') == 2
         assert str(voucherset) == voucherset.name
-        assert voucherset.offer
+        offers = voucherset.vouchers.first().offers.all()
         for voucher in voucherset.vouchers.all():
-            assert voucherset.offer in voucher.offers.all()
+            assert len(voucher.code) == 14
+            assert voucher.code.count('-') == 2
+            list(voucher.offers.all()) == list(offers)
+            assert voucher.offers.count() == 1
+            assert voucher.offers.filter(offer_type=ConditionalOffer.VOUCHER).count() == 1
 
-    def test_min_count(self):
+    def test_update_count(self):
         voucherset = VoucherSetFactory(count=20)
         assert voucherset.count == 20
         voucherset.count = 10
         voucherset.save()
+        voucherset.update_count()
         voucherset.refresh_from_db()
         assert voucherset.count == 20