Repository: readthedocs/readthedocs.org

Issue:
Add admin functions for wiping a version from web instances
This is exposed to project owners, but an admin function here would be really helpful for debugging purposes. The existing view for wiping versions will likely need to be broken out to a reusable view for the admin function.


Discussion:
@agjohnson, I would like to work on this issue. Could use please elaborate it little more. 
Hi @agjohnson, I'm new here! This enhancement is not completely clear to me, so I wanted to ask a few things:
1. Are you talking about a ModelAdmin action here by `admin function` or something different from out-of-the-box Django admin actions?
2. If yes, is it about adding the action to `readthedocs.builds.admin.BuildAdmin` or to `readthedocs.projects.admin.ProjectAdmin` (more likely I believe)?
3. If no, then I think it has to be done with `readthedocs.projects.views.private.project_versions` view!
I think point 3 is what you're talking about. Please let me know if this is the case or maybe I'm missing something here.



@ajatprabha we are looking for a django site admin feature, on VersionAdmin.
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.


Resolving patch:
diff --git a/readthedocs/builds/admin.py b/readthedocs/builds/admin.py
--- a/readthedocs/builds/admin.py
+++ b/readthedocs/builds/admin.py
@@ -7,6 +7,7 @@
 
 from readthedocs.builds.models import Build, BuildCommandResult, Version
 from readthedocs.core.utils import trigger_build
+from readthedocs.core.utils.general import wipe_version_via_slugs
 
 
 class BuildCommandResultInline(admin.TabularInline):
@@ -57,7 +58,22 @@ class VersionAdmin(GuardedModelAdmin):
     list_filter = ('type', 'privacy_level', 'active', 'built')
     search_fields = ('slug', 'project__slug')
     raw_id_fields = ('project',)
-    actions = ['build_version']
+    actions = ['wipe_selected_versions', 'build_version']
+
+    def wipe_selected_versions(self, request, queryset):
+        """Wipes the selected versions."""
+        for version in queryset:
+            wipe_version_via_slugs(
+                version_slug=version.slug,
+                project_slug=version.project.slug
+            )
+            self.message_user(
+                request,
+                'Wiped {}.'.format(version.slug),
+                level=messages.SUCCESS
+            )
+
+    wipe_selected_versions.short_description = 'Wipe selected versions'
 
     def build_version(self, request, queryset):
         """Trigger a build for the project version."""
