Browse Source

Fix users admin tests, move user content deletion to celery task

rafalp 6 years ago
parent
commit
7140f57b28

+ 0 - 136
misago/templates/misago/admin/users/delete.html

@@ -1,136 +0,0 @@
-{% extends "misago/admin/generic/form.html" %}
-{% load i18n misago_admin_form misago_avatars misago_capture %}
-
-
-{% block title %}
-{% trans "Delete users with content" %} | {{ active_link.name }} | {{ block.super }}
-{% endblock title %}
-
-
-{% block form-header %}
-<h1>{% trans "Deleting users..." %}</h1>
-{% endblock %}
-
-
-{% block form-body %}
-<div class="form-body">
-  {% for user in users %}
-    <fieldset class="user queued">
-      <legend>{{ user.username }}</legend>
-
-      <div class="extra-padding">
-        <ul class="list-unstyled">
-          <li class="step queued" data-url="{% url 'misago:admin:users:accounts:delete-threads' pk=user.pk %}" data-total="{{ user.thread_set.count }}">
-            <span class="fa fa-clock-o fa-fw text-muted"></span>
-            {% trans "Threads" %}: <strong><em class="text-muted">{% trans "queued" %}</em></strong>
-          </li>
-          <li class="step queued" data-url="{% url 'misago:admin:users:accounts:delete-posts' pk=user.pk %}" data-total="{{ user.post_set.count }}">
-            <span class="fa fa-clock-o fa-fw text-muted"></span>
-            {% trans "Posts" %}: <strong><em class="text-muted">{% trans "queued" %}</em></strong>
-          </li>
-          <li class="step queued" data-url="{% url 'misago:admin:users:accounts:delete-account' pk=user.pk %}">
-            <span class="fa fa-clock-o fa-fw text-muted"></span>
-            {% trans "Account" %}: <strong><em class="text-muted">{% trans "queued" %}</em></strong>
-          </li>
-        </ul>
-      </div>
-
-    </fieldset>
-  {% endfor %}
-</div>
-{% endblock form-body %}
-
-
-{% block form-footer %}{% endblock form-footer %}
-
-
-{% block javascripts %}
-<script type="text/javascript">
-  $(function() {
-    DeletionController = function($e, on_complete) {
-      this.lang_deleting = "{% trans 'deleting...' %}";
-      this.done = "{% trans 'done' %}";
-
-      this.$e = $e;
-      this.on_complete = on_complete;
-      this.form_data = $e.parents('form').serialize();
-
-      var _this = this;
-
-      this.step = function($step) {
-        var url = $step.data('url');
-        var total = $step.data('total');
-
-        var $icon = $step.find('.fa');
-        var $label = $step.find('strong');
-
-        var processed = 0;
-
-        $icon.attr('class', 'fa fa-cog fa-spin fa-fw text-danger');
-        $label.html(this.lang_deleting);
-
-        function query_server() {
-          $.post(url, _this.form_data, function(data) {
-
-            if (data.is_completed) {
-
-              $icon.attr('class', 'fa fa-check fa-fw text-success');
-              $label.html(_this.done);
-              $step.removeClass('queued');
-              _this.process();
-
-            } else {
-
-              if (total !== undefined && total > 0) {
-                processed += data.deleted_count;
-                progress = Math.round(processed * 100 / total);
-
-                if (progress > 100) {
-                  progress = 100;
-                }
-
-                $label.html(_this.lang_deleting + " " + progress + "%");
-              }
-              query_server();
-
-            }
-
-          });
-        }
-        query_server();
-      }
-
-      this.complete = function() {
-        this.$e.removeClass('queued');
-        on_complete();
-      }
-
-      this.process = function() {
-        var $step = this.$e.find('.step.queued').first();
-
-        if ($step.length) {
-          this.step($step);
-        } else {
-          this.complete();
-        }
-
-      }
-      this.process();
-    };
-
-    function delete_user() {
-      var $user = $('.user.queued').first();
-      if ($user.length) {
-        var controller = new DeletionController($user, delete_user);
-      } else {
-        var $form = $('.user').first().parents('form');
-        var $btn = $form.find('.btn-light');
-
-        $btn.text("{% trans 'Return to list of users' %}");
-        $btn.attr('class', 'btn btn-success');
-      }
-    }
-    delete_user();
-  });
-</script>
-{% endblock %}

