Просмотр исходного кода

Merge pull request #1198 from rafalp/fakers-rewrite

Cleanup fake data generators
Rafał Pitoń 6 лет назад
Родитель
Сommit
1974dacb62
31 измененных файлов с 1417 добавлено и 331 удалено
  1. 10 0
      dev
  2. 11 0
      misago/conftest.py
  3. 94 0
      misago/faker/bans.py
  4. 53 0
      misago/faker/categories.py
  5. 1 1
      misago/faker/englishcorpus.py
  6. 6 84
      misago/faker/management/commands/createfakebans.py
  7. 18 30
      misago/faker/management/commands/createfakecategories.py
  8. 17 15
      misago/faker/management/commands/createfakefollowers.py
  9. 272 0
      misago/faker/management/commands/createfakehistory.py
  10. 76 0
      misago/faker/management/commands/createfakeposts.py
  11. 36 138
      misago/faker/management/commands/createfakethreads.py
  12. 18 32
      misago/faker/management/commands/createfakeusers.py
  13. 87 0
      misago/faker/posts.py
  14. 7 0
      misago/faker/tests/conftest.py
  15. 18 0
      misago/faker/tests/test_create_fake_bans_command.py
  16. 51 0
      misago/faker/tests/test_create_fake_categories_command.py
  17. 29 0
      misago/faker/tests/test_create_fake_followers_command.py
  18. 154 0
      misago/faker/tests/test_create_fake_history_command.py
  19. 13 0
      misago/faker/tests/test_create_fake_posts_command.py
  20. 11 0
      misago/faker/tests/test_create_fake_threads_command.py
  21. 13 0
      misago/faker/tests/test_create_fake_users_command.py
  22. 23 28
      misago/faker/tests/test_englishcorpus.py
  23. 17 0
      misago/faker/tests/test_fake_bans.py
  24. 46 0
      misago/faker/tests/test_fake_categories.py
  25. 74 0
      misago/faker/tests/test_fake_posts.py
  26. 55 0
      misago/faker/tests/test_fake_threads.py
  27. 66 0
      misago/faker/tests/test_fake_users.py
  28. 55 0
      misago/faker/threads.py
  29. 71 0
      misago/faker/users.py
  30. 12 0
      misago/faker/utils.py
  31. 3 3
      misago/users/models/user.py

+ 10 - 0
dev

@@ -90,6 +90,7 @@ intro() {
     echo "    ${BOLD}run${NORMAL}               runs \"docker-compose run --rm misago\"."
     echo "    ${BOLD}psql${NORMAL}              runs psql connected to development database."
     echo "    ${BOLD}pyfmt${NORMAL}             runs isort + black on python code."
+    echo "    ${BOLD}fakedata${NORMAL}          populates database with testing data."
     echo
 }
 
@@ -263,6 +264,13 @@ psql_in_docker() {
     PGPASSWORD=$POSTGRES_PASSWORD psql --username $POSTGRES_USER --host $POSTGRES_HOST $POSTGRES_DB
 }
 
+# Shortcut for creating small dev forum
+create_fake_data() {
+    docker-compose run --rm misago python manage.py createfakecategories 7
+    docker-compose run --rm misago python manage.py createfakecategories 12 1
+    docker-compose run --rm misago python manage.py createfakehistory 600
+}
+
 # Command dispatcher
 if [[ $1 ]]; then
     if [[ $1 = "init" ]]; then
@@ -309,6 +317,8 @@ if [[ $1 ]]; then
     elif [[ $1 = "pyfmt" ]]; then
         isort -rc misago
         black devproject misago
+    elif [[ $1 = "fakedata" ]]; then
+        create_fake_data
     else
         invalid_argument $1
     fi

+ 11 - 0
misago/conftest.py

@@ -2,6 +2,7 @@ import pytest
 
 from .acl import ACL_CACHE, useracl
 from .admin.auth import authorize_admin
+from .categories.models import Category
 from .conf import SETTINGS_CACHE
 from .conf.dynamicsettings import DynamicSettings
 from .conf.staticsettings import StaticSettings
@@ -95,3 +96,13 @@ def admin_client(mocker, client, superuser):
     authorize_admin(mocker.Mock(session=session, user=superuser))
     session.save()
     return client
+
+
+@pytest.fixture
+def root_category(db):
+    return Category.objects.root_category()
+
+
+@pytest.fixture
+def default_category(db):
+    return Category.objects.get(slug="first-category")

+ 94 - 0
misago/faker/bans.py

@@ -0,0 +1,94 @@
+import random
+from datetime import timedelta
+
+from django.utils import timezone
+
+from ..users.models import Ban
+
+
+def get_fake_username_ban(fake):
+    ban = _create_base_ban(fake, Ban.USERNAME)
+
+    banned_value = fake.first_name()
+    if random.randint(0, 100) < 31:
+        banned_value = "%s*" % banned_value
+    elif random.randint(0, 100) < 31:
+        banned_value = "*%s" % banned_value
+    elif random.randint(0, 100) < 31:
+        banned_value = list(banned_value)
+        banned_value.insert(random.randint(0, len(banned_value) - 1), "*")
+        banned_value = "".join(banned_value)
+
+    ban.banned_value = banned_value
+    ban.save()
+    return ban
+
+
+def get_fake_email_ban(fake):
+    ban = _create_base_ban(fake, Ban.EMAIL)
+
+    if random.randint(0, 100) < 35:
+        ban.banned_value = "*@%s" % fake.domain_name()
+    else:
+        ban.banned_value = fake.email()
+
+    ban.save()
+    return ban
+
+
+def get_fake_ip_ban(fake):
+    ban = _create_base_ban(fake, Ban.IP)
+
+    if random.randint(0, 1):
+        banned_value = fake.ipv4()
+        if random.randint(0, 100) < 35:
+            banned_value = banned_value.split(".")
+            banned_value = ".".join(banned_value[: random.randint(1, 3)])
+            banned_value = "%s.*" % banned_value
+        elif random.randint(0, 100) < 35:
+            banned_value = banned_value.split(".")
+            banned_value = ".".join(banned_value[random.randint(1, 3) :])
+            banned_value = "*.%s" % banned_value
+        elif random.randint(0, 100) < 35:
+            banned_value = banned_value.split(".")
+            banned_value[random.randint(0, 3)] = "*"
+            banned_value = ".".join(banned_value)
+    else:
+        banned_value = fake.ipv6()
+
+        if random.randint(0, 100) < 35:
+            banned_value = banned_value.split(":")
+            banned_value = ":".join(banned_value[: random.randint(1, 7)])
+            banned_value = "%s:*" % banned_value
+        elif random.randint(0, 100) < 35:
+            banned_value = banned_value.split(":")
+            banned_value = ":".join(banned_value[: random.randint(1, 7)])
+            banned_value = "*:%s" % banned_value
+        elif random.randint(0, 100) < 35:
+            banned_value = banned_value.split(":")
+            banned_value[random.randint(0, 7)] = "*"
+            banned_value = ":".join(banned_value)
+
+    ban.banned_value = banned_value
+    ban.save()
+    return ban
+
+
+def _create_base_ban(fake, ban_type):
+    ban = Ban(check_type=ban_type)
+
+    if random.randint(0, 10) == 0:
+        ban.user_message = fake.sentence()
+
+    if random.randint(0, 10) == 0:
+        ban.staff_message = fake.sentence()
+
+    if random.randint(0, 1):
+        # Lets make ban temporary
+        ban_length = timedelta(days=random.randint(0, 300))
+        if random.randint(0, 1):
+            ban.valid_until = timezone.now().date() - ban_length
+        else:
+            ban.valid_until = timezone.now().date() + ban_length
+
+    return ban

+ 53 - 0
misago/faker/categories.py

@@ -0,0 +1,53 @@
+import random
+
+from ..categories.models import Category, RoleCategoryACL
+
+
+def fake_category(fake, parent, copy_acl_from=None):
+    category = Category()
+    category.set_name(fake_category_name(fake))
+
+    if random.randint(1, 100) > 50:
+        category.description = fake_category_description(fake)
+
+    category.insert_at(parent, position="last-child", save=True)
+
+    if copy_acl_from:
+        copy_acl_to_fake_category(copy_acl_from, category)
+
+    return category
+
+
+def fake_closed_category(fake, parent, copy_acl_from=None):
+    category = fake_category(fake, parent, copy_acl_from)
+    category.is_closed = True
+    category.save(update_fields=["is_closed"])
+
+    return category
+
+
+def copy_acl_to_fake_category(source, category):
+    copied_acls = []
+    for acl in source.category_role_set.all():
+        copied_acls.append(
+            RoleCategoryACL(
+                role_id=acl.role_id,
+                category=category,
+                category_role_id=acl.category_role_id,
+            )
+        )
+
+    if copied_acls:
+        RoleCategoryACL.objects.bulk_create(copied_acls)
+
+
+def fake_category_name(fake):
+    if random.randint(1, 100) > 75:
+        return fake.catch_phrase().title()
+    return fake.street_name()
+
+
+def fake_category_description(fake):
+    if random.randint(1, 100) > 80:
+        return "\r\n".join(fake.paragraphs())
+    return fake.paragraph()

