Repository: carpentries/amy

Issue:
Move from GET views for object deletions to DeleteViewContext
`DeleteViewContext` is introduced in the PR #875



Discussion:
This can be easily fixed with a better version of `DeleteContextView` that I'll introduce in instructor-training branch:

``` python
class DeleteViewContext(DeleteView):
    """
    Class-based view for deleting objects that extends default template context
    by adding proper page title.

    GET requests are redirected to appropriate update views.

    ProtectedErrors are handled.
    """
    success_message = '{} was deleted successfully.'

    def delete(self, request, *args, **kwargs):
        '''Workaround for https://code.djangoproject.com/ticket/21926'''
        messages.success(
            self.request,
            self.success_message.format(self.get_object())
        )
        try:
            return super().delete(request, *args, **kwargs)
        except ProtectedError as e:
            return failed_to_delete(self.request, self.object,
                                    e.protected_objects)

    def get(self, request, *args, **kwargs):
        self.object = self.get_object()
        return redirect(self.object.get_absolute_url())
```

Where is `failed_to_delete` imported from?

Why do we need to redirect `GET` requests on delete views?

`failed_to_delete` lives in `workshops/util.py`.

By default, `DeleteView` renders confirmation page when it receives GET request, and this requires a template for such page.

Ref. https://github.com/swcarpentry/amy/blob/a82d0c39a4c4f745c57188350e27402b53c1e2c8/workshops/views.py#L160


Resolving patch:
diff --git a/workshops/urls.py b/workshops/urls.py
--- a/workshops/urls.py
+++ b/workshops/urls.py
@@ -12,23 +12,23 @@
     url(r'^organizations/?$', views.all_organizations, name='all_organizations'),
     url(r'^organization/(?P<org_domain>[\w\.-]+)/?$', views.organization_details, name='organization_details'),
     url(r'^organization/(?P<org_domain>[\w\.-]+)/edit$', views.OrganizationUpdate.as_view(), name='organization_edit'),
-    url(r'^organization/(?P<org_domain>[\w\.-]+)/delete$', views.organization_delete, name='organization_delete'),
+    url(r'^organization/(?P<org_domain>[\w\.-]+)/delete$', views.OrganizationDelete.as_view(), name='organization_delete'),
     url(r'^organizations/add/$', views.OrganizationCreate.as_view(), name='organization_add'),
 
     url(r'membership/(?P<org_domain>[\w\.-]+)/add', views.membership_create, name='membership_add'),
     url(r'membership/(?P<membership_id>\d+)/edit', views.MembershipUpdate.as_view(), name='membership_edit'),
-    url(r'membership/(?P<membership_id>\d+)/delete', views.membership_delete, name='membership_delete'),
+    url(r'membership/(?P<membership_id>\d+)/delete', views.MembershipDelete.as_view(), name='membership_delete'),
 
     url(r'^airports/?$', views.all_airports, name='all_airports'),
     url(r'^airport/(?P<airport_iata>\w+)/?$', views.airport_details, name='airport_details'),
     url(r'^airport/(?P<airport_iata>\w+)/edit$', views.AirportUpdate.as_view(), name='airport_edit'),
-    url(r'^airport/(?P<airport_iata>\w+)/delete$', views.airport_delete, name='airport_delete'),
+    url(r'^airport/(?P<airport_iata>\w+)/delete$', views.AirportDelete.as_view(), name='airport_delete'),
     url(r'^airports/add/$', views.AirportCreate.as_view(), name='airport_add'),
 
     url(r'^persons/?$', views.all_persons, name='all_persons'),
     url(r'^person/(?P<person_id>[\w\.-]+)/?$', views.person_details, name='person_details'),
     url(r'^person/(?P<person_id>[\w\.-]+)/edit$', views.person_edit, name='person_edit'),
-    url(r'^person/(?P<person_id>[\w\.-]+)/delete$', views.person_delete, name='person_delete'),
+    url(r'^person/(?P<person_id>\d+)/delete$', views.PersonDelete.as_view(), name='person_delete'),
     url(r'^person/(?P<person_id>[\w\.-]+)/permissions$', views.PersonPermissions.as_view(), name='person_permissions'),
     url(r'^person/(?P<person_id>[\w\.-]+)/password$', views.person_password, name='person_password'),
     url(r'^persons/add/$', views.PersonCreate.as_view(), name='person_add'),