+ 0 - 18
misago/users/admin/__init__.py

@@ -17,9 +17,6 @@ from .views.ranks import (
     RankUsers,
 )
 from .views.users import (
-    DeleteAccountStep,
-    DeletePostsStep,
-    DeleteThreadsStep,
     EditUser,
     NewUser,
     UsersList,
@@ -41,21 +38,6 @@ class MisagoAdminExtension:
             url(r"^(?P<page>\d+)/$", UsersList.as_view(), name="index"),
             url(r"^new/$", NewUser.as_view(), name="new"),
             url(r"^edit/(?P<pk>\d+)/$", EditUser.as_view(), name="edit"),
-            url(
-                r"^delete-threads/(?P<pk>\d+)/$",
-                DeleteThreadsStep.as_view(),
-                name="delete-threads",
-            ),
-            url(
-                r"^delete-posts/(?P<pk>\d+)/$",
-                DeletePostsStep.as_view(),
-                name="delete-posts",
-            ),
-            url(
-                r"^delete-account/(?P<pk>\d+)/$",
-                DeleteAccountStep.as_view(),
-                name="delete-account",
-            ),
         )
 
         # Ranks

+ 14 - 0
misago/users/admin/tasks.py

@@ -0,0 +1,14 @@
+from celery import shared_task
+from django.contrib.auth import get_user_model
+
+User = get_user_model()
+
+
+@shared_task
+def delete_user_with_content(pk):
+    try:
+        user = User.objects.get(pk=pk, is_staff=False, is_superuser=False)
+    except User.DoesNotExist:
+        pass
+    else:
+        user.delete(delete_content=True)

+ 2 - 1
misago/users/admin/tests/test_data_downloads.py

@@ -8,7 +8,8 @@ from ...datadownloads import request_user_data_download
 from ...models import DataDownload
 from ...test import create_test_user
 
-TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testfiles")
+APP_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+TESTFILES_DIR = os.path.join(APP_DIR, "tests", "testfiles")
 TEST_FILE_PATH = os.path.join(TESTFILES_DIR, "avatar.png")
 
 

+ 0 - 388
misago/users/admin/tests/test_users.py

@@ -30,200 +30,6 @@ def test_list_renders_with_item(admin_client, users_admin_link, superuser):
 class UserAdminTests(AdminTestCase):
     AJAX_HEADER = {"HTTP_X_REQUESTED_WITH": "XMLHttpRequest"}
 
