Browse Source

Merge pull request #1216 from rafalp/redo-content-deletion

Redo content deletion for users
Rafał Pitoń 6 years ago
parent
commit
04c8b23b51

+ 27 - 0
misago/conftest.py

@@ -80,6 +80,17 @@ def staffuser_acl(staffuser, cache_versions):
 
 
 @pytest.fixture
+def other_staffuser(db, user_password):
+    user = create_test_superuser(
+        "OtherStaffuser", "otherstaffuser@example.com", user_password
+    )
+
+    user.is_superuser = False
+    user.save()
+    return user
+
+
+@pytest.fixture
 def superuser(db, user_password):
     return create_test_superuser("Superuser", "superuser@example.com", user_password)
 
@@ -90,6 +101,13 @@ def superuser_acl(superuser, cache_versions):
 
 
 @pytest.fixture
+def other_superuser(db, user_password):
+    return create_test_superuser(
+        "OtherSuperuser", "othersuperuser@example.com", user_password
+    )
+
+
+@pytest.fixture
 def admin_client(mocker, client, superuser):
     client.force_login(superuser)
     session = client.session
@@ -99,6 +117,15 @@ def admin_client(mocker, client, superuser):
 
 
 @pytest.fixture
+def staff_client(mocker, client, staffuser):
+    client.force_login(staffuser)
+    session = client.session
+    authorize_admin(mocker.Mock(session=session, user=staffuser))
+    session.save()
+    return client
+
+
+@pytest.fixture
 def root_category(db):
     return Category.objects.root_category()
 

+ 49 - 0
misago/search/filter_queryset.py

@@ -0,0 +1,49 @@
+EQUAL = 0
+CONTAINS = 1
+STARTS_WITH = 2
+ENDS_WITH = 3
+
+
+def filter_queryset(queryset, attr, search, *, case_sensitive=False):
+    mode = get_mode(search)
+    search = search.strip("*")
+
+    if not search:
+        return queryset
+
+    queryset_filter = get_queryset_filter(
+        attr, mode, search, case_sensitive=case_sensitive
+    )
+    return queryset.filter(**queryset_filter)
+
+
+def get_mode(search):
+    if search.startswith("*") and search.endswith("*"):
+        return CONTAINS
+    if search.endswith("*"):
+        return STARTS_WITH
+    if search.startswith("*"):
+        return ENDS_WITH
+    return EQUAL
+
+
+def get_queryset_filter(attr, mode, search, *, case_sensitive=False):
+    if mode is STARTS_WITH:
+        if case_sensitive:
+            return {"%s__startswith" % attr: search}
+        return {"%s__istartswith" % attr: search}
+
+    if mode is ENDS_WITH:
+        if case_sensitive:
+            return {"%s__endswith" % attr: search}
+        return {"%s__iendswith" % attr: search}
+
+    if mode is CONTAINS:
+        if case_sensitive:
+            return {"%s__contains" % attr: search}
+        return {"%s__icontains" % attr: search}
+
+    if case_sensitive:
+        return {attr: search}
+
+    return {"%s__iexact" % attr: search}

+ 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 %}

+ 14 - 0
misago/templates/misago/admin/users/list.html

@@ -106,6 +106,20 @@
 
 
 {% block filters-modal-body %}
+<div class="alert alert-info" role="alert">
+  <p>
+    {% trans 'You can include wildcard ("*") in username and email search:' %}
+  </p>
+  <p>
+    {% trans '"admin*" will find both "admin" and "administrator".' %}
+  </p>
+  <p>
+    {% trans '"*chan" will match both "chan" and "marichan".' %}
+  </p>
+  <p class="mb-0">
+    {% trans '"*son*" will match "son", "song", "firstson" and "firstsong".' %}
+  </p>
+</div>
 <div class="row">
   <div class="col">
     {% form_row filter_form.username %}

+ 1 - 1
misago/test.py

@@ -10,7 +10,7 @@ def assert_contains(response, string, status_code=200):
 
 def assert_not_contains(response, string, status_code=200):
     assert response.status_code == status_code
-    fail_message = f'"{string}" was found in response.content'
+    fail_message = f'"{string}" was unexpectedly found in response.content'
     assert string not in response.content.decode("utf-8"), fail_message
 
 

+ 2 - 2
misago/themes/admin/tests/test_importing_themes.py

@@ -43,12 +43,12 @@ def assert_filenames_are_same(src, dst):
     assert source_filename == imported_filename
 
 
-def test_import_theme_form_is_displayed(db, admin_client):
+def test_import_theme_form_is_displayed(admin_client):
     response = admin_client.get(import_link)
     assert_contains(response, "Import theme")
 
 
-def test_theme_import_fails_if_export_was_not_uploaded(db, admin_client):
+def test_theme_import_fails_if_export_was_not_uploaded(admin_client):
     admin_client.post(import_link, {"upload": None})
     assert Theme.objects.count() == 1
 

+ 4 - 26
misago/users/admin.py → misago/users/admin/__init__.py

@@ -4,9 +4,9 @@ from django.contrib.auth import get_user_model
 from django.utils.translation import gettext_lazy as _
 
 from .djangoadmin import UserAdminModel