@@ -43,7 +43,7 @@
     url(r'^event/(?P<slug>[\w-]+)/assign$', views.event_assign, name='event_assign'),
     url(r'^event/(?P<slug>[\w-]+)/assign/(?P<person_id>[\w\.-]+)$', views.event_assign, name='event_assign'),
     url(r'^event/(?P<slug>[\w-]+)/edit$', views.event_edit, name='event_edit'),
-    url(r'^event/(?P<slug>[\w-]+)/delete$', views.event_delete, name='event_delete'),
+    url(r'^event/(?P<slug>[\w-]+)/delete$', views.EventDelete.as_view(), name='event_delete'),
     url(r'^events/add/$', views.EventCreate.as_view(), name='event_add'),
     url(r'^event/(?P<slug>[\w-]+)/validate/?$', views.validate_event, name='validate_event'),
     url(r'^events/metadata_changed/?$', views.events_metadata_changed, name='events_metadata_changed'),
@@ -61,14 +61,12 @@
     url(r'^tasks/?$', views.all_tasks, name='all_tasks'),
     url(r'^task/(?P<task_id>\d+)/?$', views.task_details, name='task_details'),
     url(r'^task/(?P<task_id>\d+)/edit$', views.TaskUpdate.as_view(), name='task_edit'),
-    url(r'^task/(?P<task_id>\d+)/delete$', views.task_delete, name='task_delete'),
-    url(r'^event/(?P<slug>[\w-]+)/task/(?P<task_id>\d+)/delete$', views.task_delete, name='task_delete'),
+    url(r'^task/(?P<task_id>\d+)/delete$', views.TaskDelete.as_view(), name='task_delete'),
     url(r'^tasks/add/$', views.TaskCreate.as_view(), name='task_add'),
 
     url(r'^sponsorship/(?P<pk>\d+)/delete$', views.SponsorshipDelete.as_view(), name='sponsorship_delete'),
 
-    url(r'^award/(?P<award_id>\d+)/delete$', views.award_delete, name='award_delete'),
-    url(r'^person/(?P<person_id>[\w\.-]+)/award/(?P<award_id>\d+)/delete$', views.award_delete, name='award_delete'),
+    url(r'^award/(?P<pk>\d+)/delete$', views.AwardDelete.as_view(), name='award_delete'),
 
     url(r'^badges/?$', views.all_badges, name='all_badges'),
     url(r'^badge/(?P<badge_name>[\w\.=-]+)/?$', views.badge_details, name='badge_details'),
@@ -151,5 +149,5 @@
     url(r'^todo/(?P<todo_id>\d+)/completed', views.todo_mark_completed, name='todo_mark_completed'),
     url(r'^todo/(?P<todo_id>\d+)/incompleted', views.todo_mark_incompleted, name='todo_mark_incompleted'),
     url(r'^todo/(?P<todo_id>\d+)/edit', views.TodoItemUpdate.as_view(), name='todo_edit'),
-    url(r'^todo/(?P<todo_id>\d+)/delete', views.todo_delete, name='todo_delete'),
+    url(r'^todo/(?P<todo_id>\d+)/delete', views.TodoDelete.as_view(), name='todo_delete'),
 ]
diff --git a/workshops/views.py b/workshops/views.py
--- a/workshops/views.py
+++ b/workshops/views.py
@@ -21,7 +21,7 @@
 from django.db.models import Case, When, Value, IntegerField
 from django.db.models import Count, Q, F, Model, ProtectedError, Sum
 from django.http import Http404, HttpResponse, JsonResponse
-from django.http import HttpResponseBadRequest
+from django.http import HttpResponseBadRequest, HttpResponseRedirect
 from django.shortcuts import redirect, render, get_object_or_404
 from django.template.loader import get_template
 from django.utils.http import is_safe_url
@@ -222,19 +222,26 @@ class DeleteViewContext(DeleteView):
     by adding proper page title.
 
     GET requests are not allowed (returns 405)