+ 1 - 1
misago/faker/englishcorpus.py

@@ -30,7 +30,7 @@ class EnglishCorpus:
     def shuffle(self):
         random.shuffle(self.phrases)
 
-    def random_choice(self):
+    def random_sentence(self):
         self._countdown_to_shuffle()
 
         choice = None

+ 6 - 84
misago/faker/management/commands/createfakebans.py

@@ -1,77 +1,12 @@
 import random
 import sys
-from datetime import timedelta
 
 from django.core.management.base import BaseCommand
-from django.utils import timezone
 from faker import Factory
 
 from ....core.management.progressbar import show_progress
 from ....users.models import Ban
-
-
-def fake_username_ban(fake):
-    fake_value = fake.first_name()
-
-    if random.randint(0, 100) < 31:
-        fake_value = "%s*" % fake_value
-    elif random.randint(0, 100) < 31:
-        fake_value = "*%s" % fake_value
-    elif random.randint(0, 100) < 31:
-        fake_value = list(fake_value)
-        fake_value.insert(random.randint(0, len(fake_value) - 1), "*")
-        fake_value = "".join(fake_value)
-
-    return fake_value
-
-
-def fake_email_ban(fake):
-    if random.randint(0, 100) < 35:
-        return "*@%s" % fake.domain_name()
-    return fake.email()
-
-
-def fake_ip_ban(fake):
-    if random.randint(0, 1):
-        fake_value = fake.ipv4()
-        if random.randint(0, 100) < 35:
-            fake_value = fake_value.split(".")
-            fake_value = ".".join(fake_value[: random.randint(1, 3)])
-            fake_value = "%s.*" % fake_value
-        elif random.randint(0, 100) < 35:
-            fake_value = fake_value.split(".")
-            fake_value = ".".join(fake_value[random.randint(1, 3) :])
-            fake_value = "*.%s" % fake_value
-        elif random.randint(0, 100) < 35:
-            fake_value = fake_value.split(".")
-            fake_value[random.randint(0, 3)] = "*"
-            fake_value = ".".join(fake_value)
-    else:
-        fake_value = fake.ipv6()
-
-        if random.randint(0, 100) < 35:
-            fake_value = fake_value.split(":")
-            fake_value = ":".join(fake_value[: random.randint(1, 7)])
-            fake_value = "%s:*" % fake_value
-        elif random.randint(0, 100) < 35:
-            fake_value = fake_value.split(":")
-            fake_value = ":".join(fake_value[: random.randint(1, 7)])
-            fake_value = "*:%s" % fake_value
-        elif random.randint(0, 100) < 35:
-            fake_value = fake_value.split(":")
-            fake_value[random.randint(0, 7)] = "*"
-            fake_value = ":".join(fake_value)
-
-    return fake_value
-
-
-def create_fake_test(fake, test_type):
-    if test_type == Ban.USERNAME:
-        return fake_username_ban(fake)
-    if test_type == Ban.EMAIL:
-        return fake_email_ban(fake)
-    if test_type == Ban.IP:
-        return fake_ip_ban(fake)
+from ...bans import get_fake_username_ban, get_fake_email_ban, get_fake_ip_ban
 
 
 class Command(BaseCommand):
@@ -87,6 +22,7 @@ class Command(BaseCommand):
             sys.exit(1)
 
         fake = Factory.create()
+        ban_fakers = (get_fake_username_ban, get_fake_email_ban, get_fake_ip_ban)
 
         message = "Creating %s fake bans...\n"
         self.stdout.write(message % fake_bans_to_create)
@@ -94,27 +30,13 @@ class Command(BaseCommand):
         created_count = 0
         show_progress(self, created_count, fake_bans_to_create)
         for _ in range(fake_bans_to_create):
-            ban = Ban(check_type=random.randint(Ban.USERNAME, Ban.IP))
-            ban.banned_value = create_fake_test(fake, ban.check_type)
-
-            if random.randint(0, 10) == 0:
-                ban.user_message = fake.sentence()
-
-            if random.randint(0, 10) == 0:
-                ban.staff_message = fake.sentence()
-
-            if random.randint(0, 1):
-                # Lets make ban temporary
-                ban_length = timedelta(days=random.randint(0, 300))
-                if random.randint(0, 1):
-                    ban.valid_until = timezone.now().date() - ban_length
-                else:
-                    ban.valid_until = timezone.now().date() + ban_length
-
-            ban.save()
+            ban_faker = random.choice(ban_fakers)
+            ban_faker(fake)
 
             created_count += 1
             show_progress(self, created_count, fake_bans_to_create)
 
+        Ban.objects.invalidate_cache()
+
         message = "\n\nSuccessfully created %s fake bans"
         self.stdout.write(message % created_count)

+ 18 - 30
misago/faker/management/commands/createfakecategories.py

@@ -5,8 +5,9 @@ from django.core.management.base import BaseCommand
 from faker import Factory
 
 from ....acl.cache import clear_acl_cache
-from ....categories.models import Category, RoleCategoryACL
+from ....categories.models import Category
 from ....core.management.progressbar import show_progress
+from ...categories import fake_category, fake_closed_category
 
 
 class Command(BaseCommand):
@@ -33,12 +34,16 @@ class Command(BaseCommand):
         items_to_create = options["categories"]
         min_level = options["minlevel"]
 
-        categories = Category.objects.all_categories(True)
+        fake = Factory.create()
 
-        copy_acl_from = list(Category.objects.all_categories())[0]
+        categories = Category.objects.all_categories(include_root=True).filter(
+            level__gte=min_level
+        )
+        acl_source = list(Category.objects.all_categories())[0]
 
-        categories = categories.filter(level__gte=min_level)
-        fake = Factory.create()
+        if not categories.exists():
+            self.stdout.write("No valid parent categories exist.\n")
+            return
 
         message = "Creating %s fake categories...\n"
         self.stdout.write(message % items_to_create)
@@ -48,34 +53,17 @@ class Command(BaseCommand):
         show_progress(self, created_count, items_to_create)
 
         while created_count < items_to_create:
+            categories = (
+                Category.objects.all_categories(include_root=True)
+                .filter(level__gte=min_level)
+                .order_by("?")
+            )
             parent = random.choice(categories)
 
-            new_category = Category()
-            if random.randint(1, 100) > 75:
-                new_category.set_name(fake.catch_phrase().title())
+            if random.randint(0, 100) > 90:
+                fake_closed_category(fake, parent, copy_acl_from=acl_source)
             else:
-                new_category.set_name(fake.street_name())
-
-            if random.randint(1, 100) > 50:
-                if random.randint(1, 100) > 80:
-                    new_category.description = "\r\n".join(fake.paragraphs())
-                else:
-                    new_category.description = fake.paragraph()
-
-            new_category.insert_at(parent, position="last-child", save=True)
-
-            copied_acls = []
-            for acl in copy_acl_from.category_role_set.all():
-                copied_acls.append(
-                    RoleCategoryACL(
-                        role_id=acl.role_id,
-                        category=new_category,
-                        category_role_id=acl.category_role_id,
-                    )
-                )
-
-            if copied_acls:
-                RoleCategoryACL.objects.bulk_create(copied_acls)
+                fake_category(fake, parent, copy_acl_from=acl_source)
 
             created_count += 1
             show_progress(self, created_count, items_to_create, start_time)

+ 17 - 15
misago/faker/management/commands/createfakefollowers.py

@@ -14,6 +14,12 @@ class Command(BaseCommand):
 
     def handle(self, *args, **options):
         total_users = User.objects.count()
+        if total_users < 2:
+            self.stderr.write(
+                "At least two users must exist in the databse in order for "
+                "fake followers creation to be possible.\n"
+            )
+            return
 
         message = "Adding fake followers to %s users...\n"
         self.stdout.write(message % total_users)
@@ -25,25 +31,21 @@ class Command(BaseCommand):
         show_progress(self, processed_count, total_users)
         for user in User.objects.iterator():
             user.followed_by.clear()
