Repository: carpentries/amy

Issue:
Warn before deleting awards when deleting event
Right now, deleting an event results in all tasks associated with the event being deleted (good), but also deletes all awards associated with the event (very probably bad - someone shouldn't lose their Instructor badge because a training workshop was accidentally deleted).  We should get stronger confirmation, or at the very least warn the deleter about all the awards that are about to go away and/or provide an option to change their Event link.



Discussion:
Our thinking was that an award should be null if the event did not happen. Here are details: https://github.com/swcarpentry/amy/pull/357#issuecomment-111178538

Our options:
1. We have a confirmation dialog we could extend with warning about related awards and tasks being removed: https://github.com/swcarpentry/amy/blob/master/workshops/templates/workshops/event.html#L68
2. We can add a page-in-the-middle that displays the warning in a more verbose way, probably listing soon-to-be-deleted tasks and awards
3. We can add a page that's showed in case the event has related tasks/awards and prevents that event from removal while the tasks/award exist.
4. We can add a page-in-the-middle that allows user to select what should happen with related awards/tasks.

@gvwilson which one do you prefer?

@wking any comments?

Nullifying the award is better than deleting it.

On Sun, Jun 21, 2015 at 05:44:00AM -0700, Greg Wilson wrote:

> Right now, deleting an event results in all tasks associated with
> the event being deleted (good), but also deletes all awards
> associated with the event (very probably bad - someone shouldn't
> lose their Instructor badge because a training workshop was
> accidentally deleted).  We should get stronger confirmation, or at
> the very least warn the deleter about all the awards that are about
> to go away and/or provide an option to change their Event link.

We should just PROTECT Award.event then [1](https://docs.djangoproject.com/en/1.8/ref/models/fields/#django.db.models.PROTECT), so the database protects
you from deleting Events with dependent Awards.  No need for lots of
ignorable user-side confirmation levels.


Resolving patch:
diff --git a/workshops/models.py b/workshops/models.py
--- a/workshops/models.py
+++ b/workshops/models.py
@@ -425,7 +425,8 @@ class Award(models.Model):
     person     = models.ForeignKey(Person)
     badge      = models.ForeignKey(Badge)
     awarded    = models.DateField()
-    event      = models.ForeignKey(Event, null=True, blank=True)
+    event      = models.ForeignKey(Event, null=True, blank=True,
+                                   on_delete=models.PROTECT)
 
     class Meta:
         unique_together = ("person", "badge", )
diff --git a/workshops/views.py b/workshops/views.py
--- a/workshops/views.py
+++ b/workshops/views.py
@@ -1,3 +1,4 @@
+from collections import defaultdict
 import csv
 import datetime
 import io
@@ -199,8 +200,8 @@ def site_delete(request, site_domain):
         site.delete()
         messages.success(request, 'Site was deleted successfully.')
         return redirect(reverse('all_sites'))
-    except ProtectedError:
-        return _failed_to_delete(request, site)
+    except ProtectedError as e:
+        return _failed_to_delete(request, site, e.protected_objects)
 
 
 #------------------------------------------------------------
@@ -251,8 +252,8 @@ def airport_delete(request, airport_iata):
         airport.delete()
         messages.success(request, 'Airport was deleted successfully.')
         return redirect(reverse('all_airports'))
-    except ProtectedError:
-        return _failed_to_delete(request, airport)
+    except ProtectedError as e:
+        return _failed_to_delete(request, airport, e.protected_objects)
 
 #------------------------------------------------------------
 
@@ -573,8 +574,8 @@ def person_delete(request, person_id):
 
         messages.success(request, 'Person was deleted successfully.')
         return redirect(reverse('all_persons'))
-    except ProtectedError:
-        return _failed_to_delete(request, person)
+    except ProtectedError as e:
+        return _failed_to_delete(request, person, e.protected_objects)
 
 
 class PersonPermissions(LoginRequiredMixin, UpdateViewContext):
@@ -808,15 +809,15 @@ def event_delete(request, event_ident):
     """Delete event, its tasks and related awards."""
     try:
         event = Event.get_by_ident(event_ident)
+        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.")
-
-    event.delete()
-
-    messages.success(request,
-                     'Event, its tasks and related awards were deleted '
-                     'successfully.')
-    return redirect(reverse('all_events'))
+    except ProtectedError as e:
+        return _failed_to_delete(request, event, e.protected_objects)
 
 #------------------------------------------------------------
 
@@ -1298,27 +1299,20 @@ def _time_series(request, data, title):
     return render(request, 'workshops/time_series.html', context)
 
 
-def _failed_to_delete(request, object, back=None):
+def _failed_to_delete(request, object, protected_objects, back=None):
     context = {
         'title': 'Failed to delete',
         'back': back or object.get_absolute_url,
         'object': object,
-        'refs': dict(),
+        'refs': defaultdict(list),
     }
 
-    # all reverse FK fields in the object model
-    fields = [
-        f for f in object._meta.get_fields()
-        if (f.one_to_many or f.one_to_one or f.many_to_many) and f.auto_created
-    ]
-    # find out their real names to access with getattr(object, field_name)
-    field_names = [f.related_name or f.name + "_set" for f in fields]
+    for obj in protected_objects:
+        # e.g. for model Award its plural name is 'awards'
+        name = str(obj.__class__._meta.verbose_name_plural)
+        context['refs'][name].append(obj)
 
-    for field_name in field_names:
-        try:
-            context['refs'][field_name] = getattr(object, field_name)
-        except AttributeError:
-            # fail silently if we cannot get some object.field_name
-            pass
+    # this trick enables looping through defaultdict instance
+    context['refs'].default_factory = None
 
     return render(request, 'workshops/failed_to_delete.html', context)


Test patch:
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
@@ -1,10 +1,10 @@
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, date
 import sys
 import cgi
 
 from django.test import TestCase
 from django.core.urlresolvers import reverse
-from ..models import Event, Site, Tag, Person, Role, Task
+from ..models import (Event, Site, Tag, Person, Role, Task, Award, Badge)
 from .base import TestBase
 
 
@@ -129,6 +129,35 @@ def test_delete_event(self):
             with self.assertRaises(Task.DoesNotExist):
                 Task.objects.get(pk=t.pk)
 
+    def test_delete_event_with_tasks_and_awards(self):
+        """Ensure we cannot delete an event with related tasks and awards.
+
+        Deletion is prevented via Award.event's on_delete=PROTECT."""
+        event = Event.objects.get(slug="starts_today")
+        role = Role.objects.create(name='NonInstructor')
+        badge = Badge.objects.create(name='noninstructor',
+                                     title='Non-instructor',
+                                     criteria='')
+        task = Task.objects.create(event=event, person=self.spiderman,
+                                   role=role)
+        award = Award.objects.create(person=self.spiderman,
+                                     badge=badge,
+                                     awarded=date.today(),
+                                     event=event)
+
+        rv = self.client.get(reverse('event_delete', args=[event.pk, ]))
+        assert rv.status_code == 200
+
+        content = rv.content.decode('utf-8')
+        assert "Failed to delete" in content
+        assert "awards" in content
+
+        # make sure these objects were not deleted
+        Event.objects.get(pk=event.pk)
+        Badge.objects.get(pk=badge.pk)
+        Task.objects.get(pk=task.pk)
+        Award.objects.get(pk=award.pk)
+
 
 class TestEventViews(TestBase):
     "Tests for the event views"