Repository: DDMAL/CantusDB

Issue:
Cry for help: deleting one of "My Sources" is not obvious (in fact... I can't figure it out)
As an editor on Cantus, I can "Add new sources," and add chants to those sources. This function works, because I have successfully done so. However, I now can't make my source go away.

I can delete individual chants (see below), but I can't find where I can delete the entire source.

<img width="758" alt="Deleting a chant" src="https://github.com/DDMAL/cantus/assets/83373378/0c864ea7-df34-423c-b985-3214f9a724ff">

So I guess there are two possible issues here:
1. If it's actually not possible to delete a source once you've created it, it'd be great for that to be possible.
2. If it is possible to delete a source, then perhaps the "Delete" button should be somewhere more obvious to dumb users like me.


Discussion:
@JoyfulGen I transferred this from the Cantus Ultimus repo to CantusDb repo. But for what it's worth, I have not figured out how to do this with the fake sources I created...
I refer you both to #486 . 
In Debra's email she restricted source-deletion to "higher ups" (admins and proofreaders). I think being able to delete your own created source should probably be included, if it's something you can do on OldCantus (I can check if this is the case on a non-admin account, unless @JoyfulGen or @dchiller can confirm?). Maybe this is an intentional change, or maybe it is an oversight in the directives--I will ask.
> I refer you both to https://github.com/DDMAL/CantusDB/issues/486 .

Ah nice -- ty!

> I think being able to delete your own created source should probably be included, if it's something you can do on OldCantus (I can check if this is the case on a non-admin account, unless @JoyfulGen or @dchiller can confirm?).

Just confirmed that I *can* delete a source I created in OldCantus. FYI: this is not listed with other known and intentional differences. 
> I refer you both to #486 . In Debra's email she restricted source-deletion to "higher ups" (admins and proofreaders). I think being able to delete your own created source should probably be included, if it's something you can do on OldCantus (I can check if this is the case on a non-admin account, unless @JoyfulGen or @dchiller can confirm?). Maybe this is an intentional change, or maybe it is an oversight in the directives--I will ask.

As a professional goof who likes to make chaos-inducing mistakes, I can confirm that being able to delete your own resource would be very useful. I can imagine doing something stupid (refer to Fake Einsie mayhem) and wanting to make it disappear quickly and without notice.

Resolving patch:
diff --git a/django/cantusdb_project/main_app/urls.py b/django/cantusdb_project/main_app/urls.py
--- a/django/cantusdb_project/main_app/urls.py
+++ b/django/cantusdb_project/main_app/urls.py
@@ -53,6 +53,7 @@
     SourceDetailView,
     SourceEditView,
     SourceListView,
+    SourceDeleteView,
 )
 from main_app.views.user import (
     LoginView,
@@ -168,7 +169,7 @@
         name="chant-by-cantus-id",
     ),
     path(
-        "chant-delete/<int:pk>",
+        "chant/<int:pk>/delete",
         ChantDeleteView.as_view(),
         name="chant-delete",
     ),
@@ -280,6 +281,11 @@
         SourceEditView.as_view(),
         name="source-edit",
     ),
+    path(
+        "source/<int:pk>/delete",
+        SourceDeleteView.as_view(),
+        name="source-delete",
+    ),
     # melody
     path(
         "melody/",
diff --git a/django/cantusdb_project/main_app/views/source.py b/django/cantusdb_project/main_app/views/source.py
--- a/django/cantusdb_project/main_app/views/source.py
+++ b/django/cantusdb_project/main_app/views/source.py
@@ -1,4 +1,10 @@
-from django.views.generic import DetailView, ListView, CreateView, UpdateView
+from django.views.generic import (
+    DetailView,
+    ListView,
+    CreateView,
+    UpdateView,
+    DeleteView,
+)
 from django.db.models import Q, Prefetch
 from main_app.models import Source, Provenance, Century
 from main_app.forms import SourceCreateForm, SourceEditForm
@@ -218,6 +224,26 @@ def form_valid(self, form):
         return HttpResponseRedirect(self.get_success_url())
 
 
+class SourceDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView):
+    """The view for deleting a source object
+
+    This view is linked to in the source-edit page.
+    """
+
+    model = Source
+    template_name = "source_confirm_delete.html"
+
+    def test_func(self):
+        user = self.request.user
+        source_id = self.kwargs.get(self.pk_url_kwarg)
+        source = get_object_or_404(Source, id=source_id)
+        return user_can_edit_source(user, source)
+
+    def get_success_url(self):
+        # redirect to homepage
+        return "/"
+
+
 class SourceEditView(LoginRequiredMixin, UserPassesTestMixin, UpdateView):
     template_name = "source_edit.html"
     model = Source