-
-            if random.randint(1, 100) > 10:
-                processed_count += 1
-                show_progress(self, processed_count, total_users)
-                continue  # 10% active users
-
-            users_to_add = random.randint(1, total_users / 5)
-            random_queryset = User.objects.exclude(id=user.id).order_by("?")
-            while users_to_add > 0:
-                new_follower = random_queryset[:1][0]
-                if not new_follower.is_following(user):
-                    user.followed_by.add(new_follower)
-                    users_to_add -= 1
-                    total_followers += 1
+            followers_to_create = random.randint(0, total_users - 1)
+            while followers_to_create:
+                # There's 34% chance we'll skip follower creation
+                if random.randint(0, 100) > 34:
+                    new_follower = (
+                        User.objects.exclude(pk=user.pk).order_by("?")[:1].first()
+                    )
+                    if not user.is_following(new_follower):
+                        user.follows.add(new_follower)
+                followers_to_create -= 1
 
             processed_count += 1
             show_progress(self, processed_count, total_users)
 
-        self.stdout.write("\nSyncing models...")
+        self.stdout.write("\nSynchronizing users...")
         for user in User.objects.iterator():
             user.followers = user.followed_by.count()
             user.following = user.follows.count()

+ 272 - 0
misago/faker/management/commands/createfakehistory.py

@@ -0,0 +1,272 @@
+import random
+import time
+from datetime import timedelta
+
+from django.contrib.auth import get_user_model
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+from faker import Factory
+
+from ....categories.models import Category
+from ....core.pgutils import chunk_queryset
+from ....threads.checksums import update_post_checksum
+from ....threads.models import Thread
+from ....users.models import Rank
+from ...posts import get_fake_hidden_post, get_fake_post, get_fake_unapproved_post
+from ...threads import (
+    get_fake_closed_thread,
+    get_fake_hidden_thread,
+    get_fake_thread,
+    get_fake_unapproved_thread,
+)
+from ...users import (
+    get_fake_admin_activated_user,
+    get_fake_banned_user,
+    get_fake_deleted_user,
+    get_fake_inactive_user,
+    get_fake_user,
+)
+
+User = get_user_model()
+
+
+class Command(BaseCommand):
+    help = "Creates fake forum history reaching specified period."
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "length",
+            help="generated history length (in days)",
+            nargs="?",
+            type=int,
+            default=5,
+        )
+        parser.add_argument(
+            "max_actions",
+            help="number of items generate for a single day",
+            nargs="?",
+            type=int,
+            default=50,
+        )
+
+    def handle(self, *args, **options):  # pylint: disable=too-many-locals
+        history_length = options["length"]
+        max_actions = options["max_actions"]
+        fake = Factory.create()
+
+        categories = list(Category.objects.all_categories())
+        ranks = list(Rank.objects.all())
+
+        message = "Creating fake forum history for %s days...\n"
+        self.stdout.write(message % history_length)
+
+        start_time = time.time()
+
+        self.move_existing_users_to_past(history_length)
+
+        start_timestamp = timezone.now()
+        for days_ago in reversed(range(history_length)):
+            date = start_timestamp - timedelta(days=days_ago)
+            for date_variation in get_random_date_variations(date, 0, max_actions):
+                action = random.randint(0, 100)
+                if action >= 80:
+                    self.create_fake_user(fake, date_variation, ranks)
+                elif action > 50:
+                    self.create_fake_thread(fake, date_variation, categories)
+                else:
+                    self.create_fake_post(fake, date_variation)
+
+                if random.randint(0, 100) > 80:
+                    self.create_fake_follow(date)
+
+        self.synchronize_threads()
+        self.synchronize_categories()
+
+        total_time = time.time() - start_time
+        total_humanized = time.strftime("%H:%M:%S", time.gmtime(total_time))
+        message = "\n\nSuccessfully created fake history for %s days in %s"
+        self.stdout.write(message % (history_length, total_humanized))
+
+    def move_existing_users_to_past(self, history_length):
+        for user in User.objects.all():
+            user.joined_on -= timedelta(days=history_length)
+            user.save(update_fields=["joined_on"])
+            user.audittrail_set.all().delete()
+
+    def create_fake_user(self, fake, date, ranks):
+        # Pick random rank for next user
+        rank = random.choice(ranks)
+
+        # There's 10% chance user is inactive
+        if random.randint(0, 100) > 90:
+            user = get_fake_inactive_user(fake, rank)
+
+        # There's another 10% chance user is admin-activated
+        elif random.randint(0, 100) > 90:
+            user = get_fake_admin_activated_user(fake, rank)
+
+        # And further chance user is banned
+        elif random.randint(0, 100) > 90:
+            user = get_fake_banned_user(fake, rank)
+
+        # Or deleted their account
+        elif random.randint(0, 100) > 90:
+            user = get_fake_deleted_user(fake, rank)
+
+        # User is active
+        else:
+            user = get_fake_user(fake, rank)
+
+        user.joined_on = date
+        user.save(update_fields=["joined_on"])
+        user.audittrail_set.all().delete()
+
+        self.write_event(date, "%s has joined" % user)
+
+    def create_fake_thread(self, fake, date, categories):
+        category = random.choice(categories)
+
+        # 10% chance thread poster is anonymous
+        if random.randint(0, 100) > 90:
+            starter = None
+        else:
+            starter = self.get_random_user(date)
+
+        # There's 10% chance thread is closed
+        if random.randint(0, 100) > 90:
+            thread = get_fake_closed_thread(fake, category, starter)
+
+        # There's further 5% chance thread is hidden
+        elif random.randint(0, 100) > 95:
+            if random.randint(0, 100) > 90:
+                hidden_by = None
+            else:
+                hidden_by = self.get_random_user(date)
+
+            thread = get_fake_hidden_thread(fake, category, starter, hidden_by)
+
+        # And further 5% chance thread is unapproved
+        elif random.randint(0, 100) > 95:
+            thread = get_fake_unapproved_thread(fake, category, starter)
+
+        # Default, standard thread
+        else:
+            thread = get_fake_thread(fake, category, starter)
+
+        thread.first_post.posted_on = date
+        thread.first_post.updated_on = date
+        thread.first_post.checksum = update_post_checksum(thread.first_post)
+        thread.first_post.save(update_fields=["checksum", "posted_on", "updated_on"])
+
+        thread.started_on = date
+        thread.save(update_fields=["started_on"])
+
+        self.write_event(
+            date, '%s has started "%s" thread' % (thread.first_post.poster_name, thread)
+        )
+
+    def create_fake_post(self, fake, date):
+        thread = self.get_random_thread(date)
+        if not thread:
+            return
+
+        # 10% chance poster is anonymous
+        if random.randint(0, 100) > 90:
+            poster = None
+        else:
+            poster = self.get_random_user(date)
+
+        # There's 5% chance post is unapproved
+        if random.randint(0, 100) > 90:
+            post = get_fake_unapproved_post(fake, thread, poster)
+
+        # There's further 5% chance post is hidden
+        elif random.randint(0, 100) > 95:
+            if random.randint(0, 100) > 90:
+                hidden_by = None
+            else:
+                hidden_by = self.get_random_user(date)
+
+            post = get_fake_hidden_post(fake, thread, poster, hidden_by)
+
+        # Default, standard post
+        else:
+            post = get_fake_post(fake, thread, poster)
+
+        post.posted_on = date
+        post.updated_on = date
+        post.checksum = update_post_checksum(post)
+        post.save(update_fields=["checksum", "posted_on", "updated_on"])
+
+        self.write_event(
+            date, '%s has replied to "%s" thread' % (post.poster_name, thread)
+        )
+
+    def create_fake_follow(self, date):
+        user_a = self.get_random_user(date)
+        user_b = self.get_random_user(date)
+
+        if not (user_a or user_b) or user_a == user_b:
+            return
+
+        if not user_a.is_following(user_b):
+            user_a.follows.add(user_b)
+
+        self.write_event(date, "%s followed %s" % (user_a, user_b))
+
+    def get_random_thread(self, date):
+        return (
+            Thread.objects.filter(started_on__lt=date)
+            .select_related("category")
+            .order_by("?")
+            .first()
+        )
+
+    def get_random_user(self, date):
+        return (
+            User.objects.filter(
+                joined_on__lt=date, requires_activation=User.ACTIVATION_NONE
+            )
+            .order_by("?")
+            .first()
+        )
+
+    def write_event(self, date, event):
+        formatted_date = date.strftime("%Y-%m-%d %H:%M")
+        self.stdout.write("%s: %s" % (formatted_date, event))
+
+    def synchronize_threads(self):
+        self.stdout.write("\nSynchronizing threads...")
+        start_time = time.time()
+
+        for thread in chunk_queryset(Thread.objects.all()):
+            thread.synchronize()
+            thread.save()
+
+        total_time = time.time() - start_time
+        total_humanized = time.strftime("%H:%M:%S", time.gmtime(total_time))
+
+        message = "Synchronized %s threads in %s"
+        self.stdout.write(message % (Thread.objects.count(), total_humanized))
+
+    def synchronize_categories(self):
+        self.stdout.write("\nSynchronizing categories...")
+        start_time = time.time()
+
+        for category in Category.objects.all_categories():
+            category.synchronize()
+            category.save()
+
+        total_time = time.time() - start_time
+        total_humanized = time.strftime("%H:%M:%S", time.gmtime(total_time))
+
+        message = "Synchronized %s categories in %s"
+        self.stdout.write(message % (Category.objects.count(), total_humanized))
+
+
+def get_random_date_variations(date, min_date, max_date):
+    variations = []
+    for _ in range(random.randint(min_date, max_date)):
+        random_offset = timedelta(minutes=random.randint(1, 1200))
+        variations.append(date - random_offset)
+    return sorted(variations)