-    def test_mass_request_data_download(self):
-        """users list requests data download for multiple users"""
-        user_pks = []
-        for i in range(10):
-            test_user = create_test_user(
-                "User%s" % i, "user%s@example.com" % i, requires_activation=1
-            )
-            user_pks.append(test_user.pk)
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={"action": "request_data_download", "selected_items": user_pks},
-        )
-        self.assertEqual(response.status_code, 302)
-
-        self.assertEqual(
-            DataDownload.objects.filter(user_id__in=user_pks).count(), len(user_pks)
-        )
-
-    def test_mass_request_data_download_avoid_excessive_downloads(self):
-        """users list avoids excessive data download requests for multiple users"""
-        user_pks = []
-        for i in range(10):
-            test_user = create_test_user(
-                "User%s" % i, "user%s@example.com" % i, requires_activation=1
-            )
-            request_user_data_download(test_user)
-            user_pks.append(test_user.pk)
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={"action": "v", "selected_items": user_pks},
-        )
-        self.assertEqual(response.status_code, 302)
-
-        self.assertEqual(
-            DataDownload.objects.filter(user_id__in=user_pks).count(), len(user_pks)
-        )
-
-    def test_mass_delete_accounts_self(self):
-        """its impossible to delete oneself"""
-        user_pks = [self.user.pk]
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={"action": "delete_accounts", "selected_items": user_pks},
-        )
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(response["location"])
-        self.assertContains(response, "delete yourself")
-
-    def test_mass_delete_accounts_admin(self):
-        """its impossible to delete admin account"""
-        user_pks = []
-        for i in range(10):
-            test_user = create_test_user("User%s" % i, "user%s@example.com" % i)
-            user_pks.append(test_user.pk)
-
-            test_user.is_staff = True
-            test_user.save()
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={"action": "delete_accounts", "selected_items": user_pks},
-        )
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(response["location"])
-        self.assertContains(response, "is admin and can")
-        self.assertContains(response, "be deleted.")
-
-        self.assertEqual(User.objects.count(), 11)
-
-    def test_mass_delete_accounts_superadmin(self):
-        """its impossible to delete superadmin account"""
-        user_pks = []
-        for i in range(10):
-            test_user = create_test_user("User%s" % i, "user%s@example.com" % i)
-            user_pks.append(test_user.pk)
-
-            test_user.is_superuser = True
-            test_user.save()
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={"action": "delete_accounts", "selected_items": user_pks},
-        )
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(response["location"])
-        self.assertContains(response, "is admin and can")
-        self.assertContains(response, "be deleted.")
-
-        self.assertEqual(User.objects.count(), 11)
-
-    def test_mass_delete_accounts(self):
-        """users list deletes users"""
-        # create 10 users to delete
-        user_pks = []
-        for i in range(10):
-            test_user = create_test_user(
-                "User%s" % i, "user%s@example.com" % i, requires_activation=0
-            )
-            user_pks.append(test_user.pk)
-
-        # create 10 more users that won't be deleted
-        for i in range(10):
-            test_user = create_test_user(
-                "Weebl%s" % i, "weebl%s@test.com" % i, requires_activation=0
-            )
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={"action": "delete_accounts", "selected_items": user_pks},
-        )
-        self.assertEqual(response.status_code, 302)
-        self.assertEqual(User.objects.count(), 11)
-
-    def test_mass_delete_all_self(self):
-        """its impossible to delete oneself with content"""
-        user_pks = [self.user.pk]
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={"action": "delete_all", "selected_items": user_pks},
-        )
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(response["location"])
-        self.assertContains(response, "delete yourself")
-
-    def test_mass_delete_all_admin(self):
-        """its impossible to delete admin account and content"""
-        user_pks = []
-        for i in range(10):
-            test_user = create_test_user("User%s" % i, "user%s@example.com" % i)
-            user_pks.append(test_user.pk)
-
-            test_user.is_staff = True
-            test_user.save()
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={"action": "delete_all", "selected_items": user_pks},
-        )
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(response["location"])
-        self.assertContains(response, "is admin and can")
-        self.assertContains(response, "be deleted.")
-
-        self.assertEqual(User.objects.count(), 11)
-
-    def test_mass_delete_all_superadmin(self):
-        """its impossible to delete superadmin account and content"""
-        user_pks = []
-        for i in range(10):
-            test_user = create_test_user("User%s" % i, "user%s@example.com" % i)
-            user_pks.append(test_user.pk)
-
-            test_user.is_superuser = True
-            test_user.save()
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={"action": "delete_all", "selected_items": user_pks},
-        )
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(response["location"])
-        self.assertContains(response, "is admin and can")
-        self.assertContains(response, "be deleted.")
-
-        self.assertEqual(User.objects.count(), 11)
-
-    def test_mass_delete_all(self):
-        """users list mass deleting view has no showstoppers"""
-        user_pks = []
-        for i in range(10):
-            test_user = create_test_user(
-                "User%s" % i, "user%s@example.com" % i, requires_activation=1
-            )
-            user_pks.append(test_user.pk)
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={"action": "delete_all", "selected_items": user_pks},
-        )
-        self.assertEqual(response.status_code, 200)
-        # asser that no user has been deleted, because actuall deleting happens in
-        # dedicated views called via ajax from JavaScript
-        self.assertEqual(User.objects.count(), 11)
-
     def test_new_view(self):
         """new user view creates account"""
         response = self.client.get(reverse("misago:admin:users:accounts:new"))
@@ -812,197 +618,3 @@ class UserAdminTests(AdminTestCase):
         response = self.client.get(test_link)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, agreement.title)