@@ -296,5 +322,4 @@ def user_can_edit_source(user, source):
         or (is_contributor and source.created_by == user)
     ):
         return True
-    else:
-        return False
+    return False


Test patch:
diff --git a/django/cantusdb_project/main_app/tests/test_views.py b/django/cantusdb_project/main_app/tests/test_views.py
--- a/django/cantusdb_project/main_app/tests/test_views.py
+++ b/django/cantusdb_project/main_app/tests/test_views.py
@@ -92,35 +92,69 @@ def test_login(self):
         # currently not logged in, should redirect
 
         # ChantCreateView
-        response = self.client.get(f"/chant-create/{source.id}")
+        response = self.client.get(
+            reverse(
+                "chant-create",
+                args=[source.id],
+            )
+        )
         self.assertRedirects(response, f"/login/?next=/chant-create/{source.id}")
 
         # ChantDeleteView
-        response = self.client.get(f"/chant-delete/{chant.id}")
-        self.assertRedirects(response, f"/login/?next=/chant-delete/{chant.id}")
+        response = self.client.get(
+            reverse(
+                "chant-delete",
+                args=[chant.id],
+            )
+        )
+        self.assertRedirects(response, f"/login/?next=/chant/{chant.id}/delete")
 
         # SourceEditChantsView
-        response = self.client.get(f"/edit-chants/{source.id}")
+        response = self.client.get(
+            reverse(
+                "source-edit-chants",
+                args=[source.id],
+            )
+        )
         self.assertRedirects(response, f"/login/?next=/edit-chants/{source.id}")
 
         # SequenceEditView
-        response = self.client.get(f"/edit-sequence/{sequence.id}")
+        response = self.client.get(
+            reverse(
+                "sequence-edit",
+                args=[sequence.id],
+            )
+        )
         self.assertRedirects(response, f"/login/?next=/edit-sequence/{sequence.id}")
 
         # SourceCreateView
-        response = self.client.get("/source-create/")
+        response = self.client.get(reverse("source-create"))
         self.assertRedirects(response, "/login/?next=/source-create/")
 
         # SourceEditView
-        response = self.client.get(f"/edit-source/{source.id}")
+        response = self.client.get(
+            reverse(
+                "source-edit",
+                args=[source.id],
+            )
+        )
         self.assertRedirects(response, f"/login/?next=/edit-source/{source.id}")
 
+        # SourceDeleteView
+        response = self.client.get(
+            reverse(
+                "source-delete",
+                args=[source.id],
+            )
+        )
+        self.assertRedirects(response, f"/login/?next=/source/{source.id}/delete")
+
         # UserSourceListView
-        response = self.client.get("/my-sources/")
+        response = self.client.get(reverse("my-sources"))
         self.assertRedirects(response, "/login/?next=/my-sources/")
 
         # UserListView
-        response = self.client.get("/users/")
+        response = self.client.get(reverse("user-list"))
         self.assertRedirects(response, "/login/?next=/users/")
 
     def test_permissions_project_manager(self):
@@ -134,27 +168,65 @@ def test_permissions_project_manager(self):
         sequence = Sequence.objects.order_by("?").first()
 
         # ChantCreateView
-        response = self.client.get(f"/chant-create/{source.id}")
+        response = self.client.get(
+            reverse(
+                "chant-create",
+                args=[source.id],
+            )
+        )
         self.assertEqual(response.status_code, 200)
 
         # ChantDeleteView
-        response = self.client.get(f"/chant-delete/{chant.id}")
+        response = self.client.get(
+            reverse(
+                "chant-delete",
+                args=[chant.id],
+            )
+        )
         self.assertEqual(response.status_code, 200)
 
         # SourceEditChantsView
-        response = self.client.get(f"/edit-chants/{source.id}")
+        response = self.client.get(
+            reverse(
+                "source-edit-chants",
+                args=[source.id],
+            )
+        )
         self.assertEqual(response.status_code, 200)
 
         # SequenceEditView
-        response = self.client.get(f"/edit-sequence/{sequence.id}")
+        response = self.client.get(
+            reverse(
+                "sequence-edit",
+                args=[sequence.id],
+            )
+        )
         self.assertEqual(response.status_code, 200)
 
         # SourceCreateView
-        response = self.client.get("/source-create/")
+        response = self.client.get(
+            reverse(
+                "source-create",
+            )
+        )
         self.assertEqual(response.status_code, 200)
 
         # SourceEditView
-        response = self.client.get(f"/edit-source/{source.id}")
+        response = self.client.get(
+            reverse(
+                "source-edit",
+                args=[source.id],
+            )
+        )
+        self.assertEqual(response.status_code, 200)
+
+        # SourceDeleteView
+        response = self.client.get(
+            reverse(
+                "source-delete",
+                args=[source.id],
+            )
+        )
         self.assertEqual(response.status_code, 200)
 
         # ContentOverview