+ 76 - 0
misago/faker/management/commands/createfakeposts.py

@@ -0,0 +1,76 @@
+import random
+import time
+
+from django.contrib.auth import get_user_model
+from django.core.management.base import BaseCommand
+from faker import Factory
+
+from ....categories.models import Category
+from ....core.management.progressbar import show_progress
+from ....core.pgutils import chunk_queryset
+from ....threads.models import Thread
+from ...posts import get_fake_hidden_post, get_fake_post, get_fake_unapproved_post
+
+User = get_user_model()
+
+
+class Command(BaseCommand):
+    help = "Creates random posts for dev and testing purposes."
+
+    def add_arguments(self, parser):
+        parser.add_argument(
+            "posts", help="number of posts to create", nargs="?", type=int, default=5
+        )
+
+    def handle(self, *args, **options):
+        items_to_create = options["posts"]
+        fake = Factory.create()
+
+        message = "Creating %s fake posts...\n"
+        self.stdout.write(message % items_to_create)
+
+        created_posts = 0
+        start_time = time.time()
+        show_progress(self, created_posts, items_to_create)
+
+        while created_posts < items_to_create:
+            thread = Thread.objects.order_by("?")[:1].first()
+
+            # 10% chance poster is anonymous
+            if random.randint(0, 100) > 90:
+                poster = None
+            else:
+                poster = User.objects.order_by("?")[:1].last()
+
+            # There's 5% chance post is unapproved
+            if random.randint(0, 100) > 90:
+                get_fake_unapproved_post(fake, thread, poster)
+
+            # There's further 5% chance post is hidden
+            elif random.randint(0, 100) > 95:
+                if random.randint(0, 100) > 90:
+                    hidden_by = None
+                else:
+                    hidden_by = User.objects.order_by("?")[:1].last()
+
+                get_fake_hidden_post(fake, thread, poster, hidden_by)
+
+            # Default, standard post
+            else:
+                get_fake_post(fake, thread, poster)
+
+            created_posts += 1
+            show_progress(self, created_posts, items_to_create, start_time)
+
+        for thread in chunk_queryset(Thread.objects.all()):
+            thread.synchronize()
+            thread.save()
+
+        for category in Category.objects.all():
+            category.synchronize()
+            category.save()
+
+        total_time = time.time() - start_time
+        total_humanized = time.strftime("%H:%M:%S", time.gmtime(total_time))
+        message = "\nSuccessfully created %s fake posts in %s"
+        self.stdout.write(message % (created_posts, total_humanized))

+ 36 - 138
misago/faker/management/commands/createfakethreads.py

@@ -3,23 +3,20 @@ import time
 
 from django.contrib.auth import get_user_model
 from django.core.management.base import BaseCommand
-from django.db.transaction import atomic
-from django.utils import timezone
 from faker import Factory
 
 from ....categories.models import Category
 from ....core.management.progressbar import show_progress
-from ....threads.checksums import update_post_checksum
-from ....threads.models import Post, Thread
-from ...englishcorpus import EnglishCorpus
-
-PLACEKITTEN_URL = "https://placekitten.com/g/%s/%s"
+from ....threads.models import Thread
+from ...threads import (
+    get_fake_closed_thread,
+    get_fake_hidden_thread,
+    get_fake_thread,
+    get_fake_unapproved_thread,
+)
 
 User = get_user_model()
 
-corpus = EnglishCorpus()
-corpus_short = EnglishCorpus(max_length=150)
-
 
 class Command(BaseCommand):
     help = "Creates random threads for dev and testing purposes."
@@ -35,13 +32,12 @@ class Command(BaseCommand):
 
     def handle(
         self, *args, **options
-    ):  # pylint: disable=too-many-branches, too-many-locals
+    ):  # pylint: disable=too-many-locals, too-many-branches
         items_to_create = options["threads"]
+        fake = Factory.create()
 
         categories = list(Category.objects.all_categories())
 
-        fake = Factory.create()
-
         message = "Creating %s fake threads...\n"
         self.stdout.write(message % items_to_create)
 
@@ -50,117 +46,47 @@ class Command(BaseCommand):
         show_progress(self, created_threads, items_to_create)
 
         while created_threads < items_to_create:
-            with atomic():
-                datetime = timezone.now()
-                category = random.choice(categories)
-                user = User.objects.order_by("?")[:1][0]
-
-                thread_is_unapproved = random.randint(0, 100) > 90
-                thread_is_hidden = random.randint(0, 100) > 90
-                thread_is_closed = random.randint(0, 100) > 90
-
-                thread = Thread(
-                    category=category,
-                    started_on=datetime,
-                    starter_name="-",
-                    starter_slug="-",
-                    last_post_on=datetime,
-                    last_poster_name="-",
-                    last_poster_slug="-",
-                    replies=0,
-                    is_unapproved=thread_is_unapproved,
-                    is_hidden=thread_is_hidden,
-                    is_closed=thread_is_closed,
-                )
-                thread.set_title(corpus_short.random_choice())
-                thread.save()
-
-                original, parsed = self.fake_post_content()
-
-                post = Post.objects.create(
-                    category=category,
-                    thread=thread,
-                    poster=user,
-                    poster_name=user.username,
-                    original=original,
-                    parsed=parsed,
-                    posted_on=datetime,
-                    updated_on=datetime,
-                )
-                update_post_checksum(post)
-                post.save(update_fields=["checksum"])
-
-                thread.set_first_post(post)
-                thread.set_last_post(post)
-                thread.save()
-
-                user.threads += 1
-                user.posts += 1
-                user.save()
-
-                thread_type = random.randint(0, 100)
-                if thread_type > 98:
-                    thread_replies = random.randint(200, 2500)
-                elif thread_type > 50:
-                    thread_replies = random.randint(5, 30)
-                else:
-                    thread_replies = random.randint(0, 10)
+            category = random.choice(categories)
 
-                for _ in range(thread_replies):
-                    datetime = timezone.now()
-                    user = User.objects.order_by("?")[:1][0]
-
-                    original, parsed = self.fake_post_content()
-
-                    is_unapproved = random.randint(0, 100) > 97
-
-                    post = Post.objects.create(
-                        category=category,
-                        thread=thread,
-                        poster=user,
-                        poster_name=user.username,
-                        original=original,
-                        parsed=parsed,
-                        is_unapproved=is_unapproved,
-                        posted_on=datetime,
-                        updated_on=datetime,
-                    )
+            # 10% chance thread poster is anonymous
+            if random.randint(0, 100) > 90:
+                starter = None
+            else:
+                starter = User.objects.order_by("?")[:1].last()
 
-                    if not is_unapproved:
-                        is_hidden = random.randint(0, 100) > 97
-                    else:
-                        is_hidden = False
+            # There's 10% chance thread is closed
+            if random.randint(0, 100) > 90:
+                thread = get_fake_closed_thread(fake, category, starter)
 
-                    if is_hidden:
-                        post.is_hidden = True
+            # There's further 5% chance thread is hidden
+            elif random.randint(0, 100) > 95:
+                if random.randint(0, 100) > 90:
+                    hidden_by = None
+                else:
+                    hidden_by = User.objects.order_by("?")[:1].last()
 
-                        if random.randint(0, 100) < 80:
-                            user = User.objects.order_by("?")[:1][0]
-                            post.hidden_by = user
-                            post.hidden_by_name = user.username
-                            post.hidden_by_slug = user.username
-                        else:
-                            post.hidden_by_name = fake.first_name()
-                            post.hidden_by_slug = post.hidden_by_name.lower()
+                thread = get_fake_hidden_thread(fake, category, starter, hidden_by)
 