diff --git a/readthedocs/core/utils/general.py b/readthedocs/core/utils/general.py
new file mode 100644
--- /dev/null
+++ b/readthedocs/core/utils/general.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+
+import os
+
+from django.shortcuts import get_object_or_404
+
+from readthedocs.core.utils import broadcast
+from readthedocs.projects.tasks import remove_dirs
+from readthedocs.builds.models import Version
+
+
+def wipe_version_via_slugs(version_slug, project_slug):
+    """Wipes the given version of a given project."""
+    version = get_object_or_404(
+        Version,
+        slug=version_slug,
+        project__slug=project_slug,
+    )
+    del_dirs = [
+        os.path.join(version.project.doc_path, 'checkouts', version.slug),
+        os.path.join(version.project.doc_path, 'envs', version.slug),
+        os.path.join(version.project.doc_path, 'conda', version.slug),
+    ]
+    for del_dir in del_dirs:
+        broadcast(type='build', task=remove_dirs, args=[(del_dir,)])
diff --git a/readthedocs/core/views/__init__.py b/readthedocs/core/views/__init__.py
--- a/readthedocs/core/views/__init__.py
+++ b/readthedocs/core/views/__init__.py
@@ -6,8 +6,6 @@
 documentation and header rendering, and server errors.
 """
 
-from __future__ import absolute_import
-from __future__ import division
 import os
 import logging
 from urllib.parse import urlparse
@@ -19,6 +17,7 @@
 
 
 from readthedocs.builds.models import Version
+from readthedocs.core.utils.general import wipe_version_via_slugs
 from readthedocs.core.resolver import resolve_path
 from readthedocs.core.symlink import PrivateSymlink, PublicSymlink
 from readthedocs.core.utils import broadcast
@@ -89,13 +88,10 @@ def wipe_version(request, project_slug, version_slug):
         raise Http404('You must own this project to wipe it.')
 
     if request.method == 'POST':
-        del_dirs = [
-            os.path.join(version.project.doc_path, 'checkouts', version.slug),
-            os.path.join(version.project.doc_path, 'envs', version.slug),
-            os.path.join(version.project.doc_path, 'conda', version.slug),
-        ]
-        for del_dir in del_dirs:
-            broadcast(type='build', task=remove_dirs, args=[(del_dir,)])
+        wipe_version_via_slugs(
+            version_slug=version_slug,
+            project_slug=project_slug
+        )
         return redirect('project_version_list', project_slug)
     return render(
         request,


Test patch:
diff --git a/readthedocs/rtd_tests/tests/test_core_utils.py b/readthedocs/rtd_tests/tests/test_core_utils.py
--- a/readthedocs/rtd_tests/tests/test_core_utils.py
+++ b/readthedocs/rtd_tests/tests/test_core_utils.py
@@ -1,11 +1,17 @@
 # -*- coding: utf-8 -*-
 """Test core util functions."""
 
+import os
 import mock
+
+from mock import call
+from django.http import Http404
 from django.test import TestCase
 from django_dynamic_fixture import get
 
 from readthedocs.builds.models import Version
+from readthedocs.core.utils.general import wipe_version_via_slugs
+from readthedocs.projects.tasks import remove_dirs
 from readthedocs.core.utils import slugify, trigger_build
 from readthedocs.projects.models import Project
 
@@ -153,3 +159,58 @@ def test_slugify(self):
             slugify('A title_-_with separated parts', dns_safe=False),
             'a-title_-_with-separated-parts',
         )
+
+    @mock.patch('readthedocs.core.utils.general.broadcast')
+    def test_wipe_version_via_slug(self, mock_broadcast):
+        wipe_version_via_slugs(
+            version_slug=self.version.slug,
+            project_slug=self.version.project.slug
+        )
+        expected_del_dirs = [
+            os.path.join(self.version.project.doc_path, 'checkouts', self.version.slug),
+            os.path.join(self.version.project.doc_path, 'envs', self.version.slug),
+            os.path.join(self.version.project.doc_path, 'conda', self.version.slug),
+        ]
+
+        mock_broadcast.assert_has_calls(
+            [
+                call(type='build', task=remove_dirs, args=[(expected_del_dirs[0],)]),
+                call(type='build', task=remove_dirs, args=[(expected_del_dirs[1],)]),
+                call(type='build', task=remove_dirs, args=[(expected_del_dirs[2],)]),
+            ],
+            any_order=False
+        )
+
+    @mock.patch('readthedocs.core.utils.general.broadcast')
+    def test_wipe_version_via_slug_wrong_param(self, mock_broadcast):
+        self.assertFalse(Version.objects.filter(slug='wrong-slug').exists())
+        with self.assertRaises(Http404):
+            wipe_version_via_slugs(
+                version_slug='wrong-slug',
+                project_slug=self.version.project.slug
+            )
+        mock_broadcast.assert_not_called()
+
+    @mock.patch('readthedocs.core.utils.general.broadcast')
+    def test_wipe_version_via_slugs_same_version_slug_with_diff_proj(self, mock_broadcast):
+        project_2 = get(Project)
+        version_2 = get(Version, project=project_2, slug=self.version.slug)
+        wipe_version_via_slugs(
+            version_slug=version_2.slug,
+            project_slug=project_2.slug,
+        )
+
+        expected_del_dirs = [
+            os.path.join(version_2.project.doc_path, 'checkouts', version_2.slug),
+            os.path.join(version_2.project.doc_path, 'envs', version_2.slug),
+            os.path.join(version_2.project.doc_path, 'conda', version_2.slug),
+        ]
+
+        mock_broadcast.assert_has_calls(
+            [
+                call(type='build', task=remove_dirs, args=[(expected_del_dirs[0],)]),
+                call(type='build', task=remove_dirs, args=[(expected_del_dirs[1],)]),
+                call(type='build', task=remove_dirs, args=[(expected_del_dirs[2],)]),
+            ],
+            any_order=False
+        )
diff --git a/readthedocs/rtd_tests/tests/versions/__init__.py b/readthedocs/rtd_tests/tests/versions/__init__.py
new file mode 100644
diff --git a/readthedocs/rtd_tests/tests/versions/test_admin_actions.py b/readthedocs/rtd_tests/tests/versions/test_admin_actions.py
new file mode 100644
--- /dev/null
+++ b/readthedocs/rtd_tests/tests/versions/test_admin_actions.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+
+import os
+import mock
+
+from mock import call
+import django_dynamic_fixture as fixture
+from django.test import TestCase
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
+from django.contrib.auth.models import User
+from django import urls
+
+from readthedocs.builds.models import Version
+from readthedocs.core.models import UserProfile
+from readthedocs.projects.models import Project
+from readthedocs.projects.tasks import remove_dirs
+
+
+class VersionAdminActionsTest(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.owner = fixture.get(User)
+        cls.profile = fixture.get(UserProfile, user=cls.owner, banned=False)
+        cls.admin = fixture.get(User, is_staff=True, is_superuser=True)
+        cls.project = fixture.get(
+            Project,
+            main_language_project=None,
+            users=[cls.owner],
+        )
+        cls.version = fixture.get(Version, project=cls.project)
+
+    def setUp(self):
+        self.client.force_login(self.admin)
+
+    @mock.patch('readthedocs.core.utils.general.broadcast')
+    def test_wipe_selected_version(self, mock_broadcast):
+        action_data = {
+            ACTION_CHECKBOX_NAME: [self.version.pk],
+            'action': 'wipe_selected_versions',
+            'post': 'yes',
+        }
+        resp = self.client.post(
+            urls.reverse('admin:builds_version_changelist'),
+            action_data
+        )
+        expected_del_dirs = [
+            os.path.join(self.version.project.doc_path, 'checkouts', self.version.slug),
+            os.path.join(self.version.project.doc_path, 'envs', self.version.slug),
+            os.path.join(self.version.project.doc_path, 'conda', self.version.slug),
+        ]
+
+        mock_broadcast.assert_has_calls(
+            [
+                call(type='build', task=remove_dirs, args=[(expected_del_dirs[0],)]),
+                call(type='build', task=remove_dirs, args=[(expected_del_dirs[1],)]),
+                call(type='build', task=remove_dirs, args=[(expected_del_dirs[2],)]),
+            ],
+            any_order=False
+        )