@@ -201,7 +273,13 @@ def test_permissions_contributor(self):
         sequence = Sequence.objects.order_by("?").first()
 
         # ChantCreateView
-        response = self.client.get(f"/chant-create/{restricted_source.id}")
+        # response = self.client.get(f"/chant-create/{restricted_source.id}")
+        response = self.client.get(
+            reverse(
+                "chant-create",
+                args=[restricted_source.id],
+            )
+        )
         self.assertEqual(response.status_code, 403)
 
         response = self.client.get(f"/chant-create/{source_created_by_contributor.id}")
@@ -211,15 +289,15 @@ def test_permissions_contributor(self):
         self.assertEqual(response.status_code, 200)
 
         # ChantDeleteView
-        response = self.client.get(f"/chant-delete/{restricted_chant.id}")
+        response = self.client.get(f"/chant/{restricted_chant.id}/delete")
         self.assertEqual(response.status_code, 403)
 
         response = self.client.get(
-            f"/chant-delete/{chant_in_source_created_by_contributor.id}"
+            f"/chant/{chant_in_source_created_by_contributor.id}/delete"
         )
         self.assertEqual(response.status_code, 200)
 
-        response = self.client.get(f"/chant-delete/{chant_in_assigned_source.id}")
+        response = self.client.get(f"/chant/{chant_in_assigned_source.id}/delete")
         self.assertEqual(response.status_code, 200)
 
         # SourceEditChantsView
@@ -250,6 +328,17 @@ def test_permissions_contributor(self):
         response = self.client.get(f"/edit-source/{assigned_source.id}")
         self.assertEqual(response.status_code, 403)
 
+        # SourceDeleteView
+        response = self.client.get(f"/source/{restricted_source.id}/delete")
+        self.assertEqual(response.status_code, 403)
+
+        response = self.client.get(f"/source/{source_created_by_contributor.id}/delete")
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.get(f"/source/{assigned_source.id}/delete")
+        self.assertEqual(response.status_code, 403)
+        
+        # Content Overview
         response = self.client.get(reverse("content-overview"))
         self.assertEqual(response.status_code, 403)
 
@@ -303,15 +392,15 @@ def test_permissions_editor(self):
         self.assertEqual(response.status_code, 200)
 
         # ChantDeleteView
-        response = self.client.get(f"/chant-delete/{restricted_chant.id}")
+        response = self.client.get(f"/chant/{restricted_chant.id}/delete")
         self.assertEqual(response.status_code, 403)
 
         response = self.client.get(
-            f"/chant-delete/{chant_in_source_created_by_contributor.id}"
+            f"/chant/{chant_in_source_created_by_contributor.id}/delete"
         )
         self.assertEqual(response.status_code, 200)
 
-        response = self.client.get(f"/chant-delete/{chant_in_assigned_source.id}")
+        response = self.client.get(f"/chant/{chant_in_assigned_source.id}/delete")
         self.assertEqual(response.status_code, 200)
 
         # SourceEditChantsView
@@ -342,6 +431,17 @@ def test_permissions_editor(self):
         response = self.client.get(f"/edit-source/{assigned_source.id}")
         self.assertEqual(response.status_code, 200)
 
+        # SourceDeleteView
+        response = self.client.get(f"/source/{restricted_source.id}/delete")
+        self.assertEqual(response.status_code, 403)
+
+        response = self.client.get(f"/source/{source_created_by_contributor.id}/delete")
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.get(f"/source/{assigned_source.id}/delete")
+        self.assertEqual(response.status_code, 200)
+        
+        # Content Overview
         response = self.client.get(reverse("content-overview"))
         self.assertEqual(response.status_code, 403)
 
@@ -358,7 +458,7 @@ def test_permissions_default(self):
         self.assertEqual(response.status_code, 403)
 
         # ChantDeleteView
-        response = self.client.get(f"/chant-delete/{chant.id}")
+        response = self.client.get(f"/chant/{chant.id}/delete")
         self.assertEqual(response.status_code, 403)
 
         # SourceEditChantsView
@@ -377,6 +477,10 @@ def test_permissions_default(self):
         response = self.client.get(f"/edit-source/{source.id}")
         self.assertEqual(response.status_code, 403)
 
+        # SourceDeleteView
+        response = self.client.get(f"/source/{source.id}/delete")
+        self.assertEqual(response.status_code, 403)
+        
         response = self.client.get(reverse("content-overview"))
         self.assertEqual(response.status_code, 403)