-                    update_post_checksum(post)
-                    post.save()
+            # And further 5% chance thread is unapproved
+            elif random.randint(0, 100) > 95:
+                thread = get_fake_unapproved_thread(fake, category, starter)
 
-                    user.posts += 1
-                    user.save()
+            # Default, standard thread
+            else:
+                thread = get_fake_thread(fake, category, starter)
 
-                thread.synchronize()
-                thread.save()
+            thread.synchronize()
+            thread.save()
 
-                created_threads += 1
-                show_progress(self, created_threads, items_to_create, start_time)
+            created_threads += 1
+            show_progress(self, created_threads, items_to_create, start_time)
 
         pinned_threads = random.randint(0, int(created_threads * 0.025)) or 1
         self.stdout.write("\nPinning %s threads..." % pinned_threads)
 
         for _ in range(0, pinned_threads):
             thread = Thread.objects.order_by("?")[:1][0]
-            if random.randint(0, 100) > 75:
+            if random.randint(0, 100) > 90:
                 thread.weight = 2
             else:
                 thread.weight = 1
@@ -174,31 +100,3 @@ class Command(BaseCommand):
         total_humanized = time.strftime("%H:%M:%S", time.gmtime(total_time))
         message = "\nSuccessfully created %s fake threads in %s"
         self.stdout.write(message % (created_threads, total_humanized))
-
-    def fake_post_content(self):
-        raw = []
-        parsed = []
-
-        if random.randint(0, 100) > 80:
-            paragraphs_to_make = random.randint(1, 20)
-        else:
-            paragraphs_to_make = random.randint(1, 5)
-
-        for _ in range(paragraphs_to_make):
-            if random.randint(0, 100) > 95:
-                cat_width = random.randint(1, 16) * random.choice([100, 90, 80])
-                cat_height = random.randint(1, 12) * random.choice([100, 90, 80])
-
-                cat_url = PLACEKITTEN_URL % (cat_width, cat_height)
-
-                raw.append("!(%s)" % cat_url)
-                parsed.append('<p><img src="%s" alt=""/></p>' % cat_url)
-            else:
-                if random.randint(0, 100) > 95:
-                    sentences_to_make = random.randint(1, 20)
-                else:
-                    sentences_to_make = random.randint(1, 7)
-                raw.append(" ".join(corpus.random_sentences(sentences_to_make)))
-                parsed.append("<p>%s</p>" % raw[-1])
-
-        return "\n\n".join(raw), "\n".join(parsed)

+ 18 - 32
misago/faker/management/commands/createfakeusers.py

@@ -1,17 +1,17 @@
 import random
 import time
 
-from django.contrib.auth import get_user_model
-from django.core.exceptions import ValidationError
 from django.core.management.base import BaseCommand
-from django.db import IntegrityError
 from faker import Factory
 
 from ....core.management.progressbar import show_progress
-from ....users.avatars import dynamic, gallery
 from ....users.models import Rank
-
-User = get_user_model()
+from ...users import (
+    get_fake_inactive_user,
+    get_fake_admin_activated_user,
+    get_fake_banned_user,
+    get_fake_user,
+)
 
 
 class Command(BaseCommand):
@@ -26,8 +26,7 @@ class Command(BaseCommand):
         items_to_create = options["users"]
 
         fake = Factory.create()
-
-        ranks = [r for r in Rank.objects.all()]
+        ranks = list(Rank.objects.all())
 
         message = "Creating %s fake user accounts...\n"
         self.stdout.write(message % items_to_create)
@@ -37,31 +36,18 @@ class Command(BaseCommand):
         show_progress(self, created_count, items_to_create)
 
         while created_count < items_to_create:
-            try:
-                possible_usernames = [
-                    fake.first_name(),
-                    fake.last_name(),
-                    fake.name().replace(" ", ""),
-                    fake.user_name(),
-                ]
-
-                user = User.objects.create_user(
-                    random.choice(possible_usernames),
-                    fake.email(),
-                    "pass123",
-                    rank=random.choice(ranks),
-                )
-
-                if random.randint(0, 100) < 80:
-                    gallery.set_random_avatar(user)
-                else:
-                    dynamic.set_avatar(user)
-                user.save()
-            except (ValidationError, IntegrityError):
-                pass
+            rank = random.choice(ranks)
+            if random.randint(0, 100) > 80:
+                get_fake_inactive_user(fake, rank)
+            elif random.randint(0, 100) > 90:
+                get_fake_admin_activated_user(fake, rank)
+            elif random.randint(0, 100) > 90:
+                get_fake_banned_user(fake, rank)
             else:
-                created_count += 1
-                show_progress(self, created_count, items_to_create, start_time)
+                get_fake_user(fake, rank)
+
+            created_count += 1
+            show_progress(self, created_count, items_to_create, start_time)
 
         total_time = time.time() - start_time
         total_humanized = time.strftime("%H:%M:%S", time.gmtime(total_time))

+ 87 - 0
misago/faker/posts.py

@@ -0,0 +1,87 @@
+import random
+
+from django.utils import timezone
+
+from ..threads.checksums import update_post_checksum
+from ..threads.models import Post
+from .englishcorpus import EnglishCorpus
+from .users import get_fake_username
+
+PLACEKITTEN_URL = "https://placekitten.com/g/%s/%s"
+
+corpus = EnglishCorpus()
+
+
+def get_fake_post(fake, thread, poster=None):
+    original, parsed = get_fake_post_content(fake)
+    posted_on = timezone.now()
+
+    post = Post.objects.create(
+        category=thread.category,
+        thread=thread,
+        poster=poster,
+        poster_name=poster.username if poster else get_fake_username(fake),
+        original=original,
+        parsed=parsed,
+        posted_on=posted_on,
+        updated_on=posted_on,
+    )
+    update_post_checksum(post)
+    post.save(update_fields=["checksum"])
+
+    return post
+
+
+def get_fake_unapproved_post(fake, thread, poster=None):
+    post = get_fake_post(fake, thread, poster)
+    post.is_unapproved = True
+    post.save(update_fields=["is_unapproved"])
+    return post
+
+
+def get_fake_hidden_post(fake, thread, poster=None, hidden_by=None):
+    post = get_fake_post(fake, thread, poster)
+    post.is_hidden = True
+
+    if hidden_by:
+        post.hidden_by = hidden_by
+        post.hidden_by_name = hidden_by.username
+        post.hidden_by_slug = hidden_by.slug
+    else:
+        post.hidden_by_name = fake.first_name()
+        post.hidden_by_slug = post.hidden_by_name.lower()
+
+    post.save(
+        update_fields=["is_unapproved", "hidden_by", "hidden_by_name", "hidden_by_slug"]
+    )
+
+    return post
+
+
+def get_fake_post_content(fake):
+    raw = []
+    parsed = []
+
+    if random.randint(0, 100) > 90:
+        paragraphs_to_make = random.randint(1, 20)
+    else:
+        paragraphs_to_make = random.randint(1, 5)
+
+    for _ in range(paragraphs_to_make):
+        if random.randint(0, 100) > 95:
+            cat_width = random.randint(1, 16) * random.choice([100, 90, 80])
+            cat_height = random.randint(1, 12) * random.choice([100, 90, 80])
+
+            cat_url = PLACEKITTEN_URL % (cat_width, cat_height)
+
+            raw.append("!(%s)" % cat_url)
+            parsed.append('<p><img src="%s" alt=""/></p>' % cat_url)
+        else:
+            if random.randint(0, 100) > 95:
+                sentences_to_make = random.randint(1, 20)
+            else:
+                sentences_to_make = random.randint(1, 7)
+            raw.append(" ".join(corpus.random_sentences(sentences_to_make)))
+            parsed.append("<p>%s</p>" % raw[-1])
+
+    return "\n\n".join(raw), "\n".join(parsed)

+ 7 - 0
misago/faker/tests/conftest.py

@@ -0,0 +1,7 @@
+import pytest
+from faker import Factory
+
+
+@pytest.fixture
+def fake():
+    return Factory.create()

+ 18 - 0
misago/faker/tests/test_create_fake_bans_command.py

@@ -0,0 +1,18 @@
+from io import StringIO
+
+from django.core.management import call_command
+
+from ...cache.test import assert_invalidates_cache
+from ...users import BANS_CACHE
+from ...users.models import Ban
+from ..management.commands import createfakebans
+
+
+def test_management_command_creates_fake_bans(db):
+    call_command(createfakebans.Command(), stdout=StringIO())
+    assert Ban.objects.exists()
+
+
+def test_management_command_invalidates_bans_cache(db):
+    with assert_invalidates_cache(BANS_CACHE):
+        call_command(createfakebans.Command(), stdout=StringIO())