-from .views.admin.bans import BansList, DeleteBan, EditBan, NewBan
-from .views.admin.datadownloads import DataDownloadsList, RequestDataDownloads
-from .views.admin.ranks import (
+from .views.bans import BansList, DeleteBan, EditBan, NewBan
+from .views.datadownloads import DataDownloadsList, RequestDataDownloads
+from .views.ranks import (
     DefaultRank,
     DeleteRank,
     EditRank,
@@ -16,14 +16,7 @@ from .views.admin.ranks import (
     RanksList,
     RankUsers,
 )
-from .views.admin.users import (
-    DeleteAccountStep,
-    DeletePostsStep,
-    DeleteThreadsStep,
-    EditUser,
-    NewUser,
-    UsersList,
-)
+from .views.users import EditUser, NewUser, UsersList
 
 djadmin.site.register(model_or_iterable=get_user_model(), admin_class=UserAdminModel)
 
@@ -41,21 +34,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

+ 0 - 0
misago/users/djangoadmin.py → misago/users/admin/djangoadmin.py


+ 8 - 5
misago/users/forms/admin.py → misago/users/admin/forms.py

@@ -8,6 +8,7 @@ from django.utils.translation import ngettext
 from ...acl.models import Role
 from ...admin.forms import IsoDateTimeField, YesNoSwitch
 from ...core.validators import validate_sluggable
+from ...search.filter_queryset import filter_queryset
 from ..models import Ban, DataDownload, Rank
 from ..profilefields import profilefields
 from ..utils import hash_email
@@ -308,8 +309,8 @@ def EditUserFormFactory(
 
 
 class BaseFilterUsersForm(forms.Form):
-    username = forms.CharField(label=_("Username starts with"), required=False)
-    email = forms.CharField(label=_("E-mail starts with"), required=False)
+    username = forms.CharField(label=_("Username"), required=False)
+    email = forms.CharField(label=_("E-mail"), required=False)
     profilefields = forms.CharField(label=_("Profile fields contain"), required=False)
     is_inactive = forms.BooleanField(label=_("Requires activation"))
     is_disabled = forms.BooleanField(label=_("Account disabled"))
@@ -318,12 +319,14 @@ class BaseFilterUsersForm(forms.Form):
 
     def filter_queryset(self, criteria, queryset):
         if criteria.get("username"):
-            queryset = queryset.filter(
-                slug__startswith=criteria.get("username").lower()
+            queryset = filter_queryset(
+                queryset, "slug", criteria.get("username").lower()
             )
 
         if criteria.get("email"):
-            queryset = queryset.filter(email__istartswith=criteria.get("email"))
+            queryset = filter_queryset(
+                queryset, "email", criteria.get("email"), case_sensitive=False
+            )
 
         if criteria.get("rank"):
             queryset = queryset.filter(rank_id=criteria.get("rank"))

+ 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)

+ 0 - 0
misago/users/views/admin/__init__.py → misago/users/admin/tests/__init__.py


+ 8 - 0
misago/users/admin/tests/conftest.py

@@ -0,0 +1,8 @@
+import pytest
+from django.urls import reverse
+
+
+@pytest.fixture
+def users_admin_link(admin_client):
+    response = admin_client.get(reverse("misago:admin:users:accounts:index"))
+    return response["location"]

+ 139 - 0
misago/users/admin/tests/test_bans.py

@@ -0,0 +1,139 @@
+from datetime import timedelta
+
+import pytest
+from django.urls import reverse
+from django.utils import timezone
+
+from ....admin.test import AdminTestCase
+from ....cache.test import assert_invalidates_cache
+from ....test import assert_contains
+from ... import BANS_CACHE
+from ...models import Ban
+
+
+@pytest.fixture
+def admin_link(admin_client):
+    response = admin_client.get(reverse("misago:admin:users:bans:index"))
+    return response["location"]
+
+
+@pytest.fixture
+def ban(db):
+    return Ban.objects.create(banned_value="banned_username")
+
+
+def test_link_is_registered_in_admin_nav(admin_client):
+    response = admin_client.get(reverse("misago:admin:users:accounts:index"))
+    response = admin_client.get(response["location"])
+    assert_contains(response, reverse("misago:admin:users:bans:index"))
+
+
+def test_list_renders_empty(admin_client, admin_link):
+    response = admin_client.get(admin_link)
+    assert response.status_code == 200
+
+
+def test_list_renders_with_item(admin_client, admin_link, ban):
+    response = admin_client.get(admin_link)
+    assert_contains(response, ban.banned_value)
+
+
+def test_bans_can_be_mass_deleted(admin_client, admin_link):
+    bans_ids = [Ban.objects.create(banned_value="ban_%s" % i).id for i in range(10)]
+
+    admin_client.post(admin_link, data={"action": "delete", "selected_items": bans_ids})
+
+    assert Ban.objects.count() == 0
+
+
+def test_mass_deleting_bans_invalidates_bans_cache(admin_client, admin_link, ban):
+    with assert_invalidates_cache(BANS_CACHE):
+        admin_client.post(
+            admin_link, data={"action": "delete", "selected_items": [ban.id]}
+        )
+
+
+def test_ban_can_be_deleted(admin_client, admin_link, ban):
+    admin_client.post(reverse("misago:admin:users:bans:delete", kwargs={"pk": ban.pk}))
+
+    with pytest.raises(Ban.DoesNotExist):
+        ban.refresh_from_db()
+
+
+def test_deleting_ban_invalidates_bans_cache(admin_client, admin_link, ban):
+    with assert_invalidates_cache(BANS_CACHE):
+        admin_client.post(
+            reverse("misago:admin:users:bans:delete", kwargs={"pk": ban.pk})
+        )
+
+
+def test_new_ban_form_renders(admin_client):
+    response = admin_client.get(reverse("misago:admin:users:bans:new"))
+    assert response.status_code == 200
+
+
+def test_new_ban_can_be_created(admin_client):
+    test_date = timezone.now() + timedelta(days=180)
+
+    response = admin_client.post(
+        reverse("misago:admin:users:bans:new"),
+        data={
+            "check_type": Ban.EMAIL,
+            "banned_value": "test@test.com",
+            "user_message": "Lorem ipsum dolor met",
+            "staff_message": "Sit amet elit",
+            "expires_on": test_date.isoformat(),
+        },
+    )
+
+    ban = Ban.objects.get()
+    assert ban.check_type == Ban.EMAIL
+    assert ban.banned_value == "test@test.com"
+    assert ban.user_message == "Lorem ipsum dolor met"
+    assert ban.staff_message == "Sit amet elit"
+    assert ban.expires_on.isoformat() == test_date.isoformat()
+
+
+def test_new_ban_creation_invalidates_bans_cache(admin_client):
+    with assert_invalidates_cache(BANS_CACHE):
+        admin_client.post(
+            reverse("misago:admin:users:bans:new"),
+            data={"check_type": Ban.EMAIL, "banned_value": "test@test.com"},
+        )
+
+
+def test_edit_ban_form_renders(admin_client, ban):
+    response = admin_client.get(
+        reverse("misago:admin:users:bans:edit", kwargs={"pk": ban.pk})
+    )
+    assert response.status_code == 200
+
+
+def test_ban_can_be_edited(admin_client, ban):
+    test_date = timezone.now() + timedelta(days=180)
+
+    response = admin_client.post(
+        reverse("misago:admin:users:bans:edit", kwargs={"pk": ban.pk}),
+        data={
+            "check_type": Ban.EMAIL,
+            "banned_value": "test@test.com",
+            "user_message": "Lorem ipsum dolor met",
+            "staff_message": "Sit amet elit",
+            "expires_on": test_date.isoformat(),
+        },
+    )
+
+    ban.refresh_from_db()
+    assert ban.check_type == Ban.EMAIL
+    assert ban.banned_value == "test@test.com"
+    assert ban.user_message == "Lorem ipsum dolor met"
+    assert ban.staff_message == "Sit amet elit"
+    assert ban.expires_on.isoformat() == test_date.isoformat()
+
+
+def test_ban_edition_invalidates_bans_cache(admin_client, ban):
+    with assert_invalidates_cache(BANS_CACHE):
+        admin_client.post(
+            reverse("misago:admin:users:bans:edit", kwargs={"pk": ban.pk}),
+            data={"check_type": Ban.EMAIL, "banned_value": "test@test.com"},
+        )

+ 7 - 6
misago/users/tests/test_datadownloadsadmin_views.py → misago/users/admin/tests/test_data_downloads.py

@@ -3,16 +3,17 @@ import os
 from django.core.files import File
 from django.urls import reverse
 
-from ...admin.test import AdminTestCase
-from ..datadownloads import request_user_data_download
-from ..models import DataDownload
-from ..test import create_test_user
+from ....admin.test import AdminTestCase
+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")
 
 
-class DataDownloadAdminViewsTests(AdminTestCase):
+class DataDownloadAdminTests(AdminTestCase):
     def test_link_registered(self):
         """admin nav contains data downloads link"""
         response = self.client.get(reverse("misago:admin:users:accounts:index"))

+ 26 - 0
misago/users/admin/tests/test_deleting_user_with_content.py

@@ -0,0 +1,26 @@
+import pytest
+from django.contrib.auth import get_user_model
+
+from ..tasks import delete_user_with_content
+
+User = get_user_model()
+
+
+def test_task_does_nothing_for_nonexisting_user_id(db):
+    delete_user_with_content(1)
+
+
+def test_task_does_nothing_for_staff_user_id(staffuser):
+    delete_user_with_content(staffuser.id)
+    staffuser.refresh_from_db()
+
+
+def test_task_does_nothing_for_superuser_id(superuser):
+    delete_user_with_content(superuser.id)
+    superuser.refresh_from_db()
+
+
+def test_task_deletes_user(user):
+    delete_user_with_content(user.id)
+    with pytest.raises(User.DoesNotExist):
+        user.refresh_from_db()

+ 1 - 1
misago/users/tests/test_djangoadmin_auth.py → misago/users/admin/tests/test_django_admin_auth.py

@@ -1,7 +1,7 @@
 from django.test import override_settings
 from django.urls import reverse
 
-from ...admin.test import AdminTestCase
+from ....admin.test import AdminTestCase
 
 
 @override_settings(ROOT_URLCONF="misago.core.testproject.urls")

+ 2 - 2
misago/users/tests/test_djangoadmin_user.py → misago/users/admin/tests/test_django_admin_user.py

@@ -3,9 +3,9 @@ from django.test import override_settings
 from django.urls import reverse
 from django.utils import formats
 
-from ...admin.test import AdminTestCase
+from ....admin.test import AdminTestCase
+from ...test import create_test_user
 from ..djangoadmin import UserAdminModel
-from ..test import create_test_user
 
 
 @override_settings(ROOT_URLCONF="misago.core.testproject.urls")

+ 6 - 6
misago/users/tests/test_rankadmin_views.py → misago/users/admin/tests/test_ranks.py

@@ -1,13 +1,13 @@
 from django.urls import reverse
 
-from ...acl import ACL_CACHE
-from ...acl.models import Role
-from ...admin.test import AdminTestCase
-from ...cache.test import assert_invalidates_cache
-from ..models import Rank
+from ....acl import ACL_CACHE
+from ....acl.models import Role
+from ....admin.test import AdminTestCase
+from ....cache.test import assert_invalidates_cache
+from ...models import Rank
 
 
-class RankAdminViewsTests(AdminTestCase):
+class RankAdminTests(AdminTestCase):
     def test_link_registered(self):
         """admin nav contains ranks link"""
         response = self.client.get(reverse("misago:admin:users:accounts:index"))

+ 228 - 0
misago/users/admin/tests/test_searching_users.py

@@ -0,0 +1,228 @@
+import pytest
+
+from ....acl.models import Role
+from ....test import assert_contains, assert_not_contains
+from ...models import Rank
+from ...test import create_test_user
+
+
+@pytest.fixture
+def user_a(db):
+    return create_test_user("Tyrael", "test123@test.org")
+
+
+@pytest.fixture
+def user_b(db):
+    return create_test_user("Tyrion", "test321@gmail.com")
+
+
+@pytest.fixture
+def user_c(db):
+    return create_test_user("Karen", "other432@gmail.com")
+
+
+def test_search_finds_user_by_username(admin_client, users_admin_link, user_a):
+    response = admin_client.get("%s&username=Tyrael" % users_admin_link)
+    assert_contains(response, user_a.username)
+
+
+def test_search_excludes_users_with_different_username(
+    admin_client, users_admin_link, user_b, user_c
+):
+    response = admin_client.get("%s&username=Tyrael" % users_admin_link)
+    assert_not_contains(response, user_b.username)
+    assert_not_contains(response, user_c.username)
+
+
+def test_search_finds_users_by_username_start(
+    admin_client, users_admin_link, user_a, user_b
+):
+    response = admin_client.get("%s&username=Tyr*" % users_admin_link)
+    assert_contains(response, user_a.username)
+    assert_contains(response, user_b.username)
+
+
+def test_search_by_username_start_excludes_users_with_different_username(
+    admin_client, users_admin_link, user_c
+):
+    response = admin_client.get("%s&username=Tyr*" % users_admin_link)
+    assert_not_contains(response, user_c.username)
+
+
+def test_search_finds_user_by_username_end(admin_client, users_admin_link, user_a):
+    response = admin_client.get("%s&username=*ael" % users_admin_link)
+    assert_contains(response, user_a.username)
+
+
+def test_search_by_username_end_excludes_users_with_different_username(
+    admin_client, users_admin_link, user_b, user_c
+):
+    response = admin_client.get("%s&username=*ael" % users_admin_link)
+    assert_not_contains(response, user_b.username)
+    assert_not_contains(response, user_c.username)
+
+
+def test_search_finds_users_by_username_content(
+    admin_client, users_admin_link, user_a, user_b
+):
+    response = admin_client.get("%s&username=*yr*" % users_admin_link)
+    assert_contains(response, user_a.username)
+    assert_contains(response, user_b.username)
+
+
+def test_search_by_username_content_excludes_users_with_different_username(
+    admin_client, users_admin_link, user_c
+):
+    response = admin_client.get("%s&username=*yr*" % users_admin_link)
+    assert_not_contains(response, user_c.username)
+
+
+def test_search_finds_user_by_email(admin_client, users_admin_link, user_a):
+    response = admin_client.get("%s&email=test123@test.org" % users_admin_link)
+    assert_contains(response, user_a.email)
+
+
+def test_search_excludes_users_with_different_email(
+    admin_client, users_admin_link, user_b, user_c
+):
+    response = admin_client.get("%s&email=test123@test.org" % users_admin_link)
+    assert_not_contains(response, user_b.email)
+    assert_not_contains(response, user_c.email)
+
+
+def test_search_finds_users_by_email_start(
+    admin_client, users_admin_link, user_a, user_b
+):
+    response = admin_client.get("%s&email=test*" % users_admin_link)
+    assert_contains(response, user_a.email)
+    assert_contains(response, user_b.email)
+
+
+def test_search_by_email_start_excludes_users_with_different_email(
+    admin_client, users_admin_link, user_c
+):
+    response = admin_client.get("%s&email=test*" % users_admin_link)
+    assert_not_contains(response, user_c.email)
+
+
+def test_search_finds_user_by_email_end(admin_client, users_admin_link, user_a):
+    response = admin_client.get("%s&email=*org" % users_admin_link)
+    assert_contains(response, user_a.email)
+
+
+def test_search_by_email_end_excludes_users_with_different_email(
+    admin_client, users_admin_link, user_b, user_c
+):
+    response = admin_client.get("%s&email=*org" % users_admin_link)
+    assert_not_contains(response, user_b.email)
+    assert_not_contains(response, user_c.email)
+
+
+def test_search_finds_users_by_email_content(
+    admin_client, users_admin_link, user_b, user_c
+):
+    response = admin_client.get("%s&email=*@gmail*" % users_admin_link)
+    assert_contains(response, user_b.email)
+    assert_contains(response, user_c.email)
+
+
+def test_search_by_email_content_excludes_users_with_different_email(
+    admin_client, users_admin_link, user_a
+):
+    response = admin_client.get("%s&email=*@gmail*" % users_admin_link)
+    assert_not_contains(response, user_a.email)
+
+
+@pytest.fixture
+def rank(db):
+    return Rank.objects.create(name="Test Rank")
+
+
+def test_search_finds_user_with_rank(admin_client, users_admin_link, rank):
+    user = create_test_user("UserWithRank", "rank@example.org", rank=rank)
+    response = admin_client.get("%s&rank=%s" % (users_admin_link, rank.pk))
+    assert_contains(response, user.username)
+
+
+def test_staff_users_search_excludes_user_without_rank(
+    admin_client, users_admin_link, rank
+):
+    user = create_test_user("RegularUser", "regular@example.org")
+    response = admin_client.get("%s&rank=%s" % (users_admin_link, rank.pk))
+    assert_not_contains(response, user.username)
+
+
+@pytest.fixture
+def role(db):
+    return Role.objects.create(name="Test Role")
+
+
+def test_search_finds_user_with_role(admin_client, users_admin_link, role):
+    user = create_test_user("UserWithRole", "role@example.org")
+    user.roles.add(role)
+    response = admin_client.get("%s&role=%s" % (users_admin_link, role.pk))
+    assert_contains(response, user.username)
+
+
+def test_staff_users_search_excludes_user_without_role(
+    admin_client, users_admin_link, role
+):
+    user = create_test_user("RegularUser", "regular@example.org")
+    response = admin_client.get("%s&role=%s" % (users_admin_link, role.pk))
+    assert_not_contains(response, user.username)
+
+
+def test_search_finds_inactive_user(admin_client, users_admin_link):
+    user = create_test_user(
+        "InactiveUser", "inactive@example.org", requires_activation=1
+    )
+    response = admin_client.get("%s&is_inactive=1" % users_admin_link)
+    assert_contains(response, user.username)
+
+
+def test_inactive_users_search_excludes_activated_users(admin_client, users_admin_link):
+    user = create_test_user(
+        "ActivatedUser", "activated@example.org", requires_activation=0
+    )
+    response = admin_client.get("%s&is_inactive=1" % users_admin_link)
+    assert_not_contains(response, user.username)
+
+
+def test_search_finds_disabled_user(admin_client, users_admin_link):
+    user = create_test_user("DisabledUser", "disabled@example.org", is_active=False)
+    response = admin_client.get("%s&is_disabled=1" % users_admin_link)
+    assert_contains(response, user.username)
+
+
+def test_disabled_users_search_excludes_active_users(admin_client, users_admin_link):
+    user = create_test_user("ActiveUser", "active@example.org", is_active=True)
+    response = admin_client.get("%s&is_disabled=1" % users_admin_link)
+    assert_not_contains(response, user.username)
+
+
+def test_search_finds_staff_user(admin_client, users_admin_link):
+    user = create_test_user("StaffUser", "staff@example.org", is_staff=True)
+    response = admin_client.get("%s&is_staff=1" % users_admin_link)
+    assert_contains(response, user.username)
+
+
+def test_staff_users_search_excludes_non_staff_users(admin_client, users_admin_link):
+    user = create_test_user("RegularUser", "non_staff@example.org", is_staff=False)
+    response = admin_client.get("%s&is_staff=1" % users_admin_link)
+    assert_not_contains(response, user.username)
+
+
+def test_search_finds_user_deleting_account(admin_client, users_admin_link):
+    user = create_test_user(
+        "DeletingUser", "deleting@example.org", is_deleting_account=True
+    )
+    response = admin_client.get("%s&is_deleting_account=1" % users_admin_link)
+    assert_contains(response, user.username)
+
+
+def test_staff_users_search_excludes_non_deleting_users(admin_client, users_admin_link):
+    user = create_test_user(
+        "RegularUser", "regular@example.org", is_deleting_account=False
+    )
+    response = admin_client.get("%s&is_deleting_account=1" % users_admin_link)
+    assert_not_contains(response, user.username)

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

@@ -0,0 +1,716 @@
+import pytest
+from django.contrib.auth import get_user_model
+from django.urls import reverse
+
+from ....acl.models import Role
+from ....legal.models import Agreement
+from ....legal.utils import save_user_agreement_acceptance
+from ....test import assert_contains
+from ...models import Rank
+from ...utils import hash_email
+
+User = get_user_model()
+
+
+def test_link_is_registered_in_admin_nav(admin_client):
+    response = admin_client.get(reverse("misago:admin:index"))
+    assert_contains(response, reverse("misago:admin:users:accounts:index"))
+
+
+def test_list_renders_with_item(admin_client, users_admin_link, superuser):
+    response = admin_client.get(users_admin_link)
+    assert_contains(response, superuser.username)
+
+
+def test_new_user_form_renders(admin_client):
+    response = admin_client.get(reverse("misago:admin:users:accounts:new"))
+    assert response.status_code == 200
+
+
+def test_new_user_can_be_created(admin_client):
+    default_rank = Rank.objects.get_default()
+    authenticated_role = Role.objects.get(special_role="authenticated")
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:new"),
+        data={
+            "username": "User",
+            "rank": str(default_rank.pk),
+            "roles": str(authenticated_role.pk),
+            "email": "user@example.com",
+            "new_password": "pass123",
+            "staff_level": "0",
+        },
+    )
+
+    user = User.objects.get_by_email("user@example.com")
+    assert user.username == "User"
+    assert user.rank == default_rank
+    assert authenticated_role in user.roles.all()
+    assert user.check_password("pass123")
+    assert not user.is_staff
+    assert not user.is_superuser
+
+
+def test_new_user_can_be_created_with_whitespace_around_password(admin_client):
+    default_rank = Rank.objects.get_default()
+    authenticated_role = Role.objects.get(special_role="authenticated")
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:new"),
+        data={
+            "username": "User",
+            "rank": str(default_rank.pk),
+            "roles": str(authenticated_role.pk),
+            "email": "user@example.com",
+            "new_password": "  pass123  ",
+            "staff_level": "0",
+        },
+    )
+
+    user = User.objects.get_by_email("user@example.com")
+    assert user.check_password("  pass123  ")
+
+
+def test_new_user_creation_fails_because_user_was_not_given_authenticated_role(
+    admin_client
+):
+    default_rank = Rank.objects.get_default()
+    guest_role = Role.objects.get(special_role="anonymous")
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:new"),
+        data={
+            "username": "User",
+            "rank": str(default_rank.pk),
+            "roles": str(guest_role.pk),
+            "email": "user@example.com",
+            "new_password": "pass123",
+            "staff_level": "0",
+        },
+    )
+
+    with pytest.raises(User.DoesNotExist):
+        User.objects.get_by_email("user@example.com")
+
+
+def test_edit_user_form_renders(admin_client, user):
+    response = admin_client.get(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk})
+    )
+    assert response.status_code == 200
+
+
+def test_edit_user_form_renders_for_staff_user(staff_client, user):
+    response = staff_client.get(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk})
+    )
+    assert response.status_code == 200
+
+
+def test_edit_staff_form_renders_for_staff_user(staff_client, other_staffuser):
+    response = staff_client.get(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": other_staffuser.pk})
+    )
+    assert response.status_code == 200
+
+
+def test_edit_superuser_form_renders_for_staff_user(staff_client, superuser):
+    response = staff_client.get(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": superuser.pk})
+    )
+    assert response.status_code == 200
+
+
+def get_default_edit_form_data(user):
+    default_rank = Rank.objects.get_default()
+    authenticated_role = Role.objects.get(special_role="authenticated")
+    data = {
+        "username": user.username,
+        "rank": str(user.rank_id),
+        "roles": str(user.roles.all()[0].id),
+        "email": user.email,
+        "new_password": "",
+        "signature": user.signature,
+        "is_signature_locked": str(user.is_signature_locked),
+        "is_hiding_presence": str(user.is_hiding_presence),
+        "limits_private_thread_invites_to": str(user.limits_private_thread_invites_to),
+        "signature_lock_staff_message": str(user.signature_lock_staff_message or ""),
+        "signature_lock_user_message": str(user.signature_lock_user_message or ""),
+        "subscribe_to_started_threads": str(user.subscribe_to_started_threads),
+        "subscribe_to_replied_threads": str(user.subscribe_to_replied_threads),
+        "is_active": "1",
+    }
+
+    if user.is_staff:
+        data["is_staff"] = "1"
+    if user.is_superuser:
+        data["is_superuser"] = "1"
+
+    return data
+
+
+def test_edit_form_changes_user_username(admin_client, user):
+    form_data = get_default_edit_form_data(user)
+    form_data["username"] = "NewUsername"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert user.username == "NewUsername"
+    assert user.slug == "newusername"
+
+
+def test_editing_user_username_creates_entry_in_username_history(admin_client, user):
+    form_data = get_default_edit_form_data(user)
+    form_data["username"] = "NewUsername"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    assert user.namechanges.exists()
+
+
+def test_not_editing_user_username_doesnt_create_entry_in_username_history(
+    admin_client, user
+):
+    form_data = get_default_edit_form_data(user)
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    assert not user.namechanges.exists()
+
+
+def test_edit_form_changes_user_email(admin_client, user):
+    form_data = get_default_edit_form_data(user)
+    form_data["email"] = "edited@example.com"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert user.email == "edited@example.com"
+    assert user.email_hash == hash_email("edited@example.com")
+
+
+def test_edit_form_doesnt_remove_current_user_password_if_new_password_is_omitted(
+    admin_client, user, user_password
+):
+    form_data = get_default_edit_form_data(user)
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert user.check_password(user_password)
+
+
+def test_edit_form_displays_message_for_user_with_unusable_password(
+    admin_client, user, user_password
+):
+    user.set_password(None)
+    user.save()
+
+    response = admin_client.get(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk})
+    )
+
+    assert_contains(response, "alert-has-unusable-password")
+
+
+def test_edit_form_doesnt_set_password_for_user_with_unusable_password_if_none_is_given(
+    admin_client, user, user_password
+):
+    user.set_password(None)
+    user.save()
+
+    form_data = get_default_edit_form_data(user)
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert not user.has_usable_password()
+
+
+def test_edit_form_sets_password_for_user_with_unusable_password(
+    admin_client, user, user_password
+):
+    user.set_password(None)
+    user.save()
+
+    form_data = get_default_edit_form_data(user)
+    form_data["new_password"] = user_password
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert user.check_password(user_password)
+
+
+def test_edit_form_changes_user_password(admin_client, user):
+    form_data = get_default_edit_form_data(user)
+    form_data["new_password"] = "newpassword123"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert user.check_password("newpassword123")
+
+
+def test_edit_form_preserves_whitespace_in_new_user_password(admin_client, user):
+    form_data = get_default_edit_form_data(user)
+    form_data["new_password"] = "  newpassword123  "
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert user.check_password("  newpassword123  ")
+
+
+def test_admin_editing_their_own_password_is_not_logged_out(admin_client, superuser):
+    form_data = get_default_edit_form_data(superuser)
+    form_data["new_password"] = "newpassword123"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": superuser.pk}),
+        data=form_data,
+    )
+
+    user = admin_client.get("/api/auth/")
+    assert user.json()["id"] == superuser.id
+
+
+def test_staff_user_cannot_degrade_superuser_to_staff_user(staff_client, superuser):
+    form_data = get_default_edit_form_data(superuser)
+    form_data["is_staff"] = "1"
+    form_data.pop("is_superuser")
+
+    staff_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": superuser.pk}),
+        data=form_data,
+    )
+
+    superuser.refresh_from_db()
+    assert superuser.is_staff
+    assert superuser.is_superuser
+
+
+def test_staff_user_cannot_degrade_superuser_to_regular_user(staff_client, superuser):
+    form_data = get_default_edit_form_data(superuser)
+    form_data.pop("is_staff")
+    form_data.pop("is_superuser")
+
+    staff_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": superuser.pk}),
+        data=form_data,
+    )
+
+    superuser.refresh_from_db()
+    assert superuser.is_staff
+    assert superuser.is_superuser
+
+
+def test_staff_user_cannot_promote_other_staff_user_to_superuser(
+    staff_client, other_staffuser
+):
+    form_data = get_default_edit_form_data(other_staffuser)
+    form_data["is_staff"] = "1"
+    form_data["is_superuser"] = "1"
+
+    staff_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": other_staffuser.pk}),
+        data=form_data,
+    )
+
+    other_staffuser.refresh_from_db()
+    assert other_staffuser.is_staff
+    assert not other_staffuser.is_superuser
+
+
+def test_staff_user_cannot_promote_regular_user_to_staff(staff_client, user):
+    form_data = get_default_edit_form_data(user)
+    form_data["is_staff"] = "1"
+
+    staff_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert not user.is_staff
+
+
+def test_staff_user_cannot_promote_regular_user_to_superuser(staff_client, user):
+    form_data = get_default_edit_form_data(user)
+    form_data["is_superuser"] = "1"
+
+    staff_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert not user.is_superuser
+
+
+def test_staff_user_cannot_promote_themselves_to_superuser(staff_client, staffuser):
+    form_data = get_default_edit_form_data(staffuser)
+    form_data["is_superuser"] = "1"
+
+    staff_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": staffuser.pk}),
+        data=form_data,
+    )
+
+    staffuser.refresh_from_db()
+    assert not staffuser.is_superuser
+
+
+def test_staff_user_cannot_degrade_themselves_to_regular_user(staff_client, staffuser):
+    form_data = get_default_edit_form_data(staffuser)
+    form_data.pop("is_staff")
+
+    staff_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": staffuser.pk}),
+        data=form_data,
+    )
+
+    staffuser.refresh_from_db()
+    assert staffuser.is_staff
+
+
+def test_superuser_cannot_degrade_themselves_to_staff_user(admin_client, superuser):
+    form_data = get_default_edit_form_data(superuser)
+    form_data.pop("is_superuser")
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": superuser.pk}),
+        data=form_data,
+    )
+
+    superuser.refresh_from_db()
+    assert superuser.is_superuser
+
+
+def test_superuser_cannot_degrade_themselves_to_regular_user(admin_client, superuser):
+    form_data = get_default_edit_form_data(superuser)
+    form_data.pop("is_staff")
+    form_data.pop("is_superuser")
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": superuser.pk}),
+        data=form_data,
+    )
+
+    superuser.refresh_from_db()
+    assert superuser.is_staff
+    assert superuser.is_superuser
+
+
+def test_superuser_can_degrade_other_superuser_to_staff_user(
+    admin_client, other_superuser
+):
+    form_data = get_default_edit_form_data(other_superuser)
+    form_data.pop("is_superuser")
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": other_superuser.pk}),
+        data=form_data,
+    )
+
+    other_superuser.refresh_from_db()
+    assert other_superuser.is_staff
+    assert not other_superuser.is_superuser
+
+
+def test_superuser_can_degrade_other_superuser_to_regular_user(
+    admin_client, other_superuser
+):
+    form_data = get_default_edit_form_data(other_superuser)
+    form_data.pop("is_staff")
+    form_data.pop("is_superuser")
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": other_superuser.pk}),
+        data=form_data,
+    )
+
+    other_superuser.refresh_from_db()
+    assert not other_superuser.is_staff
+    assert not other_superuser.is_superuser
+
+
+def test_superuser_can_promote_to_staff_user_to_superuser(admin_client, staffuser):
+    form_data = get_default_edit_form_data(staffuser)
+    form_data["is_superuser"] = "1"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": staffuser.pk}),
+        data=form_data,
+    )
+
+    staffuser.refresh_from_db()
+    assert staffuser.is_staff
+    assert staffuser.is_superuser
+
+
+def test_superuser_can_promote_to_regular_user_to_staff_user(admin_client, user):
+    form_data = get_default_edit_form_data(user)
+    form_data["is_staff"] = "1"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert user.is_staff
+    assert not user.is_superuser
+
+
+def test_superuser_can_promote_to_regular_user_to_superuser(admin_client, user):
+    form_data = get_default_edit_form_data(user)
+    form_data["is_staff"] = "1"
+    form_data["is_superuser"] = "1"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert user.is_staff
+    assert user.is_superuser
+
+
+def test_superuser_can_disable_other_superuser_account(admin_client, other_superuser):
+    form_data = get_default_edit_form_data(other_superuser)
+    form_data["is_active"] = "0"
+    form_data["is_active_staff_message"] = "Test message"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": other_superuser.pk}),
+        data=form_data,
+    )
+
+    other_superuser.refresh_from_db()
+    assert not other_superuser.is_active
+    assert other_superuser.is_active_staff_message == "Test message"
+
+
+def test_superuser_can_reactivate_other_superuser_account(
+    admin_client, other_superuser
+):
+    other_superuser.is_active = False
+    other_superuser.save()
+
+    form_data = get_default_edit_form_data(other_superuser)
+    form_data["is_active"] = "1"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": other_superuser.pk}),
+        data=form_data,
+    )
+
+    other_superuser.refresh_from_db()
+    assert other_superuser.is_active
+
+
+def test_superuser_can_disable_staff_user_account(admin_client, staffuser):
+    form_data = get_default_edit_form_data(staffuser)
+    form_data["is_active"] = "0"
+    form_data["is_active_staff_message"] = "Test message"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": staffuser.pk}),
+        data=form_data,
+    )
+
+    staffuser.refresh_from_db()
+    assert not staffuser.is_active
+    assert staffuser.is_active_staff_message == "Test message"
+
+
+def test_superuser_can_reactivate_staff_user_account(admin_client, staffuser):
+    staffuser.is_active = False
+    staffuser.save()
+
+    form_data = get_default_edit_form_data(staffuser)
+    form_data["is_active"] = "1"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": staffuser.pk}),
+        data=form_data,
+    )
+
+    staffuser.refresh_from_db()
+    assert staffuser.is_active
+
+
+def test_superuser_can_disable_regular_user_account(admin_client, user):
+    form_data = get_default_edit_form_data(user)
+    form_data["is_active"] = "0"
+    form_data["is_active_staff_message"] = "Test message"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert not user.is_active
+    assert user.is_active_staff_message == "Test message"
+
+
+def test_superuser_can_reactivate_regular_user_account(admin_client, user):
+    user.is_active = False
+    user.save()
+
+    form_data = get_default_edit_form_data(user)
+    form_data["is_active"] = "1"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert user.is_active
+
+
+def test_staff_user_can_disable_regular_user_account(staff_client, user):
+    form_data = get_default_edit_form_data(user)
+    form_data["is_active"] = "0"
+
+    staff_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert not user.is_active
+
+
+def test_staff_user_can_reactivate_regular_user_account(staff_client, user):
+    user.is_active = False
+    user.save()
+
+    form_data = get_default_edit_form_data(user)
+    form_data["is_active"] = "1"
+
+    staff_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert user.is_active
+
+
+def test_superuser_cant_disable_their_own_account(admin_client, superuser):
+    form_data = get_default_edit_form_data(superuser)
+    form_data["is_active"] = "0"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": superuser.pk}),
+        data=form_data,
+    )
+
+    superuser.refresh_from_db()
+    assert superuser.is_active
+
+
+def test_staff_user_cant_disable_their_own_account(staff_client, staffuser):
+    form_data = get_default_edit_form_data(staffuser)
+    form_data["is_active"] = "0"
+
+    staff_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": staffuser.pk}),
+        data=form_data,
+    )
+
+    staffuser.refresh_from_db()
+    assert staffuser.is_active
+
+
+def test_staff_user_cant_disable_superuser_account(staff_client, superuser):
+    form_data = get_default_edit_form_data(superuser)
+    form_data["is_active"] = "0"
+
+    staff_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": superuser.pk}),
+        data=form_data,
+    )
+
+    superuser.refresh_from_db()
+    assert superuser.is_active
+
+
+def test_staff_user_cant_disable_other_staff_user_account(
+    staff_client, other_staffuser
+):
+    form_data = get_default_edit_form_data(other_staffuser)
+    form_data["is_active"] = "0"
+
+    staff_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": other_staffuser.pk}),
+        data=form_data,
+    )
+
+    other_staffuser.refresh_from_db()
+    assert other_staffuser.is_active
+
+
+def test_user_deleting_their_account_cant_be_reactivated(admin_client, user):
+    user.mark_for_delete()
+
+    form_data = get_default_edit_form_data(user)
+    form_data["is_active"] = "1"
+
+    admin_client.post(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk}),
+        data=form_data,
+    )
+
+    user.refresh_from_db()
+    assert not user.is_active
+
+
+def test_user_agreements_are_displayed_on_edit_form(admin_client, user):
+    agreement = Agreement.objects.create(
+        type=Agreement.TYPE_TOS,
+        title="Test agreement!",
+        text="Lorem ipsum!",
+        is_active=True,
+    )
+
+    save_user_agreement_acceptance(user, agreement, commit=True)
+
+    response = admin_client.get(
+        reverse("misago:admin:users:accounts:edit", kwargs={"pk": user.pk})
+    )
+    assert_contains(response, agreement.title)