-
+    Allows for custom redirection based on `next` param in POST
     ProtectedErrors are handled.
     """
     success_message = '{} was deleted successfully.'
 
     def delete(self, request, *args, **kwargs):
-        '''Workaround for https://code.djangoproject.com/ticket/21926'''
-        messages.success(
-            self.request,
-            self.success_message.format(self.get_object())
-        )
+        # Workaround for https://code.djangoproject.com/ticket/21926
+        # Replicates the `delete` method of DeleteMixin
+        self.object = self.get_object()
+        if request.POST.get('next', None):
+            success_url = request.POST['next']
+        else:
+            success_url = self.get_success_url()
         try:
-            return super().delete(request, *args, **kwargs)
+            self.object.delete()
+            messages.success(
+                self.request,
+                self.success_message.format(self.object)
+            )
+            return HttpResponseRedirect(success_url)
         except ProtectedError as e:
             return failed_to_delete(self.request, self.object,
                                     e.protected_objects)
@@ -440,17 +447,13 @@ class OrganizationUpdate(OnlyForAdminsMixin, PermissionRequiredMixin,
     slug_url_kwarg = 'org_domain'
 
 
-@admin_required
-@permission_required('workshops.delete_organization', raise_exception=True)
-def organization_delete(request, org_domain):
-    """Delete specific organization."""
-    try:
-        organization = get_object_or_404(Organization, domain=org_domain)
-        organization.delete()
-        messages.success(request, 'Organization was deleted successfully.')
-        return redirect(reverse('all_organizations'))
-    except ProtectedError as e:
-        return failed_to_delete(request, organization, e.protected_objects)
+class OrganizationDelete(OnlyForAdminsMixin, PermissionRequiredMixin,
+                         DeleteViewContext):
+    model = Organization
+    slug_field = 'domain'
+    slug_url_kwarg = 'org_domain'
+    permission_required = 'workshops.delete_organization'
+    success_url = reverse_lazy('all_organizations')
 
 
 @admin_required
@@ -493,21 +496,14 @@ def get_success_url(self):
         )
 
 
-@admin_required
-@permission_required('workshops.delete_membership', raise_exception=True)
-def membership_delete(request, membership_id):
-    """Delete specific membership."""
-    try:
-        membership = get_object_or_404(Membership, pk=membership_id)
-        organization = membership.organization
-        membership.delete()
-        messages.success(request, 'Membership was deleted successfully.')
-        return redirect(
-            reverse('organization_details', args=[organization.domain])
-        )
-    except ProtectedError as e:
-        return failed_to_delete(request, organization, e.protected_objects)
+class MembershipDelete(OnlyForAdminsMixin, PermissionRequiredMixin,
+                       DeleteViewContext):
+    model = Membership
+    permission_required = 'workshops.delete_membership'
+    pk_url_kwarg = 'membership_id'
 
+    def get_success_url(self):
+        return reverse('organization_details', args=[self.get_object().organization.domain])
 
 #------------------------------------------------------------
 
@@ -550,17 +546,13 @@ class AirportUpdate(OnlyForAdminsMixin, PermissionRequiredMixin,
     slug_url_kwarg = 'airport_iata'
 
 
-@admin_required
-@permission_required('workshops.delete_airport', raise_exception=True)
-def airport_delete(request, airport_iata):
-    """Delete specific airport."""
-    try:
-        airport = get_object_or_404(Airport, iata=airport_iata)
-        airport.delete()
-        messages.success(request, 'Airport was deleted successfully.')
-        return redirect(reverse('all_airports'))
-    except ProtectedError as e:
-        return failed_to_delete(request, airport, e.protected_objects)
+class AirportDelete(OnlyForAdminsMixin, PermissionRequiredMixin,
+                    DeleteViewContext):
+    model = Airport
+    slug_field = 'iata'
+    slug_url_kwarg = 'airport_iata'
+    permission_required = 'workshops.delete_airport'
+    success_url = reverse_lazy('all_airports')
 
 #------------------------------------------------------------
 
@@ -945,18 +937,12 @@ def person_edit(request, person_id):
     return render(request, 'workshops/person_edit_form.html', context)
 
 
-@admin_required
-@permission_required('workshops.delete_person', raise_exception=True)
-def person_delete(request, person_id):
-    """Delete specific person."""
-    try:
-        person = get_object_or_404(Person, pk=person_id)
-        person.delete()
-
-        messages.success(request, 'Person was deleted successfully.')
-        return redirect(reverse('all_persons'))
-    except ProtectedError as e:
-        return failed_to_delete(request, person, e.protected_objects)
+class PersonDelete(OnlyForAdminsMixin, PermissionRequiredMixin,
+                   DeleteViewContext):
+    model = Person
+    permission_required = 'workshops.delete_person'
+    success_url = reverse_lazy('all_persons')
+    pk_url_kwarg = 'person_id'
 
 
 class PersonPermissions(OnlyForAdminsMixin, PermissionRequiredMixin,
@@ -1342,21 +1328,11 @@ def event_edit(request, slug):
     return render(request, 'workshops/event_edit_form.html', context)
 
 
-@admin_required
-@permission_required('workshops.delete_event', raise_exception=True)
-def event_delete(request, slug):
-    """Delete event, its tasks and related awards."""
-    try:
-        event = Event.objects.get(slug=slug)
-        event.delete()
-
-        messages.success(request,
-                         'Event and its tasks were deleted successfully.')
-        return redirect(reverse('all_events'))
-    except ObjectDoesNotExist:
-        raise Http404("No event found matching the query.")
-    except ProtectedError as e:
-        return failed_to_delete(request, event, e.protected_objects)
+class EventDelete(OnlyForAdminsMixin, PermissionRequiredMixin,
+                  DeleteViewContext):
+    model = Event
+    permission_required = 'workshops.delete_event'
+    success_url = reverse_lazy('all_events')
 
 
 @admin_required
@@ -1726,20 +1702,6 @@ def task_details(request, task_id):
     return render(request, 'workshops/task.html', context)
 
 
-@admin_required
-@permission_required('workshops.delete_task', raise_exception=True)
-def task_delete(request, task_id, slug=None):
-    '''Delete a task. This is used on the event edit page'''
-    t = get_object_or_404(Task, pk=task_id)
-    t.delete()
-
-    messages.success(request, 'Task was deleted successfully.')
-
-    if slug:
-        return redirect(event_edit, slug)
-    return redirect(all_tasks)
-
-
 class TaskCreate(OnlyForAdminsMixin, PermissionRequiredMixin,
                  CreateViewContext):
     permission_required = 'workshops.add_task'
@@ -1754,25 +1716,25 @@ class TaskUpdate(OnlyForAdminsMixin, PermissionRequiredMixin,
     form_class = TaskFullForm
     pk_url_kwarg = 'task_id'
 
-#------------------------------------------------------------
 
+class TaskDelete(OnlyForAdminsMixin, PermissionRequiredMixin,
+                 DeleteViewContext):
+    model = Task
+    permission_required = 'workshops.delete_task'
+    success_url = reverse_lazy('all_tasks')
+    pk_url_kwarg = 'task_id'
 
-@admin_required
-@permission_required('workshops.delete_award', raise_exception=True)
-def award_delete(request, award_id, person_id=None):
-    """Delete an award. This is used on the person edit page."""
-    award = get_object_or_404(Award, pk=award_id)
-    badge_name = award.badge.name
-    award.delete()
 
-    messages.success(request, 'Award was deleted successfully.',
-                     extra_tags='awards')
+#------------------------------------------------------------
 
-    if person_id:
-        # if a second form of URL, then return back to person edit page
-        return redirect(person_edit, person_id)
 
-    return redirect(reverse(badge_details, args=[badge_name]))
+class AwardDelete(OnlyForAdminsMixin, PermissionRequiredMixin,
+                 DeleteViewContext):
+    model = Award
+    permission_required = 'workshops.delete_award'
+
+    def get_success_url(self):
+        return reverse('person_edit', args=[self.get_object().person.pk]) + '#awards'
 
 
 #------------------------------------------------------------
@@ -3141,19 +3103,14 @@ def form_valid(self, form):
         return response
 
 
-@admin_required
-@permission_required('workshops.delete_todoitem', raise_exception=True)
-def todo_delete(request, todo_id):
-    """Delete a TodoItem. This is used on the event details page."""
-    todo = get_object_or_404(TodoItem, pk=todo_id)
-    slug = todo.event.slug
-    todo.delete()
-
-    messages.success(request, 'TODO was deleted successfully.',
-                     extra_tags='todos')
-
-    return redirect(event_details, slug)
+class TodoDelete(OnlyForAdminsMixin, PermissionRequiredMixin,
+                 DeleteViewContext):
+    model = TodoItem
+    permission_required = 'workshops.delete_todoitem'
+    pk_url_kwarg = 'todo_id'
 
+    def get_success_url(self):
+        return reverse('event_details', args=[self.get_object().event.slug]) + '#todos'
 
 # ------------------------------------------------------------
 


Test patch:
diff --git a/workshops/test/test_airport.py b/workshops/test/test_airport.py
--- a/workshops/test/test_airport.py
+++ b/workshops/test/test_airport.py
@@ -29,7 +29,7 @@ def test_airport_delete(self):
         ]
 
         for iata in L1[:3]:
-            rv = self.client.get(reverse('airport_delete', args=[iata, ]))
+            rv = self.client.post(reverse('airport_delete', args=[iata, ]))
             content = rv.content.decode('utf-8')
             assert 'Failed to delete' in content
 
@@ -37,7 +37,7 @@ def test_airport_delete(self):
             Person.objects.get(pk=person_pk).delete()
 
         for iata in L1:
-            rv = self.client.get(reverse('airport_delete', args=[iata, ]))
+            rv = self.client.post(reverse('airport_delete', args=[iata, ]))
             assert rv.status_code == 302
 
             with self.assertRaises(Airport.DoesNotExist):
diff --git a/workshops/test/test_badge.py b/workshops/test/test_badge.py
--- a/workshops/test/test_badge.py
+++ b/workshops/test/test_badge.py
@@ -58,22 +58,7 @@ def test_remove_award(self):
         award = person.award_set.all()[0]
         badge = award.badge
         # test first URL
-        rv = self.client.get(
-            reverse('award_delete',
-                    kwargs=dict(award_id=award.pk, person_id=person.pk)),
-        )
-        assert rv.status_code == 302
-        assert award not in badge.award_set.all()  # award really removed
-        assert badge not in person.badges.all()  # badge not avail. via Awards
-
-        person = self.ron
-        award = person.award_set.all()[0]
-        badge = award.badge
-        # test second URL
-        rv = self.client.get(
-            reverse('award_delete',
-                    kwargs=dict(award_id=award.pk)),
-        )
+        rv = self.client.post(reverse('award_delete', args=[award.pk, ]))
         assert rv.status_code == 302
         assert award not in badge.award_set.all()  # award really removed
         assert badge not in person.badges.all()  # badge not avail. via Awards
diff --git a/workshops/test/test_event.py b/workshops/test/test_event.py
--- a/workshops/test/test_event.py
+++ b/workshops/test/test_event.py
@@ -123,7 +123,7 @@ def test_delete_event(self):
         event.task_set = [t1, t2, t3]
         event.save()
 
-        rv = self.client.get(reverse('event_delete', args=[event.slug, ]))
+        rv = self.client.post(reverse('event_delete', args=[event.slug, ]))
         assert rv.status_code == 302
 
         with self.assertRaises(Event.DoesNotExist):
@@ -149,7 +149,7 @@ def test_delete_event_with_tasks_and_awards(self):
                                      awarded=date.today(),
                                      event=event)
 
-        rv = self.client.get(reverse('event_delete', args=[event.slug, ]))
+        rv = self.client.post(reverse('event_delete', args=[event.slug, ]))
         assert rv.status_code == 200
 
         content = rv.content.decode('utf-8')
diff --git a/workshops/test/test_membership.py b/workshops/test/test_membership.py
--- a/workshops/test/test_membership.py
+++ b/workshops/test/test_membership.py
@@ -1,6 +1,8 @@
 from datetime import timedelta, date
 import itertools
 
+from django.core.urlresolvers import reverse
+
 from .base import TestBase
 from ..models import Membership, Organization, Event
 
@@ -8,6 +10,7 @@
 class TestMembership(TestBase):
     def setUp(self):
         super().setUp()
+        self._setUpUsersAndLogin()
 
         # let's add a membership for one of the organizations
         self.current = Membership.objects.create(
@@ -85,3 +88,18 @@ def test_self_organized_workshops(self):
             self.current.self_organized_workshops_per_year_completed, 8)
         self.assertEqual(
             self.current.self_organized_workshops_per_year_remaining, 12)
+
+    def test_delete_membership(self):
+        '''Test that we can delete membership instance'''
+        response = self.client.post(
+            reverse('membership_delete', args=[self.current.pk]),
+            follow=True
+        )
+        self.assertRedirects(
+            response,
+            reverse('organization_details', args=[self.current.organization.domain]),
+        )
+        self.assertFalse(response.context['organization'].membership_set.all())
+        self.assertEqual(response.context['organization'].membership_set.count(), 0)
+        with self.assertRaises(Membership.DoesNotExist):
+            self.current.refresh_from_db()
diff --git a/workshops/test/test_organization.py b/workshops/test/test_organization.py
--- a/workshops/test/test_organization.py
+++ b/workshops/test/test_organization.py
@@ -35,14 +35,14 @@ def test_organization_delete(self):
                              slug='test-event')
 
         for org_domain in [self.org_alpha.domain, self.org_beta.domain]:
-            rv = self.client.get(reverse('organization_delete', args=[org_domain, ]))
+            rv = self.client.post(reverse('organization_delete', args=[org_domain, ]))
             content = rv.content.decode('utf-8')
             assert 'Failed to delete' in content
 
         Event.objects.get(slug='test-event').delete()
 
         for org_domain in [self.org_alpha.domain, self.org_beta.domain]:
-            rv = self.client.get(reverse('organization_delete', args=[org_domain, ]))
+            rv = self.client.post(reverse('organization_delete', args=[org_domain, ]))
             assert rv.status_code == 302
 
             with self.assertRaises(Organization.DoesNotExist):
diff --git a/workshops/test/test_person.py b/workshops/test/test_person.py
--- a/workshops/test/test_person.py
+++ b/workshops/test/test_person.py
@@ -272,7 +272,7 @@ def test_delete_person(self):
             qualifications = person.qualification_set.all()
             tasks = person.task_set.all()
 
-            rv = self.client.get(reverse('person_delete', args=[person.pk, ]))
+            rv = self.client.post(reverse('person_delete', args=[person.pk, ]))
             assert rv.status_code == 302
 
             with self.assertRaises(Person.DoesNotExist):
diff --git a/workshops/test/test_tasks.py b/workshops/test/test_tasks.py
--- a/workshops/test/test_tasks.py
+++ b/workshops/test/test_tasks.py
@@ -131,7 +131,7 @@ def test_task_manager_roles_lookup(self):
     def test_delete_task(self):
         """Make sure deleted task is longer accessible."""
         for task in Task.objects.all():
-            rv = self.client.get(reverse('task_delete', args=[task.pk, ]))
+            rv = self.client.post(reverse('task_delete', args=[task.pk, ]))
             assert rv.status_code == 302
 
             with self.assertRaises(Task.DoesNotExist):
diff --git a/workshops/test/test_todos.py b/workshops/test/test_todos.py
--- a/workshops/test/test_todos.py
+++ b/workshops/test/test_todos.py
@@ -102,7 +102,12 @@ def test_deleted(self):
 
         assert todo in event.todoitem_set.all()
 
-        self.client.get(reverse('todo_delete', args=[todo.pk]))
+        rv = self.client.post(
+            reverse('todo_delete', args=[todo.pk]),
+            {'next': reverse('admin-dashboard')},
+            follow=True
+        )
+        self.assertRedirects(rv, reverse('admin-dashboard'))
 
         assert event.todoitem_set.all().count() == 0