+ 51 - 0
misago/faker/tests/test_create_fake_categories_command.py

@@ -0,0 +1,51 @@
+from io import StringIO
+
+from django.core.management import call_command
+
+from ...acl import ACL_CACHE
+from ...cache.test import assert_invalidates_cache
+from ..management.commands import createfakecategories
+
+
+def test_management_command_creates_fake_categories(root_category):
+    call_command(createfakecategories.Command(), categories=5, stdout=StringIO())
+    root_category.refresh_from_db()
+    assert root_category.get_descendant_count() == 6  # 5 fakes + 1 default
+
+
+def test_management_command_updates_categories_tree_after_creation(root_category):
+    call_command(createfakecategories.Command(), categories=5, stdout=StringIO())
+    root_category.refresh_from_db()
+    assert root_category.rght == root_category.lft + 13  # 6 child items
+
+
+def test_management_command_creates_categories_at_minimal_depth(default_category):
+    call_command(
+        createfakecategories.Command(),
+        categories=5,
+        minlevel=default_category.level,
+        stdout=StringIO(),
+    )
+
+    default_category.refresh_from_db()
+    assert default_category.get_descendant_count() == 5
+
+
+def test_management_command_copies_default_category_acl(default_category):
+    call_command(
+        createfakecategories.Command(),
+        categories=5,
+        minlevel=default_category.level,
+        stdout=StringIO(),
+    )
+
+    default_category.refresh_from_db()
+    default_acls_count = default_category.category_role_set.count()
+
+    for fake_category in default_category.get_descendants():
+        assert fake_category.category_role_set.count() == default_acls_count
+
+
+def test_management_command_invalidates_acl_cache(db):
+    with assert_invalidates_cache(ACL_CACHE):
+        call_command(createfakecategories.Command(), categories=5, stdout=StringIO())

+ 29 - 0
misago/faker/tests/test_create_fake_followers_command.py

@@ -0,0 +1,29 @@
+from io import StringIO
+
+from django.core.management import call_command
+
+from ..management.commands import createfakefollowers
+from ..users import get_fake_user
+
+
+def test_management_command_creates_fake_followers_for_two_users(user, other_user):
+    call_command(createfakefollowers.Command(), stdout=StringIO())
+
+
+def test_management_command_creates_fake_followers_for_multiple_users(db, fake):
+    [get_fake_user(fake) for i in range(10)]
+    call_command(createfakefollowers.Command(), stdout=StringIO())
+
+
+def test_management_command_displays_error_if_no_users_exist(db):
+    stderr = StringIO()
+    call_command(createfakefollowers.Command(), stderr=stderr)
+    stderr.seek(0)
+    assert stderr.read()
+
+
+def test_management_command_displays_error_if_only_one_user_exist(user):
+    stderr = StringIO()
+    call_command(createfakefollowers.Command(), stderr=stderr)
+    stderr.seek(0)
+    assert stderr.read()

+ 154 - 0
misago/faker/tests/test_create_fake_history_command.py

@@ -0,0 +1,154 @@
+from datetime import timedelta
+from io import StringIO
+
+import pytest
+from django.contrib.auth import get_user_model
+from django.core.management import call_command
+from django.utils import timezone
+
+from ...categories.models import Category
+from ...threads.models import Post, Thread
+from ...users.models import Rank
+from ..management.commands import createfakehistory
+from ..threads import get_fake_thread
+from ..users import get_fake_admin_activated_user, get_fake_inactive_user, get_fake_user
+
+User = get_user_model()
+
+
+@pytest.fixture
+def command(db):
+    return createfakehistory.Command(stdout=StringIO())
+
+
+@pytest.fixture
+def date():
+    return timezone.now()
+
+
+def test_management_command_has_no_errors(db):
+    call_command(createfakehistory.Command(), max_actions=3, stdout=StringIO())
+
+
+def test_management_command_creates_fake_user(fake, command, date):
+    ranks = list(Rank.objects.all())
+    command.create_fake_user(fake, date, ranks)
+    assert User.objects.exists()
+
+
+def test_fake_user_join_date_is_overridden_by_command(fake, command, date):
+    ranks = list(Rank.objects.all())
+    command.create_fake_user(fake, date, ranks)
+    user = User.objects.order_by("-pk").last()
+    assert user.joined_on == date
+
+
+def test_fake_user_rank_is_one_from_the_choices(fake, command, date):
+    ranks = list(Rank.objects.all())
+    command.create_fake_user(fake, date, ranks)
+    user = User.objects.order_by("-pk").last()
+    assert user.rank in ranks
+
+
+def test_none_is_returned_for_random_user_if_no_users_exist(command, date):
+    user = command.get_random_user(date)
+    assert user is None
+
+
+def test_users_created_after_given_date_are_excluded_from_random_user_pick(
+    command, date, other_user
+):
+    other_user.joined_on = timezone.now()
+    other_user.save()
+
+    user = command.get_random_user(date)
+    assert user is None
+
+
+def test_inactive_users_are_excluded_from_random_user_pick(fake, command):
+    get_fake_admin_activated_user(fake)
+    get_fake_inactive_user(fake)
+
+    user = command.get_random_user(timezone.now())
+    assert user is None
+
+
+def test_random_user_pick_returns_random_user(fake, command):
+    valid_choices = [get_fake_user(fake) for _ in range(5)]
+    user = command.get_random_user(timezone.now())
+    assert user in valid_choices
+
+
+def test_management_command_creates_fake_thread(fake, command, date):
+    categories = list(Category.objects.all_categories())
+    command.create_fake_thread(fake, date, categories)
+    assert Thread.objects.exists()
+
+
+def test_fake_thread_start_date_is_overridden_by_command(fake, command, date):
+    categories = list(Category.objects.all_categories())
+    command.create_fake_thread(fake, date, categories)
+    thread = Thread.objects.last()
+    assert thread.started_on == date
+
+
+def test_fake_thread_was_created_in_one_of_valid_categories(fake, command, date):
+    categories = list(Category.objects.all_categories())
+    command.create_fake_thread(fake, date, categories)
+    thread = Thread.objects.last()
+    assert thread.category in categories
+
+
+def test_none_is_returned_for_random_thread_if_no_threads_exist(command, date):
+    thread = command.get_random_thread(date)
+    assert thread is None
+
+
+def test_threads_created_after_given_date_are_excluded_from_random_thread_pick(
+    fake, command, date, default_category
+):
+    get_fake_thread(fake, default_category)
+    thread = command.get_random_thread(date)
+    assert thread is None
+
+
+def test_random_thread_pick_returns_random_thread(fake, command, default_category):
+    valid_choices = [get_fake_thread(fake, default_category) for _ in range(5)]
+    thread = command.get_random_thread(timezone.now())
+    assert thread in valid_choices
+
+
+def test_management_command_creates_fake_post(fake, command, default_category):
+    thread = get_fake_thread(fake, default_category)
+    command.create_fake_post(fake, timezone.now())
+    assert thread.post_set.count() == 2
+
+
+def test_fake_post_creation_date_is_overridden_by_command(
+    fake, command, date, default_category
+):
+    thread = get_fake_thread(fake, default_category)
+    thread.started_on -= timedelta(days=1)
+    thread.save()
+
+    command.create_fake_post(fake, date)
+    post = thread.post_set.last()
+    assert post.posted_on == date
+
+
+def test_fake_post_is_not_created_if_no_threads_exist(fake, command, date):
+    command.create_fake_post(fake, date)
+    assert not Post.objects.exists()
+
+
+def test_management_command_synchronizes_threads(fake, command, date, default_category):
+    command.create_fake_thread(fake, date, [default_category])
+    command.synchronize_threads()
+
+
+def test_management_command_synchronizes_categories(
+    fake, command, date, default_category
+):
+    command.create_fake_thread(fake, date, [default_category])
+    command.synchronize_threads()
+    command.synchronize_categories()

+ 13 - 0
misago/faker/tests/test_create_fake_posts_command.py

@@ -0,0 +1,13 @@
+from io import StringIO
+
+from django.core.management import call_command
+
+from ...threads.models import Post
+from ..management.commands import createfakeposts
+from ..threads import get_fake_thread
+
+
+def test_management_command_creates_fake_threads(fake, default_category):
+    thread = get_fake_thread(fake, default_category)
+    call_command(createfakeposts.Command(), stdout=StringIO())
+    assert Post.objects.exclude(pk=thread.first_post.pk).exists()