+ 308 - 0
misago/users/admin/tests/test_users_mass_actions.py

@@ -0,0 +1,308 @@
+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_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
+
+User = get_user_model()
+
+
+def create_multiple_users(**kwargs):
+    return [
+        create_test_user("User%s" % i, "user%s@gmail.com" % i, **kwargs)
+        for i in range(5)
+    ]
+
+
+def get_multiple_users_ids(**kwargs):
+    users = create_multiple_users(**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 = create_multiple_users(requires_activation=1)
+    response = admin_client.post(
+        users_admin_link,
+        data={"action": "activate", "selected_items": [u.id for u in users]},
+    )
+
+    for user in users:
+        user.refresh_from_db()
+        assert not user.requires_activation
+
+
+def test_activating_multiple_users_sends_email_notifications_to_them(
+    admin_client, users_admin_link
+):
+    users_ids = get_multiple_users_ids(requires_activation=1)
+    response = admin_client.post(
+        users_admin_link, data={"action": "activate", "selected_items": users_ids}
+    )
+
+    assert len(mail.outbox) == len(users_ids)
+    assert "has been activated" in mail.outbox[0].subject
+
+
+def test_ban_multiple_users_form_is_rendered(admin_client, users_admin_link):
+    users_ids = get_multiple_users_ids()
+    response = admin_client.post(
+        users_admin_link, data={"action": "ban", "selected_items": users_ids}
+    )
+    assert response.status_code == 200
+
+
+def test_multiple_users_can_be_banned_with_mass_action(admin_client, users_admin_link):
+    users = create_multiple_users()
+    admin_client.post(
+        users_admin_link,
+        data={
+            "action": "ban",
+            "selected_items": [u.id for u in users],
+            "ban_type": ["usernames", "emails", "domains"],
+            "finalize": "",
+        },
+    )
+
+    for user in users:
+        Ban.objects.get(banned_value=user.username.lower())
+        Ban.objects.get(banned_value=user.email)
+        Ban.objects.get(banned_value="*%s" % user.email[-10:])
+
+
+def test_option_to_ban_multiple_users_ips_is_disabled_if_user_ips_are_not_available(
+    admin_client, users_admin_link, users_ids
+):
+    response = admin_client.post(
+        users_admin_link, data={"action": "ban", "selected_items": users_ids}
+    )
+    assert_not_contains(response, 'value="ip"')
+    assert_not_contains(response, 'value="ip_first"')
+    assert_not_contains(response, 'value="ip_two"')
+
+
+def test_option_to_ban_multiple_users_ips_is_enabled_if_user_ips_are_available(
+    admin_client, users_admin_link
+):
+    users_ids = get_multiple_users_ids(joined_from_ip="1.2.3.4")
+    response = admin_client.post(
+        users_admin_link, data={"action": "ban", "selected_items": users_ids}
+    )
+    assert_contains(response, 'value="ip"')
+    assert_contains(response, 'value="ip_first"')
+    assert_contains(response, 'value="ip_two"')
+
+
+def test_multiple_users_ips_can_be_banned_with_mass_action(
+    admin_client, users_admin_link
+):
+    users_ids = get_multiple_users_ids(joined_from_ip="1.2.3.4")
+    response = admin_client.post(
+        users_admin_link,
+        data={
+            "action": "ban",
+            "selected_items": users_ids,
+            "ban_type": ["ip", "ip_first", "ip_two"],
+            "finalize": "",
+        },
+    )
+
+    Ban.objects.get(banned_value="1.2.3.4")
+    Ban.objects.get(banned_value="1.*")
+    Ban.objects.get(banned_value="1.2.*")
+
+
+def test_banning_multiple_users_with_mass_action_invalidates_bans_cache(
+    admin_client, users_admin_link, users_ids
+):
+    with assert_invalidates_cache(BANS_CACHE):
+        admin_client.post(
+            users_admin_link,
+            data={
+                "action": "ban",
+                "selected_items": users_ids,
+                "ban_type": ["usernames", "emails", "domains"],
+                "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

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


+ 1 - 1
misago/users/views/admin/bans.py → misago/users/admin/views/bans.py

@@ -2,8 +2,8 @@ from django.contrib import messages
 from django.utils.translation import gettext_lazy as _
 
 from ....admin.views import generic
-from ...forms.admin import BanForm, FilterBansForm
 from ...models import Ban
+from ..forms import BanForm, FilterBansForm
 
 
 class BanAdmin(generic.AdminBaseMixin):

+ 1 - 1
misago/users/views/admin/datadownloads.py → misago/users/admin/views/datadownloads.py

@@ -7,8 +7,8 @@ from ...datadownloads import (
     request_user_data_download,
     user_has_data_download_request,
 )
-from ...forms.admin import RequestDataDownloadsForm, FilterDataDownloadsForm
 from ...models import DataDownload
+from ..forms import RequestDataDownloadsForm, FilterDataDownloadsForm
 
 
 class DataDownloadAdmin(generic.AdminBaseMixin):

+ 1 - 1
misago/users/views/admin/ranks.py → misago/users/admin/views/ranks.py

@@ -4,8 +4,8 @@ from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
 from ....admin.views import generic
-from ...forms.admin import RankForm
 from ...models import Rank
+from ..forms import RankForm
 
 
 class RankAdmin(generic.AdminBaseMixin):

+ 21 - 13
misago/users/views/admin/users.py → misago/users/admin/views/users.py

@@ -14,17 +14,18 @@ from ....core.pgutils import chunk_queryset
 from ....threads.models import Thread
 from ...avatars.dynamic import set_avatar as set_dynamic_avatar
 from ...datadownloads import request_user_data_download, user_has_data_download_request
-from ...forms.admin import (
+from ...models import Ban
+from ...profilefields import profilefields
+from ...setupnewuser import setup_new_user
+from ...signatures import set_user_signature
+from ..forms import (
     BanUsersForm,
     EditUserForm,
     EditUserFormFactory,
     NewUserForm,
     create_filter_users_form,
 )
-from ...models import Ban
-from ...profilefields import profilefields
-from ...setupnewuser import setup_new_user
-from ...signatures import set_user_signature
+from ..tasks import delete_user_with_content
 
 User = get_user_model()
 
@@ -62,8 +63,8 @@ class UsersList(UserAdmin, generic.ListView):
         ("id", _("From oldest")),
         ("slug", _("A to z")),
         ("-slug", _("Z to a")),
-        ("posts", _("Biggest posters")),
-        ("-posts", _("Smallest posters")),
+        ("-posts", _("Biggest posters")),
+        ("posts", _("Smallest posters")),
     ]
     selection_label = _("With users: 0")
     empty_selection_label = _("Select users")
@@ -87,7 +88,7 @@ class UsersList(UserAdmin, generic.ListView):
         },
         {
             "action": "delete_all",
-            "name": _("Delete all"),
+            "name": _("Delete with content"),
             "icon": "fa fa-eraser",
             "confirmation": _(
                 "Are you sure you want to delete selected users? "
@@ -241,8 +242,18 @@ class UsersList(UserAdmin, generic.ListView):
                 }
                 raise generic.MassActionError(message)
 
-        return self.render(
-            request, template="misago/admin/users/delete.html", context={"users": users}
+        for user in users:
+            user.is_active = False
+            user.save()
+
+            delete_user_with_content.delay(user.pk)
+
+        messages.success(
+            request,
+            _(
+                "Selected users have disabled and been queued for deletion "
+                "together with their content."
+            ),
         )
 
 
@@ -266,9 +277,6 @@ class NewUser(UserAdmin, generic.ModelFormView):
             joined_from_ip=request.user_ip,
         )
 
-        if form.cleaned_data.get("staff_level"):
-            new_user.staff_level = form.cleaned_data["staff_level"]
-
         if form.cleaned_data.get("roles"):
             new_user.roles.add(*form.cleaned_data["roles"])
 

+ 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()

+ 5 - 0
misago/users/signals.py

@@ -100,3 +100,8 @@ def remove_old_registrations_ips(sender, **kwargs):
 def remove_old_audit_trails(sender, **kwargs):
     removal_cutoff = timezone.now() - timedelta(days=settings.MISAGO_IP_STORE_TIME)
     AuditTrail.objects.filter(created_on__lte=removal_cutoff).delete()
+
+
+@receiver(delete_user_content)
+def remove_data_downloads(sender, **kwargs):
+    pass

+ 0 - 125
misago/users/tests/test_banadmin_views.py

@@ -1,125 +0,0 @@
-from datetime import datetime, timedelta
-
-from django.urls import reverse
-
-from ...admin.test import AdminTestCase
-from ..models import Ban
-
-
-class BanAdminViewsTests(AdminTestCase):
-    def test_link_registered(self):
-        """admin nav contains bans link"""
-        response = self.client.get(reverse("misago:admin:users:accounts:index"))
-
-        response = self.client.get(response["location"])
-        self.assertContains(response, reverse("misago:admin:users:bans:index"))
-
-    def test_list_view(self):
-        """bans list view returns 200"""
-        response = self.client.get(reverse("misago:admin:users:bans:index"))
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(response["location"])
-        self.assertEqual(response.status_code, 200)
-
-    def test_mass_delete(self):
-        """adminview deletes multiple bans"""
-        test_date = datetime.now() + timedelta(days=180)
-
-        for i in range(10):
-            response = self.client.post(
-                reverse("misago:admin:users:bans:new"),
-                data={
-                    "check_type": "1",
-                    "banned_value": "%stest@test.com" % i,
-                    "user_message": "Lorem ipsum dolor met",
-                    "staff_message": "Sit amet elit",
-                    "expires_on": test_date.isoformat(),
-                },
-            )
-            self.assertEqual(response.status_code, 302)
-
-        self.assertEqual(Ban.objects.count(), 10)
-
-        bans_pks = []
-        for ban in Ban.objects.iterator():
-            bans_pks.append(ban.pk)
-
-        response = self.client.post(
-            reverse("misago:admin:users:bans:index"),
-            data={"action": "delete", "selected_items": bans_pks},
-        )
-        self.assertEqual(response.status_code, 302)
-        self.assertEqual(Ban.objects.count(), 0)
-
-    def test_new_view(self):
-        """new ban view has no showstoppers"""
-        response = self.client.get(reverse("misago:admin:users:bans:new"))
-        self.assertEqual(response.status_code, 200)
-
-        test_date = datetime.now() + timedelta(days=180)
-
-        response = self.client.post(
-            reverse("misago:admin:users:bans:new"),
-            data={
-                "check_type": "1",
-                "banned_value": "test@test.com",
-                "user_message": "Lorem ipsum dolor met",
-                "staff_message": "Sit amet elit",
-                "expires_on": test_date.isoformat(),
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:users:bans:index"))
-        response = self.client.get(response["location"])
-        self.assertEqual(response.status_code, 200)
-        self.assertContains(response, "test@test.com")
-
-    def test_edit_view(self):
-        """edit ban view has no showstoppers"""
-        self.client.post(
-            reverse("misago:admin:users:bans:new"),
-            data={"check_type": "0", "banned_value": "Admin"},
-        )
-
-        test_ban = Ban.objects.get(banned_value="admin")
-        form_link = reverse("misago:admin:users:bans:edit", kwargs={"pk": test_ban.pk})
-
-        response = self.client.post(
-            form_link,
-            data={
-                "check_type": "1",
-                "banned_value": "test@test.com",
-                "user_message": "Lorem ipsum dolor met",
-                "staff_message": "Sit amet elit",
-                "expires_on": "",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:users:bans:index"))
-        response = self.client.get(response["location"])
-        self.assertEqual(response.status_code, 200)
-        self.assertContains(response, "test@test.com")
-
-    def test_delete_view(self):
-        """delete ban view has no showstoppers"""
-        self.client.post(
-            reverse("misago:admin:users:bans:new"),
-            data={"check_type": "0", "banned_value": "TestBan"},
-        )
-
-        test_ban = Ban.objects.get(banned_value="testban")
-
-        response = self.client.post(
-            reverse("misago:admin:users:bans:delete", kwargs={"pk": test_ban.pk})
-        )
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(reverse("misago:admin:users:bans:index"))
-        self.client.get(response["location"])
-        response = self.client.get(response["location"])
-
-        self.assertEqual(response.status_code, 200)
-        self.assertNotContains(response, test_ban.banned_value)

+ 0 - 1155
misago/users/tests/test_useradmin_views.py

@@ -1,1155 +0,0 @@
-from django.contrib.auth import get_user_model
-from django.core import mail
-from django.urls import reverse
-
-from ...acl.models import Role
-from ...admin.test import AdminTestCase
-from ...categories.models import Category
-from ...legal.models import Agreement
-from ...legal.utils import save_user_agreement_acceptance
-from ...threads.test import post_thread, reply_thread
-from ..datadownloads import request_user_data_download
-from ..models import Ban, DataDownload, Rank
-from ..test import create_test_user
-
-User = get_user_model()
-
-
-class UserAdminViewsTests(AdminTestCase):
-    AJAX_HEADER = {"HTTP_X_REQUESTED_WITH": "XMLHttpRequest"}
-
-    def test_link_registered(self):
-        """admin index view contains users link"""
-        response = self.client.get(reverse("misago:admin:index"))
-
-        self.assertContains(response, reverse("misago:admin:users:accounts:index"))
-
-    def test_list_view(self):
-        """users list view returns 200"""
-        response = self.client.get(reverse("misago:admin:users:accounts:index"))
-        self.assertEqual(response.status_code, 302)
-
-        response = self.client.get(response["location"])
-        self.assertEqual(response.status_code, 200)
-        self.assertContains(response, self.user.username)
-
-    def test_list_filtering(self):
-        """users list can be filtered"""
-        response = self.client.get(reverse("misago:admin:users:accounts:index"))
-        self.assertEqual(response.status_code, 302)
-
-        link_base = response["location"]
-        response = self.client.get(link_base)
-        self.assertEqual(response.status_code, 200)
-
-        user_a = create_test_user("Tyrael", "t123@test.com")
-        user_b = create_test_user("Tyrion", "t321@test.com")
-        user_c = create_test_user("Karen", "t432@test.com")
-
-        # Partial search for "tyr" returns both "tyrael" and "tyrion"
-        response = self.client.get("%s&username=tyr" % link_base)
-        self.assertEqual(response.status_code, 200)
-        self.assertContains(response, user_a.username)
-        self.assertContains(response, user_b.username)
-
-        # Search for "tyrion" returns it only
-        response = self.client.get("%s&username=tyrion" % link_base)
-        self.assertEqual(response.status_code, 200)
-        self.assertNotContains(response, user_a.username)
-        self.assertContains(response, user_b.username)
-
-        # Search for "tyrael" returns it only
-        response = self.client.get("%s&email=t123@test.com" % link_base)
-        self.assertEqual(response.status_code, 200)
-        self.assertContains(response, user_a.username)
-        self.assertNotContains(response, user_b.username)
-
-        # Search for disabled user
-        user_c.is_active = False
-        user_c.save()
-
-        response = self.client.get("%s&is_disabled=1" % link_base)
-        self.assertEqual(response.status_code, 200)
-        self.assertNotContains(response, user_a.username)
-        self.assertNotContains(response, user_b.username)
-        self.assertContains(response, user_c.username)
-
-        # Search for requested own account delete
-        user_c.is_deleting_account = True
-        user_c.save()
-
-        response = self.client.get("%s&is_deleting_account=1" % link_base)
-        self.assertEqual(response.status_code, 200)
-        self.assertNotContains(response, user_a.username)
-        self.assertNotContains(response, user_b.username)
-        self.assertContains(response, user_c.username)
-
-        response = self.client.get("%s&is_disabled=1" % link_base)
-        self.assertEqual(response.status_code, 200)
-        self.assertNotContains(response, user_a.username)
-        self.assertNotContains(response, user_b.username)
-        self.assertContains(response, user_c.username)
-
-    def test_mass_activation(self):
-        """users list activates 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": "activate", "selected_items": user_pks},
-        )
-        self.assertEqual(response.status_code, 302)
-
-        inactive_qs = User.objects.filter(id__in=user_pks, requires_activation=1)
-        self.assertEqual(inactive_qs.count(), 0)
-        self.assertIn("has been activated", mail.outbox[0].subject)
-
-    def test_mass_ban(self):
-        """users list bans 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": "ban", "selected_items": user_pks},
-        )
-        self.assertNotContains(response, 'value="ip"')
-        self.assertNotContains(response, 'value="ip_first"')
-        self.assertNotContains(response, 'value="ip_two"')
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={
-                "action": "ban",
-                "selected_items": user_pks,
-                "ban_type": ["usernames", "emails", "domains"],
-                "finalize": "",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-        self.assertEqual(Ban.objects.count(), 21)
-
-    def test_mass_ban_with_ips(self):
-        """users list bans multiple users that also have ips"""
-        user_pks = []
-        for i in range(10):
-            test_user = create_test_user(
-                "User%s" % i,
-                "user%s@example.com" % i,
-                joined_from_ip="73.95.67.27",
-                requires_activation=1,
-            )
-            user_pks.append(test_user.pk)
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={"action": "ban", "selected_items": user_pks},
-        )
-        self.assertContains(response, 'value="ip"')
-        self.assertContains(response, 'value="ip_first"')
-        self.assertContains(response, 'value="ip_two"')
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:index"),
-            data={
-                "action": "ban",
-                "selected_items": user_pks,
-                "ban_type": [
-                    "usernames",
-                    "emails",
-                    "domains",
-                    "ip",
-                    "ip_first",
-                    "ip_two",
-                ],
-                "finalize": "",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-        self.assertEqual(Ban.objects.count(), 24)
-
-    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"))
-        self.assertEqual(response.status_code, 200)
-
-        default_rank = Rank.objects.get_default()
-        authenticated_role = Role.objects.get(special_role="authenticated")
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:new"),
-            data={
-                "username": "NewUsername",
-                "rank": str(default_rank.pk),
-                "roles": str(authenticated_role.pk),
-                "email": "edited@example.com",
-                "new_password": "pass123",
-                "staff_level": "0",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        User.objects.get_by_username("NewUsername")
-        test_user = User.objects.get_by_email("edited@example.com")
-
-        self.assertTrue(test_user.check_password("pass123"))
-
-    def test_new_view_password_with_whitespaces(self):
-        """new user view creates account with whitespaces password"""
-        response = self.client.get(reverse("misago:admin:users:accounts:new"))
-        self.assertEqual(response.status_code, 200)
-
-        default_rank = Rank.objects.get_default()
-        authenticated_role = Role.objects.get(special_role="authenticated")
-
-        response = self.client.post(
-            reverse("misago:admin:users:accounts:new"),
-            data={
-                "username": "NewUsername",
-                "rank": str(default_rank.pk),
-                "roles": str(authenticated_role.pk),
-                "email": "edited@example.com",
-                "new_password": " pass123 ",
-                "staff_level": "0",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        User.objects.get_by_username("NewUsername")
-        test_user = User.objects.get_by_email("edited@example.com")
-
-        self.assertTrue(test_user.check_password(" pass123 "))
-
-    def test_edit_view(self):
-        """edit user view changes account"""
-        test_user = create_test_user("User", "user@example.com")
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertEqual(response.status_code, 200)
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "NewUsername",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "new_password": "newpass123",
-                "staff_level": "0",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertTrue(updated_user.check_password("newpass123"))
-        self.assertEqual(updated_user.username, "NewUsername")
-        self.assertEqual(updated_user.slug, "newusername")
-
-        User.objects.get_by_username("NewUsername")
-        User.objects.get_by_email("edited@example.com")
-
-    def test_edit_dont_change_username(self):
-        """
-        If username wasn't changed, don't touch user's username, slug or history
-
-        This is regression test for issue #640
-        """
-        test_user = create_test_user("User", "user@example.com")
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertEqual(response.status_code, 200)
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "User",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertEqual(updated_user.username, "User")
-        self.assertEqual(updated_user.slug, "user")
-        self.assertEqual(updated_user.namechanges.count(), 0)
-
-    def test_edit_change_password_whitespaces(self):
-        """edit user view changes account password to include whitespaces"""
-        test_user = create_test_user("User", "user@example.com")
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertEqual(response.status_code, 200)
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "NewUsername",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "new_password": " newpass123 ",
-                "staff_level": "0",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertTrue(updated_user.check_password(" newpass123 "))
-        self.assertEqual(updated_user.username, "NewUsername")
-        self.assertEqual(updated_user.slug, "newusername")
-
-        User.objects.get_by_username("NewUsername")
-        User.objects.get_by_email("edited@example.com")
-
-    def test_edit_make_admin(self):
-        """edit user view allows super admin to make other user admin"""
-        test_user = create_test_user("User", "user@example.com")
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertContains(response, 'id="id_is_staff_1"')
-        self.assertContains(response, 'id="id_is_superuser_1"')
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "NewUsername",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "is_staff": "1",
-                "is_superuser": "0",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertTrue(updated_user.is_staff)
-        self.assertFalse(updated_user.is_superuser)
-
-    def test_edit_make_superadmin_admin(self):
-        """edit user view allows super admin to make other user super admin"""
-        test_user = create_test_user("User", "user@example.com")
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertContains(response, 'id="id_is_staff_1"')
-        self.assertContains(response, 'id="id_is_superuser_1"')
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "NewUsername",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "is_staff": "0",
-                "is_superuser": "1",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertFalse(updated_user.is_staff)
-        self.assertTrue(updated_user.is_superuser)
-
-    def test_edit_denote_superadmin(self):
-        """edit user view allows super admin to denote other super admin"""
-        test_user = create_test_user(
-            "User", "user@example.com", is_staff=True, is_superuser=True
-        )
-
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertContains(response, 'id="id_is_staff_1"')
-        self.assertContains(response, 'id="id_is_superuser_1"')
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "NewUsername",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "is_staff": "0",
-                "is_superuser": "0",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertFalse(updated_user.is_staff)
-        self.assertFalse(updated_user.is_superuser)
-
-    def test_edit_cant_make_admin(self):
-        """edit user view forbids admins from making other admins"""
-        self.user.is_superuser = False
-        self.user.save()
-
-        test_user = create_test_user("User", "user@example.com")
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertNotContains(response, 'id="id_is_staff_1"')
-        self.assertNotContains(response, 'id="id_is_superuser_1"')
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "NewUsername",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "is_staff": "1",
-                "is_superuser": "1",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertFalse(updated_user.is_staff)
-        self.assertFalse(updated_user.is_superuser)
-
-    def test_edit_disable_user(self):
-        """edit user view allows admin to disable non admin"""
-        self.user.is_superuser = False
-        self.user.save()
-
-        test_user = create_test_user("User", "user@example.com")
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertContains(response, 'id="id_is_active_1"')
-        self.assertContains(response, 'id="id_is_active_staff_message"')
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "NewUsername",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "is_staff": "0",
-                "is_superuser": "0",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-                "is_active": "0",
-                "is_active_staff_message": "Disabled in test!",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertFalse(updated_user.is_active)
-        self.assertEqual(updated_user.is_active_staff_message, "Disabled in test!")
-
-    def test_edit_superuser_disable_admin(self):
-        """edit user view allows admin to disable non admin"""
-        self.user.is_superuser = True
-        self.user.save()
-
-        test_user = create_test_user("User", "user@example.com")
-
-        test_user.is_staff = True
-        test_user.save()
-
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertContains(response, 'id="id_is_active_1"')
-        self.assertContains(response, 'id="id_is_active_staff_message"')
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "NewUsername",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "is_staff": "1",
-                "is_superuser": "0",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-                "is_active": "0",
-                "is_active_staff_message": "Disabled in test!",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertFalse(updated_user.is_active)
-        self.assertEqual(updated_user.is_active_staff_message, "Disabled in test!")
-
-    def test_edit_admin_cant_disable_admin(self):
-        """edit user view disallows admin to disable admin"""
-        self.user.is_superuser = False
-        self.user.save()
-
-        test_user = create_test_user("User", "user@example.com")
-
-        test_user.is_staff = True
-        test_user.save()
-
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertNotContains(response, 'id="id_is_active_1"')
-        self.assertNotContains(response, 'id="id_is_active_staff_message"')
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "NewUsername",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "is_staff": "1",
-                "is_superuser": "0",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-                "is_active": "0",
-                "is_active_staff_message": "Disabled in test!",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertTrue(updated_user.is_active)
-        self.assertFalse(updated_user.is_active_staff_message)
-
-    def test_edit_is_deleting_account_cant_reactivate(self):
-        """users deleting own accounts can't be reactivated"""
-        test_user = create_test_user("User", "user@example.com")
-        test_user.mark_for_delete()
-
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertNotContains(response, 'id="id_is_active_1"')
-        self.assertNotContains(response, 'id="id_is_active_staff_message"')
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "NewUsername",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "is_staff": "1",
-                "is_superuser": "0",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-                "is_active": "1",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertFalse(updated_user.is_active)
-        self.assertTrue(updated_user.is_deleting_account)
-
-    def test_edit_unusable_password(self):
-        """admin edit form handles unusable passwords and lets setting new password"""
-        test_user = create_test_user("User", "user@example.com")
-        self.assertFalse(test_user.has_usable_password())
-
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertContains(response, "alert-has-unusable-password")
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "NewUsername",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "new_password": "pass123",
-                "is_staff": "1",
-                "is_superuser": "0",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-                "is_active": "1",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertTrue(updated_user.has_usable_password())
-
-    def test_edit_keep_unusable_password(self):
-        """
-        admin edit form handles unusable passwords and lets admin leave them unchanged
-        """
-        test_user = create_test_user("User", "user@example.com")
-        self.assertFalse(test_user.has_usable_password())
-
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        response = self.client.get(test_link)
-        self.assertContains(response, "alert-has-unusable-password")
-
-        response = self.client.post(
-            test_link,
-            data={
-                "username": "NewUsername",
-                "rank": str(test_user.rank_id),
-                "roles": str(test_user.roles.all()[0].pk),
-                "email": "edited@example.com",
-                "is_staff": "1",
-                "is_superuser": "0",
-                "signature": "Hello world!",
-                "is_signature_locked": "1",
-                "is_hiding_presence": "0",
-                "limits_private_thread_invites_to": "0",
-                "signature_lock_staff_message": "Staff message",
-                "signature_lock_user_message": "User message",
-                "subscribe_to_started_threads": "2",
-                "subscribe_to_replied_threads": "2",
-                "is_active": "1",
-            },
-        )
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = User.objects.get(pk=test_user.pk)
-        self.assertFalse(updated_user.has_usable_password())
-
-    def test_edit_agreements_list(self):
-        """edit view displays list of user's agreements"""
-        test_user = create_test_user("User", "user@example.com")
-        test_link = reverse(
-            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
-        )
-
-        agreement = Agreement.objects.create(
-            type=Agreement.TYPE_TOS,
-            title="Test agreement!",
-            text="Lorem ipsum!",
-            is_active=True,
-        )
-
-        response = self.client.get(test_link)
-        self.assertEqual(response.status_code, 200)
-        self.assertNotContains(response, agreement.title)
-
-        save_user_agreement_acceptance(test_user, agreement, commit=True)
-
-        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"])