-
-    def test_delete_threads_view_self(self):
-        """delete user threads view validates if user deletes self"""
-        test_link = reverse(
-            "misago:admin:users:accounts:delete-threads", kwargs={"pk": self.user.pk}
-        )
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "delete yourself")
-
-    def test_delete_threads_view_staff(self):
-        """delete user threads view validates if user deletes staff"""
-        test_user = create_test_user("User", "user@example.com")
-        test_user.is_staff = True
-        test_user.save()
-
-        test_link = reverse(
-            "misago:admin:users:accounts:delete-threads", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "is admin and")
-
-    def test_delete_threads_view_superuser(self):
-        """delete user threads view validates if user deletes superuser"""
-        test_user = create_test_user("User", "user@example.com")
-        test_user.is_superuser = True
-        test_user.save()
-
-        test_link = reverse(
-            "misago:admin:users:accounts:delete-threads", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "is admin and")
-
-    def test_delete_threads_view(self):
-        """delete user threads view deletes threads"""
-        test_user = create_test_user("User", "user@example.com")
-        test_link = reverse(
-            "misago:admin:users:accounts:delete-threads", kwargs={"pk": test_user.pk}
-        )
-
-        category = Category.objects.all_categories()[:1][0]
-        [post_thread(category, poster=test_user) for _ in range(10)]
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 200)
-
-        response_dict = response.json()
-        self.assertEqual(response_dict["deleted_count"], 10)
-        self.assertFalse(response_dict["is_completed"])
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 200)
-
-        response_dict = response.json()
-        self.assertEqual(response_dict["deleted_count"], 0)
-        self.assertTrue(response_dict["is_completed"])
-
-    def test_delete_posts_view_self(self):
-        """delete user posts view validates if user deletes self"""
-        test_link = reverse(
-            "misago:admin:users:accounts:delete-posts", kwargs={"pk": self.user.pk}
-        )
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "delete yourself")
-
-    def test_delete_posts_view_staff(self):
-        """delete user posts view validates if user deletes staff"""
-        test_user = create_test_user("User", "user@example.com")
-        test_user.is_staff = True
-        test_user.save()
-
-        test_link = reverse(
-            "misago:admin:users:accounts:delete-posts", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "is admin and")
-
-    def test_delete_posts_view_superuser(self):
-        """delete user posts view validates if user deletes superuser"""
-        test_user = create_test_user("User", "user@example.com")
-        test_user.is_superuser = True
-        test_user.save()
-
-        test_link = reverse(
-            "misago:admin:users:accounts:delete-posts", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "is admin and")
-
-    def test_delete_posts_view(self):
-        """delete user posts view deletes posts"""
-        test_user = create_test_user("User", "user@example.com")
-        test_link = reverse(
-            "misago:admin:users:accounts:delete-posts", kwargs={"pk": test_user.pk}
-        )
-
-        category = Category.objects.all_categories()[:1][0]
-        thread = post_thread(category)
-        [reply_thread(thread, poster=test_user) for _ in range(10)]
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 200)
-
-        response_dict = response.json()
-        self.assertEqual(response_dict["deleted_count"], 10)
-        self.assertFalse(response_dict["is_completed"])
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 200)
-
-        response_dict = response.json()
-        self.assertEqual(response_dict["deleted_count"], 0)
-        self.assertTrue(response_dict["is_completed"])
-
-    def test_delete_account_view_self(self):
-        """delete user account view validates if user deletes self"""
-        test_link = reverse(
-            "misago:admin:users:accounts:delete-account", kwargs={"pk": self.user.pk}
-        )
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "delete yourself")
-
-    def test_delete_account_view_staff(self):
-        """delete user account view validates if user deletes staff"""
-        test_user = create_test_user("User", "user@example.com")
-        test_user.is_staff = True
-        test_user.save()
-
-        test_link = reverse(
-            "misago:admin:users:accounts:delete-account", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "is admin and")
-
-    def test_delete_account_view_superuser(self):
-        """delete user account view validates if user deletes superuser"""
-        test_user = create_test_user("User", "user@example.com")
-        test_user.is_superuser = True
-        test_user.save()
-
-        test_link = reverse(
-            "misago:admin:users:accounts:delete-account", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:index"))
-        self.assertContains(response, "is admin and")
-
-    def test_delete_account_view(self):
-        """delete user account view deletes user account"""
-        test_user = create_test_user("User", "user@example.com")
-        test_link = reverse(
-            "misago:admin:users:accounts:delete-account", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.post(test_link, **self.AJAX_HEADER)
-        self.assertEqual(response.status_code, 200)
-
-        response_dict = response.json()
-        self.assertTrue(response_dict["is_completed"])

+ 189 - 8
misago/users/admin/tests/test_users_mass_actions.py

@@ -1,9 +1,13 @@
+from unittest.mock import call
+
+import pytest
 from django.contrib.auth import get_user_model
 from django.core import mail
 
 from ....cache.test import assert_invalidates_cache
-from ....test import assert_contains, assert_not_contains
+from ....test import assert_contains, assert_has_error_message, assert_not_contains
 from ... import BANS_CACHE
+from ...datadownloads import request_user_data_download
 from ...models import Ban, DataDownload
 from ...test import create_test_user
 
@@ -22,15 +26,23 @@ def get_multiple_users_ids(**kwargs):
     return [u.id for u in users]
 
 
+@pytest.fixture
+def users_ids(db):
+    return get_multiple_users_ids()
+
+
 def test_multiple_users_can_be_activated_with_mass_action(
     admin_client, users_admin_link
 ):
-    users_ids = get_multiple_users_ids(requires_activation=1)
+    users = create_multiple_users(requires_activation=1)
     response = admin_client.post(
-        users_admin_link, data={"action": "activate", "selected_items": users_ids}
+        users_admin_link,
+        data={"action": "activate", "selected_items": [u.id for u in users]},
     )
 
-    assert not User.objects.filter(id__in=users_ids, requires_activation=1).exists()
+    for user in users:
+        user.refresh_from_db()
+        assert not user.requires_activation
 
 
 def test_activating_multiple_users_sends_email_notifications_to_them(
@@ -72,9 +84,8 @@ def test_multiple_users_can_be_banned_with_mass_action(admin_client, users_admin
 
 
 def test_option_to_ban_multiple_users_ips_is_disabled_if_user_ips_are_not_available(
-    admin_client, users_admin_link
+    admin_client, users_admin_link, users_ids
 ):
-    users_ids = get_multiple_users_ids()
     response = admin_client.post(
         users_admin_link, data={"action": "ban", "selected_items": users_ids}
     )
@@ -115,9 +126,8 @@ def test_multiple_users_ips_can_be_banned_with_mass_action(
 
 
 def test_banning_multiple_users_with_mass_action_invalidates_bans_cache(
-    admin_client, users_admin_link
+    admin_client, users_admin_link, users_ids
 ):
-    users_ids = get_multiple_users_ids()
     with assert_invalidates_cache(BANS_CACHE):
         admin_client.post(
             users_admin_link,
@@ -128,3 +138,174 @@ def test_banning_multiple_users_with_mass_action_invalidates_bans_cache(
                 "finalize": "",
             },
         )
+
+
+def test_data_downloads_can_be_requested_for_multiple_users_with_mass_action(
+    admin_client, users_admin_link
+):
+    users = create_multiple_users()
+    response = admin_client.post(
+        users_admin_link,
+        data={
+            "action": "request_data_download",
+            "selected_items": [u.id for u in users],
+        },
+    )
+
+    for user in users:
+        DataDownload.objects.get(user=user)
+
+
+def test_mass_action_is_not_requesting_data_downloads_for_users_with_existing_requests(
+    admin_client, users_admin_link
+):
+    users = create_multiple_users()
+    downloads_ids = [request_user_data_download(u).id for u in users]
+
+    response = admin_client.post(
+        users_admin_link,
+        data={
+            "action": "request_data_download",
+            "selected_items": [u.id for u in users],
+        },
+    )
+
+    assert not DataDownload.objects.exclude(id__in=downloads_ids).exists()
+
+
+def test_multiple_users_can_be_deleted_with_mass_action(
+    admin_client, users_admin_link
+):
+    users = create_multiple_users()
+    response = admin_client.post(
+        users_admin_link,
+        data={"action": "delete_accounts", "selected_items": [u.id for u in users]},
+    )
+
+    for user in users:
+        with pytest.raises(User.DoesNotExist):
+            user.refresh_from_db()
+
+
+def test_delete_users_mass_action_fails_if_user_tries_to_delete_themselves(
+    admin_client, users_admin_link, superuser
+):
+    response = admin_client.post(
+        users_admin_link,
+        data={"action": "delete_accounts", "selected_items": [superuser.id]},
+    )
+    assert_has_error_message(response)
+    superuser.refresh_from_db()
+
+
+def test_delete_users_mass_action_fails_if_user_tries_to_delete_staff_members(
+    admin_client, users_admin_link
+):
+    users = create_multiple_users(is_staff=True)
+    response = admin_client.post(
+        users_admin_link,
+        data={"action": "delete_accounts", "selected_items": [u.id for u in users]},
+    )
+    assert_has_error_message(response)
+
+    for user in users:
+        user.refresh_from_db()
+
+
+def test_delete_users_mass_action_fails_if_user_tries_to_delete_superusers(
+    admin_client, users_admin_link
+):
+    users = create_multiple_users(is_superuser=True)
+    response = admin_client.post(
+        users_admin_link,
+        data={"action": "delete_accounts", "selected_items": [u.id for u in users]},
+    )
+    assert_has_error_message(response)
+
+    for user in users:
+        user.refresh_from_db()
+
+
+@pytest.fixture
+def mock_delete_user_with_content(mocker):
+    delay = mocker.Mock()
+    mocker.patch(
+        "misago.users.admin.views.users.delete_user_with_content",
+        mocker.Mock(delay=delay)
+    )
+    return delay
+
+
+def test_multiple_users_can_be_deleted_together_with_content_by_mass_action(
+    admin_client, users_admin_link, users_ids, mock_delete_user_with_content
+):
+    response = admin_client.post(
+        users_admin_link,
+        data={"action": "delete_all", "selected_items": users_ids},
+    )
+
+    calls = [call(u) for u in users_ids]
+    mock_delete_user_with_content.assert_has_calls(calls, any_order=True)
+
+
+def test_deleting_multiple_users_with_content_disables_their_accounts(
+    admin_client, users_admin_link, mock_delete_user_with_content
+):
+    users = create_multiple_users()
+    response = admin_client.post(
+        users_admin_link,
+        data={"action": "delete_all", "selected_items": [u.id for u in users]},
+    )
+
+    for user in users:
+        user.refresh_from_db()
+        assert not user.is_active
+
+
+def test_delete_users_with_content_mass_action_fails_if_user_tries_to_delete_themselves(
+    admin_client, users_admin_link, superuser, mock_delete_user_with_content
+):
+    response = admin_client.post(
+        users_admin_link,
+        data={"action": "delete_all", "selected_items": [superuser.id]},
+    )
+
+    assert_has_error_message(response)
+    mock_delete_user_with_content.assert_not_called()
+
+    superuser.refresh_from_db()
+    assert superuser.is_active
+
+
+def test_delete_users_with_content_mass_action_fails_if_user_tries_to_delete_staff(
+    admin_client, users_admin_link, mock_delete_user_with_content
+):
+    users = create_multiple_users(is_staff=True)
+    response = admin_client.post(
+        users_admin_link,
+        data={"action": "delete_all", "selected_items": [u.id for u in users]},
+    )
+
+    assert_has_error_message(response)
+    mock_delete_user_with_content.assert_not_called()
+
+    for user in users:
+        user.refresh_from_db()
+        assert user.is_active
+
+
+def test_delete_users_with_content_mass_action_fails_if_user_tries_to_delete_superusers(
+    admin_client, users_admin_link, mock_delete_user_with_content
+):
+    users = create_multiple_users(is_superuser=True)
+    response = admin_client.post(
+        users_admin_link,
+        data={"action": "delete_all", "selected_items": [u.id for u in users]},
+    )
+
+    assert_has_error_message(response)
+    mock_delete_user_with_content.assert_not_called()
+
+    for user in users:
+        user.refresh_from_db()
+        assert user.is_active

+ 5 - 4
misago/users/admin/views/users.py

@@ -25,6 +25,7 @@ from ..forms import (
     NewUserForm,
     create_filter_users_form,
 )
+from ..tasks import delete_user_with_content
 
 User = get_user_model()
 
@@ -242,10 +243,10 @@ class UsersList(UserAdmin, generic.ListView):
                 raise generic.MassActionError(message)
 
         for user in users:
-            # todo:
-            # mark as deleted
-            # fire the cron task
-            pass
+            user.is_active = False
+            user.save()
+
+            delete_user_with_content.delay(user.pk)
 
         messages.success(
             request,

+ 1 - 0
misago/users/apps.py

@@ -12,6 +12,7 @@ class MisagoUsersConfig(AppConfig):
 
     def ready(self):
         from . import signals as _
+        from .admin import tasks  # pylint: disable=unused-import
 
         self.register_default_usercp_pages()
         self.register_default_users_list_pages()