+ 11 - 0
misago/faker/tests/test_create_fake_threads_command.py

@@ -0,0 +1,11 @@
+from io import StringIO
+
+from django.core.management import call_command
+
+from ...threads.models import Thread
+from ..management.commands import createfakethreads
+
+
+def test_management_command_creates_fake_threads(db):
+    call_command(createfakethreads.Command(), stdout=StringIO())
+    assert Thread.objects.exists()

+ 13 - 0
misago/faker/tests/test_create_fake_users_command.py

@@ -0,0 +1,13 @@
+from io import StringIO
+
+from django.contrib.auth import get_user_model
+from django.core.management import call_command
+
+from ..management.commands import createfakeusers
+
+User = get_user_model()
+
+
+def test_management_command_creates_fake_users(db):
+    call_command(createfakeusers.Command(), stdout=StringIO())
+    assert User.objects.exists()

+ 23 - 28
misago/faker/tests/test_englishcorpus.py

@@ -1,37 +1,32 @@
-from django.test import TestCase
-
 from ..englishcorpus import EnglishCorpus
 
 
-class EnglishCorpusTests(TestCase):
-    def test_corpus_has_length(self):
-        """corpus returns length"""
-        corpus = EnglishCorpus()
-        self.assertTrue(len(corpus))
+def test_corpus_has_length():
+    corpus = EnglishCorpus()
+    assert corpus
+
+
+def test_corpus_can_be_shuffled():
+    corpus = EnglishCorpus()
+    corpus.shuffle()
+
+
+def test_corpus_can_be_limited_to_phrases_shorter_than_specified():
+    corpus = EnglishCorpus(max_length=100)
+    assert corpus
 
-    def test_corpus_can_be_shuffled(self):
-        """corpus returns length"""
-        corpus = EnglishCorpus()
-        corpus.shuffle()
 
-    def test_shorter_than_100(self):
-        """corpus returns phrases shorter than 100"""
-        corpus = EnglishCorpus(max_length=100)
-        self.assertTrue(len(corpus))
+def test_corpus_can_be_limited_to_phrases_longer_than_specified():
+    corpus = EnglishCorpus(min_length=100)
+    assert corpus
 
-    def test_longer_than_150(self):
-        """corpus returns phrases longer than 150"""
-        corpus = EnglishCorpus(min_length=100)
-        self.assertTrue(len(corpus))
 
-    def test_random_choice(self):
-        """corpus random choice renturns non-repeatable choices"""
-        corpus = EnglishCorpus()
+def test_corpus_produces_random_sequence():
+    corpus = EnglishCorpus()
+    choices = [corpus.random_sentence() for _ in range(2)]
+    assert len(choices) == len(set(choices))
 
-        choices = [corpus.random_choice() for _ in range(2)]
-        self.assertEqual(len(choices), len(set(choices)))
 
-    def test_random_sentences(self):
-        """corpus random_sentences returns x random sentences"""
-        corpus = EnglishCorpus()
-        self.assertEqual(len(corpus.random_sentences(5)), 5)
+def test_corpus_produces_list_of_random_sentences():
+    corpus = EnglishCorpus()
+    assert len(corpus.random_sentences(5)) == 5

+ 17 - 0
misago/faker/tests/test_fake_bans.py

@@ -0,0 +1,17 @@
+from ...users.models import Ban
+from ..bans import get_fake_email_ban, get_fake_ip_ban, get_fake_username_ban
+
+
+def test_fake_username_ban_can_be_created(db, fake):
+    assert get_fake_username_ban(fake)
+    Ban.objects.get(check_type=Ban.USERNAME)
+
+
+def test_fake_email_ban_can_be_created(db, fake):
+    assert get_fake_email_ban(fake)
+    Ban.objects.get(check_type=Ban.EMAIL)
+
+
+def test_fake_ip_ban_can_be_created(db, fake):
+    assert get_fake_ip_ban(fake)
+    Ban.objects.get(check_type=Ban.IP)

+ 46 - 0
misago/faker/tests/test_fake_categories.py

@@ -0,0 +1,46 @@
+from ..categories import (
+    fake_category,
+    fake_category_description,
+    fake_category_name,
+    fake_closed_category,
+)
+
+
+def test_fake_category_can_be_created(fake, root_category):
+    assert fake_category(fake, root_category)
+
+
+def test_fake_category_is_created_with_specified_parent(fake, default_category):
+    category = fake_category(fake, default_category)
+    assert category.parent == default_category
+
+
+def test_fake_category_can_be_created_with_copy_of_other_category_acls(
+    fake, root_category, default_category
+):
+    category = fake_category(fake, root_category, copy_acl_from=default_category)
+    for acl in default_category.category_role_set.all():
+        category.category_role_set.get(role=acl.role, category_role=acl.category_role)
+
+
+def test_fake_closed_category_can_be_created(fake, root_category):
+    category = fake_closed_category(fake, root_category)
+    assert category.is_closed
+
+
+def test_fake_category_name_can_be_created(fake):
+    assert fake_category_name(fake)
+
+
+def test_different_fake_category_name_is_created_every_time(fake):
+    fake_names = [fake_category_name(fake) for i in range(5)]
+    assert len(fake_names) == len(set(fake_names))
+
+
+def test_fake_category_description_can_be_created(fake):
+    assert fake_category_description(fake)
+
+
+def test_different_fake_category_description_is_created_every_time(fake):
+    fake_descriptions = [fake_category_description(fake) for i in range(5)]
+    assert len(fake_descriptions) == len(set(fake_descriptions))

+ 74 - 0
misago/faker/tests/test_fake_posts.py

@@ -0,0 +1,74 @@
+import pytest
+
+from ..posts import (
+    get_fake_hidden_post,
+    get_fake_post,
+    get_fake_post_content,
+    get_fake_unapproved_post,
+)
+from ..threads import get_fake_thread
+
+
+@pytest.fixture
+def thread(fake, default_category):
+    return get_fake_thread(fake, default_category)
+
+
+def test_fake_post_can_be_created(fake, thread):
+    assert get_fake_post(fake, thread)
+
+
+def test_fake_post_belongs_to_same_category_as_its_thread(fake, thread):
+    post = get_fake_post(fake, thread)
+    assert post.category == thread.category
+
+
+def test_fake_post_is_created_with_guest_poster(fake, thread):
+    post = get_fake_post(fake, thread)
+    assert post.poster is None
+
+
+def test_fake_post_is_created_with_guest_poster_has_poster_name(fake, thread):
+    post = get_fake_post(fake, thread)
+    assert post.poster_name
+
+
+def test_fake_post_is_created_with_specified_poster(fake, thread, user):
+    post = get_fake_post(fake, thread, user)
+    assert post.poster == user
+    assert post.poster_name == user.username
+
+
+def test_fake_post_is_created_with_valid_checksum(fake, thread):
+    post = get_fake_post(fake, thread)
+    assert post.is_valid
+
+
+def test_fake_post_is_created_with_different_content_every_time(fake, thread):
+    post_a = get_fake_post(fake, thread)
+    post_b = get_fake_post(fake, thread)
+    assert post_a.original != post_b.original
+    assert post_a.parsed != post_b.parsed
+
+
+def test_fake_hidden_post_can_be_created(fake, thread):
+    post = get_fake_hidden_post(fake, thread)
+    assert post.is_hidden
+
+
+def test_fake_unapproved_post_can_be_created(fake, thread):
+    post = get_fake_unapproved_post(fake, thread)
+    assert post.is_unapproved
+
+
+def test_fake_post_content_can_be_created(fake):
+    original, parsed = get_fake_post_content(fake)
+    assert original
+    assert parsed
+
+
+def test_different_fake_post_content_is_created_every_time(fake):
+    original_a, parsed_a = get_fake_post_content(fake)
+    original_b, parsed_b = get_fake_post_content(fake)
+    assert original_a != original_b
+    assert parsed_a != parsed_b

+ 55 - 0
misago/faker/tests/test_fake_threads.py

@@ -0,0 +1,55 @@
+from ..threads import (
+    get_fake_closed_thread,
+    get_fake_hidden_thread,
+    get_fake_thread,
+    get_fake_unapproved_thread,
+)
+
+
+def test_fake_thread_can_be_created(fake, default_category):
+    assert get_fake_thread(fake, default_category)
+
+
+def test_fake_thread_is_created_with_opening_post(fake, default_category):
+    thread = get_fake_thread(fake, default_category)
+    assert thread.first_post
+
+
+def test_fake_thread_is_created_with_guest_starter(fake, default_category):
+    thread = get_fake_thread(fake, default_category)
+    assert thread.first_post.poster is None
+
+
+def test_fake_thread_is_created_with_specified_starter(fake, default_category, user):
+    thread = get_fake_thread(fake, default_category, user)
+    assert thread.first_post.poster == user
+    assert thread.first_post.poster_name == user.username
+
+
+def test_fake_thread_is_created_in_specified_category(fake, default_category):
+    thread = get_fake_thread(fake, default_category)
+    assert thread.category == default_category
+    assert thread.first_post.category == default_category
+
+
+def test_fake_closed_thread_can_be_created(fake, default_category):
+    thread = get_fake_closed_thread(fake, default_category)
+    assert thread.is_closed
+
+
+def test_fake_hidden_thread_can_be_created(fake, default_category):
+    thread = get_fake_hidden_thread(fake, default_category)
+    assert thread.is_hidden
+    assert thread.first_post.is_hidden
+
+
+def test_fake_unapproved_thread_can_be_created(fake, default_category):
+    thread = get_fake_unapproved_thread(fake, default_category)
+    assert thread.is_unapproved
+    assert thread.first_post.is_unapproved
+
+
+def test_different_fake_thread_title_is_used_every_time(fake, default_category):
+    thread_a = get_fake_thread(fake, default_category)
+    thread_b = get_fake_thread(fake, default_category)
+    assert thread_a.title != thread_b.title

+ 66 - 0
misago/faker/tests/test_fake_users.py

@@ -0,0 +1,66 @@
+from ...users.bans import get_user_ban
+from ...users.models import Rank
+from ..users import (
+    PASSWORD,
+    get_fake_admin_activated_user,
+    get_fake_banned_user,
+    get_fake_deleted_user,
+    get_fake_inactive_user,
+    get_fake_user,
+    get_fake_username,
+)
+
+
+def test_fake_user_can_be_created(db, fake):
+    assert get_fake_user(fake)
+
+
+def test_fake_user_is_created_with_predictable_password(db, fake):
+    user = get_fake_user(fake)
+    assert user.check_password(PASSWORD)
+
+
+def test_fake_user_is_created_with_test_avatars(db, fake):
+    user = get_fake_user(fake)
+    assert user.avatars
+
+
+def test_new_fake_user_avatars_are_created_for_every_new_user(db, fake):
+    user_a = get_fake_user(fake)
+    user_b = get_fake_user(fake)
+    assert user_a.avatars != user_b.avatars
+
+
+def test_fake_user_is_created_with_explicit_rank(db, fake):
+    rank = Rank.objects.create(name="Test Rank")
+    user = get_fake_user(fake, rank)
+    assert user.rank is rank
+
+
+def test_banned_fake_user_can_be_created(db, cache_versions, fake):
+    user = get_fake_banned_user(fake)
+    assert get_user_ban(user, cache_versions)
+
+
+def test_inactivate_fake_user_can_be_created(db, fake):
+    user = get_fake_inactive_user(fake)
+    assert user.requires_activation
+
+
+def test_admin_activated_fake_user_can_be_created(db, fake):
+    user = get_fake_admin_activated_user(fake)
+    assert user.requires_activation
+
+
+def test_deleted_fake_user_can_be_created(db, fake):
+    user = get_fake_deleted_user(fake)
+    assert not user.is_active
+
+
+def test_fake_username_can_be_created(fake):
+    assert get_fake_username(fake)
+
+
+def test_different_fake_username_is_used_every_time(fake):
+    fake_usernames = [get_fake_username(fake) for i in range(5)]
+    assert len(fake_usernames) == len(set(fake_usernames))

+ 55 - 0
misago/faker/threads.py

@@ -0,0 +1,55 @@
+from django.utils import timezone
+
+from ..threads.models import Thread
+from .englishcorpus import EnglishCorpus
+from .posts import get_fake_hidden_post, get_fake_post, get_fake_unapproved_post
+
+corpus_short = EnglishCorpus(max_length=150)
+
+
+def get_fake_thread(fake, category, starter=None):
+    thread = _create_base_thread(fake, category)
+    thread.first_post = get_fake_post(fake, thread, starter)
+    thread.save(update_fields=["first_post"])
+    return thread
+
+
+def get_fake_closed_thread(fake, category, starter=None):
+    thread = get_fake_thread(fake, category)
+    thread.is_closed = True
+    thread.save(update_fields=["is_closed"])
+    return thread
+
+
+def get_fake_hidden_thread(fake, category, starter=None, hidden_by=None):
+    thread = _create_base_thread(fake, category)
+    thread.first_post = get_fake_hidden_post(fake, thread, starter, hidden_by)
+    thread.is_hidden = True
+    thread.save(update_fields=["first_post", "is_hidden"])
+    return thread
+
+
+def get_fake_unapproved_thread(fake, category, starter=None):
+    thread = _create_base_thread(fake, category)
+    thread.first_post = get_fake_unapproved_post(fake, thread, starter)
+    thread.is_unapproved = True
+    thread.save(update_fields=["first_post", "is_unapproved"])
+    return thread
+
+
+def _create_base_thread(fake, category):
+    started_on = timezone.now()
+    thread = Thread(
+        category=category,
+        started_on=started_on,
+        starter_name="-",
+        starter_slug="-",
+        last_post_on=started_on,
+        last_poster_name="-",
+        last_poster_slug="-",
+        replies=0,
+    )
+    thread.set_title(corpus_short.random_sentence())
+    thread.save()
+
+    return thread

+ 71 - 0
misago/faker/users.py

@@ -0,0 +1,71 @@
+import hashlib
+import random
+
+from django.contrib.auth import get_user_model
+from django.utils.crypto import get_random_string
+
+from ..users.bans import ban_user
+from ..users.test import create_test_user
+from .utils import retry_on_db_error
+
+User = get_user_model()
+
+AVATAR_SIZES = (400, 200, 100)
+GRAVATAR_URL = "https://www.gravatar.com/avatar/%s?s=%s&d=retro"
+PASSWORD = "password"
+
+
+@retry_on_db_error
+def get_fake_user(fake, rank=None, requires_activation=User.ACTIVATION_NONE):
+    username = get_fake_username(fake)
+    email = fake.email()
+    return create_test_user(
+        username,
+        email.lower(),
+        PASSWORD,
+        avatars=get_fake_avatars(email),
+        rank=rank,
+        requires_activation=requires_activation,
+    )
+
+
+def get_fake_banned_user(fake, rank=None):
+    user = get_fake_user(fake, rank=rank)
+    ban_user(user)
+    return user
+
+
+def get_fake_inactive_user(fake, rank=None):
+    return get_fake_user(fake, rank=rank, requires_activation=User.ACTIVATION_USER)
+
+
+def get_fake_admin_activated_user(fake, rank=None):
+    return get_fake_user(fake, rank=rank, requires_activation=User.ACTIVATION_ADMIN)
+
+
+def get_fake_deleted_user(fake, rank=None):
+    user = get_fake_user(fake, rank=rank)
+    user.is_active = False
+    user.save(update_fields=["is_active"])
+    return user
+
+
+def get_fake_username(fake):
+    possible_usernames = [
+        fake.first_name(),
+        fake.last_name(),
+        fake.name().replace(" ", ""),
+        fake.user_name(),
+        get_random_string(random.randint(4, 8)),
+    ]
+
+    return random.choice(possible_usernames)
+
+
+def get_fake_avatars(email):
+    email_hash = hashlib.md5(email.lower().encode("utf-8")).hexdigest()
+
+    return [
+        {"size": size, "url": GRAVATAR_URL % (email_hash, size)}
+        for size in AVATAR_SIZES
+    ]

+ 12 - 0
misago/faker/utils.py

@@ -0,0 +1,12 @@
+from django.db import IntegrityError
+from django.db.transaction import TransactionManagementError
+
+
+def retry_on_db_error(f):
+    def wrapper(*args, **kwargs):
+        try:
+            return f(*args, **kwargs)
+        except (IntegrityError, TransactionManagementError):
+            return wrapper(*args, **kwargs)
+
+    return wrapper

+ 3 - 3
misago/users/models/user.py

@@ -32,14 +32,14 @@ class UserManager(BaseUserManager):
         if not email:
             raise ValueError("User must have an email address.")
 
+        if not extra_fields.get("rank"):
+            extra_fields["rank"] = Rank.objects.get_default()
+
         user = self.model(**extra_fields)
         user.set_username(username)
         user.set_email(email)
         user.set_password(password)
 
-        if "rank" not in extra_fields:
-            user.rank = Rank.objects.get_default()
-
         now = timezone.now()
         user.last_login = now
         user.joined_on = now