Browse Source

Merge pull request #1144 from rafalp/remove-global-state-from-db-settings

Remove global state from dynamic settings
Rafał Pitoń 6 years ago
parent
commit
9ecf2bd958
109 changed files with 1597 additions and 1035 deletions
  1. 2 2
      devproject/settings.py
  2. 1 2
      devproject/test_settings.py
  3. 2 1
      misago/acl/tests/test_getting_user_acl.py
  4. 2 1
      misago/acl/tests/test_patching_user_acl.py
  5. 2 1
      misago/acl/tests/test_serializing_user_acl.py
  6. 2 1
      misago/acl/tests/test_user_acl_middleware.py
  7. 6 4
      misago/cache/tests/test_getting_cache_versions.py
  8. 2 1
      misago/categories/tests/test_utils.py
  9. 5 1
      misago/conf/__init__.py
  10. 23 0
      misago/conf/cache.py
  11. 22 24
      misago/conf/context_processors.py
  12. 0 96
      misago/conf/dbsettings.py
  13. 67 0
      misago/conf/dynamicsettings.py
  14. 1 2
      misago/conf/forms.py
  15. 0 22
      misago/conf/gateway.py
  16. 14 0
      misago/conf/middleware.py
  17. 17 0
      misago/conf/migrations/0002_cache_version.py
  18. 0 7
      misago/conf/migrationutils.py
  19. 18 0
      misago/conf/staticsettings.py
  20. 21 0
      misago/conf/test.py
  21. 9 9
      misago/conf/tests/test_context_processors.py
  22. 50 0
      misago/conf/tests/test_dynamic_settings_middleware.py
  23. 167 0
      misago/conf/tests/test_getting_dynamic_settings_values.py
  24. 33 0
      misago/conf/tests/test_getting_static_settings_values.py
  25. 68 0
      misago/conf/tests/test_overridding_dynamic_settings.py
  26. 0 132
      misago/conf/tests/test_settings.py
  27. 2 2
      misago/conf/views.py
  28. 10 0
      misago/conftest.py
  29. 4 3
      misago/core/mail.py
  30. 4 1
      misago/core/tests/test_errorpages.py
  31. 5 1
      misago/core/tests/test_exceptionhandler_middleware.py
  32. 32 3
      misago/core/tests/test_mail.py
  33. 3 1
      misago/markup/api.py
  34. 2 1
      misago/markup/serializers.py
  35. 2 1
      misago/readtracker/tests/test_categoriestracker.py
  36. 2 1
      misago/readtracker/tests/test_threadstracker.py
  37. 3 3
      misago/templates/misago/base.html
  38. 12 12
      misago/templates/misago/categories/base.html
  39. 3 3
      misago/templates/misago/categories/header.html
  40. 2 2
      misago/templates/misago/emails/base.html
  41. 2 2
      misago/templates/misago/emails/base.txt
  42. 3 3
      misago/templates/misago/footer.html
  43. 4 4
      misago/templates/misago/index.html
  44. 4 4
      misago/templates/misago/navbar.html
  45. 11 11
      misago/templates/misago/threadslist/threads.html
  46. 1 0
      misago/threads/api/postendpoints/split.py
  47. 1 0
      misago/threads/api/postingendpoint/__init__.py
  48. 3 2
      misago/threads/api/postingendpoint/emailnotification.py
  49. 11 3
      misago/threads/api/postingendpoint/reply.py
  50. 4 4
      misago/threads/api/postingendpoint/subscribe.py
  51. 4 1
      misago/threads/api/threadendpoints/merge.py
  52. 2 2
      misago/threads/api/threadendpoints/patch.py
  53. 1 4
      misago/threads/migrations/0004_update_settings.py
  54. 5 2
      misago/threads/participants.py
  55. 4 2
      misago/threads/serializers/moderation.py
  56. 4 1
      misago/threads/tests/test_anonymize_data.py
  57. 2 1
      misago/threads/tests/test_attachments_middleware.py
  58. 2 1
      misago/threads/tests/test_events.py
  59. 10 10
      misago/threads/tests/test_subscription_middleware.py
  60. 2 1
      misago/threads/tests/test_threads_editor_api.py
  61. 2 1
      misago/threads/tests/test_threads_merge_api.py
  62. 2 1
      misago/threads/tests/test_threadview.py
  63. 33 26
      misago/threads/tests/test_validators.py
  64. 23 21
      misago/threads/validators.py
  65. 8 6
      misago/users/api/auth.py
  66. 3 5
      misago/users/api/captcha.py
  67. 22 12
      misago/users/api/userendpoints/avatar.py
  68. 11 3
      misago/users/api/userendpoints/changeemail.py
  69. 8 4
      misago/users/api/userendpoints/changepassword.py
  70. 5 6
      misago/users/api/userendpoints/create.py
  71. 18 15
      misago/users/api/userendpoints/signature.py
  72. 9 7
      misago/users/api/userendpoints/username.py
  73. 22 23
      misago/users/avatars/uploaded.py
  74. 10 10
      misago/users/captcha.py
  75. 11 8
      misago/users/forms/admin.py
  76. 8 5
      misago/users/forms/register.py
  77. 15 10
      misago/users/management/commands/createsuperuser.py
  78. 6 0
      misago/users/management/commands/prepareuserdatadownloads.py
  79. 1 4
      misago/users/migrations/0006_update_settings.py
  80. 2 1
      misago/users/models/__init__.py
  81. 19 0
      misago/users/models/online.py
  82. 2 0
      misago/users/models/rank.py
  83. 44 95
      misago/users/models/user.py
  84. 9 2
      misago/users/registration.py
  85. 8 6
      misago/users/serializers/options.py
  86. 29 0
      misago/users/setupnewuser.py
  87. 14 10
      misago/users/social/pipeline.py
  88. 15 15
      misago/users/tests/test_activation_views.py
  89. 12 10
      misago/users/tests/test_avatars.py
  90. 3 4
      misago/users/tests/test_bans.py
  91. 10 11
      misago/users/tests/test_captcha_api.py
  92. 1 1
      misago/users/tests/test_joinip_profilefield.py
  93. 5 10
      misago/users/tests/test_lists_views.py
  94. 5 8
      misago/users/tests/test_mention_api.py
  95. 70 0
      misago/users/tests/test_new_user_setup.py
  96. 6 4
      misago/users/tests/test_profile_views.py
  97. 2 1
      misago/users/tests/test_signatures.py
  98. 42 23
      misago/users/tests/test_social_pipeline.py
  99. 31 31
      misago/users/tests/test_user_avatar_api.py
  100. 46 25
      misago/users/tests/test_user_create_api.py
  101. 80 0
      misago/users/tests/test_user_creation.py
  102. 62 0
      misago/users/tests/test_user_getters.py
  103. 0 74
      misago/users/tests/test_user_model.py
  104. 7 5
      misago/users/tests/test_user_username_api.py
  105. 69 90
      misago/users/tests/test_useradmin_views.py
  106. 12 8
      misago/users/tests/test_validators.py
  107. 32 9
      misago/users/testutils.py
  108. 18 16
      misago/users/validators.py
  109. 25 11
      misago/users/views/admin/users.py

+ 2 - 2
devproject/settings.py

@@ -225,6 +225,7 @@ MIDDLEWARE = [
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
 
     'misago.cache.middleware.cache_versions_middleware',
+    'misago.conf.middleware.dynamic_settings_middleware',
     'misago.users.middleware.UserMiddleware',
     'misago.acl.middleware.user_acl_middleware',
     'misago.core.middleware.ExceptionHandlerMiddleware',
@@ -287,10 +288,9 @@ TEMPLATES = [
                 'django.contrib.messages.context_processors.messages',
 
                 'misago.acl.context_processors.user_acl',
-                'misago.conf.context_processors.settings',
+                'misago.conf.context_processors.conf',
                 'misago.core.context_processors.site_address',
                 'misago.core.context_processors.momentjs_locale',
-                'misago.legal.context_processors.legal_links',
                 'misago.search.context_processors.search_providers',
                 'misago.users.context_processors.user_links',
 

+ 1 - 2
devproject/test_settings.py

@@ -17,8 +17,7 @@ DATABASES = {
 # Use in-memory cache
 CACHES = {
     'default': {
-        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
-        'LOCATION': 'uniqu3-sn0wf14k3'
+        'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
     }
 }
 

+ 2 - 1
misago/acl/tests/test_getting_user_acl.py

@@ -4,11 +4,12 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 
 from misago.acl.useracl import get_user_acl
+from misago.conftest import get_cache_versions
 from misago.users.models import AnonymousUser
 
 User = get_user_model()
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 class GettingUserACLTests(TestCase):

+ 2 - 1
misago/acl/tests/test_patching_user_acl.py

@@ -3,10 +3,11 @@ from django.test import TestCase
 
 from misago.acl import useracl
 from misago.acl.test import patch_user_acl
+from misago.conftest import get_cache_versions
 
 User = get_user_model()
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 def callable_acl_patch(user, user_acl):

+ 2 - 1
misago/acl/tests/test_serializing_user_acl.py

@@ -4,10 +4,11 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 
 from misago.acl.useracl import get_user_acl, serialize_user_acl
+from misago.conftest import get_cache_versions
 
 User = get_user_model()
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 class SerializingUserACLTests(TestCase):

+ 2 - 1
misago/acl/tests/test_user_acl_middleware.py

@@ -4,10 +4,11 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 
 from misago.acl.middleware import user_acl_middleware
+from misago.conftest import get_cache_versions
 
 User = get_user_model()
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 class MiddlewareTests(TestCase):

+ 6 - 4
misago/cache/tests/test_getting_cache_versions.py

@@ -9,8 +9,8 @@ from misago.cache.versions import (
 
 class CacheVersionsTests(TestCase):
     def test_db_getter_returns_cache_versions_from_db(self):
-        cache_versions = get_cache_versions_from_db()
-        assert cache_versions
+        with self.assertNumQueries(1):
+            assert get_cache_versions_from_db()
 
     @patch('django.core.cache.cache.get', return_value=True)
     def test_cache_getter_returns_cache_versions_from_cache(self, cache_get):
@@ -19,14 +19,16 @@ class CacheVersionsTests(TestCase):
 
     @patch('django.core.cache.cache.get', return_value=True)
     def test_getter_reads_from_cache(self, cache_get):
-        assert get_cache_versions() is True
+        with self.assertNumQueries(0):
+            assert get_cache_versions() is True
         cache_get.assert_called_once_with(CACHE_NAME)
 
     @patch('django.core.cache.cache.set')
     @patch('django.core.cache.cache.get', return_value=None)
     def test_getter_reads_from_db_when_cache_is_not_available(self, cache_get, _):
         db_caches = get_cache_versions_from_db()
-        assert get_cache_versions() == db_caches
+        with self.assertNumQueries(1):
+            assert get_cache_versions() == db_caches
         cache_get.assert_called_once_with(CACHE_NAME)
 
     @patch('django.core.cache.cache.set')

+ 2 - 1
misago/categories/tests/test_utils.py

@@ -1,10 +1,11 @@
 from misago.acl.useracl import get_user_acl
 from misago.categories.models import Category
 from misago.categories.utils import get_categories_tree, get_category_path
+from misago.conftest import get_cache_versions
 from misago.core import threadstore
 from misago.users.testutils import AuthenticatedUserTestCase
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 def get_patched_user_acl(user):

+ 5 - 1
misago/conf/__init__.py

@@ -1,3 +1,7 @@
-from .gateway import settings, db_settings  # noqa
+from .staticsettings import StaticSettings
 
 default_app_config = 'misago.conf.apps.MisagoConfConfig'
+
+SETTINGS_CACHE = "settings"
+
+settings = StaticSettings()

+ 23 - 0
misago/conf/cache.py

@@ -0,0 +1,23 @@
+from django.core.cache import cache
+
+from misago.cache.versions import invalidate_cache
+
+from . import SETTINGS_CACHE
+
+
+def get_settings_cache(cache_versions):
+    key = get_cache_key(cache_versions)
+    return cache.get(key)
+
+
+def set_settings_cache(cache_versions, user_settings):
+    key = get_cache_key(cache_versions)
+    cache.set(key, user_settings)
+
+
+def get_cache_key(cache_versions):
+    return "%s_%s" % (SETTINGS_CACHE, cache_versions[SETTINGS_CACHE])
+
+
+def clear_settings_cache():
+    invalidate_cache(SETTINGS_CACHE)

+ 22 - 24
misago/conf/context_processors.py

@@ -4,46 +4,44 @@ from django.utils.translation import get_language
 
 from misago.users.social.utils import get_enabled_social_auth_sites_list
 
-from .gateway import settings as misago_settings  # noqa
-from .gateway import db_settings
+from . import settings
 
+BLANK_AVATAR_URL = static(settings.MISAGO_BLANK_AVATAR)
 
-BLANK_AVATAR_URL = static(misago_settings.MISAGO_BLANK_AVATAR)
 
-
-def settings(request):
+def conf(request):
     return {
-        'DEBUG': misago_settings.DEBUG,
-        'LANGUAGE_CODE_SHORT': get_language()[:2],
-        'misago_settings': db_settings,
         'BLANK_AVATAR_URL': BLANK_AVATAR_URL,
-        'THREADS_ON_INDEX': misago_settings.MISAGO_THREADS_ON_INDEX,
-        'LOGIN_REDIRECT_URL': misago_settings.LOGIN_REDIRECT_URL,
-        'LOGIN_URL': misago_settings.LOGIN_URL,
-        'LOGOUT_URL': misago_settings.LOGOUT_URL,
+        'DEBUG': settings.DEBUG,
+        'LANGUAGE_CODE_SHORT': get_language()[:2],
+        'LOGIN_REDIRECT_URL': settings.LOGIN_REDIRECT_URL,
+        'LOGIN_URL': settings.LOGIN_URL,
+        'LOGOUT_URL': settings.LOGOUT_URL,
+        'THREADS_ON_INDEX': settings.MISAGO_THREADS_ON_INDEX,
+        'settings': request.settings,
     }
 
 
 def preload_settings_json(request):
-    preloaded_settings = db_settings.get_public_settings()
+    preloaded_settings = request.settings.get_public_settings()
 
     preloaded_settings.update({
-        'LOGIN_API_URL': misago_settings.MISAGO_LOGIN_API_URL,
-        'LOGIN_REDIRECT_URL': reverse(misago_settings.LOGIN_REDIRECT_URL),
-        'LOGIN_URL': reverse(misago_settings.LOGIN_URL),
-        'LOGOUT_URL': reverse(misago_settings.LOGOUT_URL),
+        'LOGIN_API_URL': settings.MISAGO_LOGIN_API_URL,
+        'LOGIN_REDIRECT_URL': reverse(settings.LOGIN_REDIRECT_URL),
+        'LOGIN_URL': reverse(settings.LOGIN_URL),
+        'LOGOUT_URL': reverse(settings.LOGOUT_URL),
         'SOCIAL_AUTH': get_enabled_social_auth_sites_list(),
     })
 
     request.frontend_context.update({
-        'SETTINGS': preloaded_settings,
-        'MISAGO_PATH': reverse('misago:index'),
         'BLANK_AVATAR_URL': BLANK_AVATAR_URL,
-        'ENABLE_DOWNLOAD_OWN_DATA': misago_settings.MISAGO_ENABLE_DOWNLOAD_OWN_DATA,
-        'ENABLE_DELETE_OWN_ACCOUNT': misago_settings.MISAGO_ENABLE_DELETE_OWN_ACCOUNT,
-        'STATIC_URL': misago_settings.STATIC_URL,
-        'CSRF_COOKIE_NAME': misago_settings.CSRF_COOKIE_NAME,
-        'THREADS_ON_INDEX': misago_settings.MISAGO_THREADS_ON_INDEX,
+        'CSRF_COOKIE_NAME': settings.CSRF_COOKIE_NAME,
+        'ENABLE_DELETE_OWN_ACCOUNT': settings.MISAGO_ENABLE_DELETE_OWN_ACCOUNT,
+        'ENABLE_DOWNLOAD_OWN_DATA': settings.MISAGO_ENABLE_DOWNLOAD_OWN_DATA,
+        'MISAGO_PATH': reverse('misago:index'),
+        'SETTINGS': preloaded_settings,
+        'STATIC_URL': settings.STATIC_URL,
+        'THREADS_ON_INDEX': settings.MISAGO_THREADS_ON_INDEX,
     })
 
     return {}

+ 0 - 96
misago/conf/dbsettings.py

@@ -1,96 +0,0 @@
-from misago.core import threadstore
-
-
-CACHE_KEY = 'misago_db_settings'
-
-
-class DBSettings(object):
-    def __init__(self):
-        self._settings = self._read_cache()
-        self._overrides = {}
-
-    def _read_cache(self):
-        from misago.core.cache import cache
-
-        data = cache.get(CACHE_KEY, 'nada')
-        if data == 'nada':
-            data = self._read_db()
-            cache.set(CACHE_KEY, data)
-        return data
-
-    def _read_db(self):
-        from .models import Setting
-
-        data = {}
-        for setting in Setting.objects.iterator():
-            if setting.is_lazy:
-                data[setting.setting] = {
-                    'value': True if setting.value else None,
-                    'is_lazy': setting.is_lazy,
-                    'is_public': setting.is_public,
-                }
-            else:
-                data[setting.setting] = {
-                    'value': setting.value,
-                    'is_lazy': setting.is_lazy,
-                    'is_public': setting.is_public,
-                }
-        return data
-
-    def get_public_settings(self):
-        public_settings = {}
-        for name, setting in self._settings.items():
-            if setting['is_public']:
-                public_settings[name] = setting['value']
-        return public_settings
-
-    def get_lazy_setting(self, setting):
-        from .models import Setting
-
-        try:
-            if self._settings[setting]['is_lazy']:
-                if not self._settings[setting].get('real_value'):
-                    real_value = Setting.objects.get(setting=setting).value
-                    self._settings[setting]['real_value'] = real_value
-                return self._settings[setting]['real_value']
-            else:
-                raise ValueError("Setting %s is not lazy" % setting)
-        except (KeyError, Setting.DoesNotExist):
-            raise AttributeError("Setting %s is undefined" % setting)
-
-    def flush_cache(self):
-        from misago.core.cache import cache
-        cache.delete(CACHE_KEY)
-
-    def __getattr__(self, attr):
-        try:
-            return self._settings[attr]['value']
-        except KeyError:
-            raise AttributeError("Setting %s is undefined" % attr)
-
-    def override_setting(self, setting, new_value):
-        if not setting in self._overrides:
-            self._overrides[setting] = self._settings[setting]['value']
-        self._settings[setting]['value'] = new_value
-        self._settings[setting]['real_value'] = new_value
-        return new_value
-
-    def reset_settings(self):
-        for setting, original_value in self._overrides.items():
-            self._settings[setting]['value'] = original_value
-            self._settings[setting].pop('real_value', None)
-
-
-class _DBSettingsGateway(object):
-    def get_db_settings(self):
-        dbsettings = threadstore.get(CACHE_KEY)
-        if not dbsettings:
-            dbsettings = DBSettings()
-            threadstore.set(CACHE_KEY, dbsettings)
-        return dbsettings
-
-    def __getattr__(self, attr):
-        return getattr(self.get_db_settings(), attr)
-
-
-db_settings = _DBSettingsGateway()

+ 67 - 0
misago/conf/dynamicsettings.py

@@ -0,0 +1,67 @@
+from .cache import get_settings_cache, set_settings_cache
+from .models import Setting
+
+
+class DynamicSettings:
+    _overrides = {}
+
+    def __init__(self, cache_versions):
+        self._settings = get_settings_cache(cache_versions)
+        if self._settings is None:
+            self._settings = get_settings_from_db()
+            set_settings_cache(cache_versions, self._settings)
+
+    def get_public_settings(self):
+        public_settings = {}
+        for name, setting in self._settings.items():
+            if setting["is_public"]:
+                public_settings[name] = setting["value"]
+        return public_settings
+
+    def get_lazy_setting_value(self, setting):
+        try:
+            if self._settings[setting]["is_lazy"]:
+                if setting in self._overrides:
+                    return self._overrides[setting]
+                if not self._settings[setting].get("real_value"):
+                    real_value = Setting.objects.get(setting=setting).value
+                    self._settings[setting]["real_value"] = real_value
+                return self._settings[setting]["real_value"]
+            raise ValueError("Setting %s is not lazy" % setting)
+        except (KeyError, Setting.DoesNotExist):
+            raise AttributeError("Setting %s is not defined" % setting)
+
+    def __getattr__(self, setting):
+        if setting in self._overrides:
+            return self._overrides[setting]
+        return self._settings[setting]["value"]
+
+    @classmethod
+    def override_settings(cls, overrides):
+        cls._overrides = overrides
+
+    @classmethod
+    def remove_overrides(cls):
+        cls._overrides = {}
+
+
+def get_cache_name(cache_versions):
+    return "%s_%s" % (SETTINGS_CACHE, cache_versions[SETTINGS_CACHE])
+
+
+def get_settings_from_db():
+    settings = {}
+    for setting in Setting.objects.iterator():
+        if setting.is_lazy:
+            settings[setting.setting] = {
+                'value': True if setting.value else None,
+                'is_lazy': setting.is_lazy,
+                'is_public': setting.is_public,
+            }
+        else:
+            settings[setting.setting] = {
+                'value': setting.value,
+                'is_lazy': setting.is_lazy,
+                'is_public': setting.is_public,
+            }
+    return settings

+ 1 - 2
misago/conf/forms.py

@@ -4,8 +4,7 @@ from django.utils.translation import ngettext
 
 from misago.admin.forms import YesNoSwitch
 
-
-__ALL__ = ['ChangeSettingsForm']
+__all__ = ['ChangeSettingsForm']
 
 
 class ValidateChoicesNum(object):

+ 0 - 22
misago/conf/gateway.py

@@ -1,22 +0,0 @@
-from django.conf import settings as dj_settings
-
-from . import defaults
-from .dbsettings import db_settings
-
-
-class SettingsGateway(object):
-    def __getattr__(self, name):
-        try:
-            return getattr(dj_settings, name)
-        except AttributeError:
-            pass
-
-        try:
-            return getattr(defaults, name)
-        except AttributeError:
-            pass
-
-        return getattr(db_settings, name)
-
-
-settings = SettingsGateway()

+ 14 - 0
misago/conf/middleware.py

@@ -0,0 +1,14 @@
+from django.utils.functional import SimpleLazyObject
+
+from .dynamicsettings import DynamicSettings
+
+
+def dynamic_settings_middleware(get_response):
+    """Sets request.settings attribute with DynamicSettings."""
+    def middleware(request):
+        def get_dynamic_settings():
+            return DynamicSettings(request.cache_versions)
+        request.settings = SimpleLazyObject(get_dynamic_settings)
+        return get_response(request)
+
+    return middleware

+ 17 - 0
misago/conf/migrations/0002_cache_version.py

@@ -0,0 +1,17 @@
+# Generated by Django 1.11.16 on 2018-12-02 15:54
+from django.db import migrations
+
+from misago.cache.operations import StartCacheVersioning
+
+from misago.conf import SETTINGS_CACHE
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('misago_conf', '0001_initial'),
+    ]
+
+    operations = [
+        StartCacheVersioning(SETTINGS_CACHE)
+    ]

+ 0 - 7
misago/conf/migrationutils.py

@@ -1,6 +1,3 @@
-from misago.core.cache import cache as default_cache
-
-from .dbsettings import CACHE_KEY
 from .hydrators import dehydrate_value
 from .utils import get_setting_value, has_custom_value
 
@@ -91,7 +88,3 @@ def migrate_setting(Setting, group, setting_fixture, order, old_value):
     setting.field_extra = field_extra or {}
 
     setting.save()
-
-
-def delete_settings_cache():
-    default_cache.delete(CACHE_KEY)

+ 18 - 0
misago/conf/staticsettings.py

@@ -0,0 +1,18 @@
+from django.conf import settings
+
+from . import defaults
+
+
+class StaticSettings(object):
+    def __getattr__(self, name):
+        try:
+            return getattr(settings, name)
+        except AttributeError:
+            pass
+
+        try:
+            return getattr(defaults, name)
+        except AttributeError:
+            pass
+
+        raise AttributeError("%s setting is not defined" % name)

+ 21 - 0
misago/conf/test.py

@@ -0,0 +1,21 @@
+from functools import wraps
+
+from misago.conf.dynamicsettings import DynamicSettings
+
+
+class override_dynamic_settings:
+    def __init__(self, **settings):
+        self._overrides = settings
+
+    def __enter__(self):
+        DynamicSettings.override_settings(self._overrides)
+
+    def __exit__(self, *_):
+        DynamicSettings.remove_overrides()
+
+    def __call__(self, f):
+        @wraps(f)
+        def test_function_wrapper(*args, **kwargs):
+            with self as context:
+                return f(*args, **kwargs)
+        return test_function_wrapper

+ 9 - 9
misago/conf/tests/test_context_processors.py

@@ -1,12 +1,12 @@
+from unittest.mock import Mock
+
 from django.test import TestCase
 
-from misago.conf.context_processors import settings
-from misago.conf.dbsettings import db_settings
+from misago.cache.versions import get_cache_versions
 from misago.core import threadstore
 
-
-class MockRequest(object):
-    pass
+from misago.conf.context_processors import conf
+from misago.conf.dynamicsettings import DynamicSettings
 
 
 class ContextProcessorsTests(TestCase):
@@ -15,10 +15,10 @@ class ContextProcessorsTests(TestCase):
 
     def test_db_settings(self):
         """DBSettings are exposed to templates"""
-        mock_request = MockRequest()
-        processor_settings = settings(mock_request)['misago_settings'],
-
-        self.assertEqual(id(processor_settings[0]), id(db_settings))
+        cache_versions = get_cache_versions()
+        mock_request = Mock(settings=DynamicSettings(cache_versions))
+        context_settings = conf(mock_request)['settings']
+        assert context_settings == mock_request.settings
 
     def test_preload_settings(self):
         """site configuration is preloaded by middleware"""

+ 50 - 0
misago/conf/tests/test_dynamic_settings_middleware.py

@@ -0,0 +1,50 @@
+from unittest.mock import Mock, PropertyMock, patch
+
+from django.test import TestCase
+from django.utils.functional import SimpleLazyObject
+
+from misago.conf.dynamicsettings import DynamicSettings
+from misago.conf.middleware import dynamic_settings_middleware
+
+
+class MiddlewareTests(TestCase):
+    def test_middleware_sets_attr_on_request(self):
+        get_response = Mock()
+        request = Mock()
+        settings = PropertyMock()
+        type(request).settings = settings
+        middleware = dynamic_settings_middleware(get_response)
+        middleware(request)
+        settings.assert_called_once()
+
+    def test_attr_set_by_middleware_on_request_is_lazy_object(self):
+        get_response = Mock()
+        request = Mock()
+        settings = PropertyMock()
+        type(request).settings = settings
+        middleware = dynamic_settings_middleware(get_response)
+        middleware(request)
+        attr_value = settings.call_args[0][0]
+        assert isinstance(attr_value, SimpleLazyObject)
+
+    def test_middleware_calls_get_response(self):
+        get_response = Mock()
+        request = Mock()
+        middleware = dynamic_settings_middleware(get_response)
+        middleware(request)
+        get_response.assert_called_once()
+
+    def test_middleware_is_not_reading_db(self):
+        get_response = Mock()
+        request = Mock()
+        with self.assertNumQueries(0):
+            middleware = dynamic_settings_middleware(get_response)
+            middleware(request)
+
+    @patch('django.core.cache.cache.get')
+    def test_middleware_is_not_reading_cache(self, cache_get):
+        get_response = Mock()
+        request = Mock()
+        middleware = dynamic_settings_middleware(get_response)
+        middleware(request)
+        cache_get.assert_not_called()

+ 167 - 0
misago/conf/tests/test_getting_dynamic_settings_values.py

@@ -0,0 +1,167 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+
+from misago.conf import SETTINGS_CACHE
+from misago.conf.dynamicsettings import DynamicSettings
+from misago.conf.models import Setting, SettingsGroup
+from misago.conftest import get_cache_versions
+
+cache_versions = get_cache_versions()
+
+
+class GettingSettingValueTests(TestCase):
+    @patch('django.core.cache.cache.set')
+    @patch('django.core.cache.cache.get', return_value=None)
+    def test_settings_are_loaded_from_database_if_cache_is_not_available(self, cache_get, _):
+        with self.assertNumQueries(1):
+            DynamicSettings(cache_versions)
+
+    @patch('django.core.cache.cache.set')
+    @patch('django.core.cache.cache.get', return_value={})
+    def test_settings_are_loaded_from_cache_if_it_is_not_none(self, cache_get, _):
+        with self.assertNumQueries(0):
+            DynamicSettings(cache_versions)
+        cache_get.assert_called_once()
+
+    @patch('django.core.cache.cache.set')
+    @patch('django.core.cache.cache.get', return_value=None)
+    def test_settings_cache_is_set_if_none_exists(self, _, cache_set):
+        DynamicSettings(cache_versions)
+        cache_set.assert_called_once()
+
+    @patch('django.core.cache.cache.set')
+    @patch('django.core.cache.cache.get', return_value={})
+    def test_settings_cache_is_not_set_if_it_already_exists(self, _, cache_set):
+        with self.assertNumQueries(0):
+            DynamicSettings(cache_versions)
+        cache_set.assert_not_called()
+
+    @patch('django.core.cache.cache.set')
+    @patch('django.core.cache.cache.get', return_value=None)
+    def test_settings_cache_key_includes_cache_name_and_version(self, _, cache_set):
+        DynamicSettings(cache_versions)
+        cache_key = cache_set.call_args[0][0]
+        assert SETTINGS_CACHE in cache_key
+        assert cache_versions[SETTINGS_CACHE] in cache_key
+
+    def test_accessing_attr_returns_setting_value(self):
+        settings = DynamicSettings(cache_versions)
+        assert settings.forum_name == "Misago"
+
+    def test_accessing_attr_for_undefined_setting_raises_error(self):
+        settings = DynamicSettings(cache_versions)
+        with self.assertRaises(KeyError):
+            settings.not_existing
+
+    def test_accessing_attr_for_lazy_setting_without_value_returns_none(self):
+        settings_group = SettingsGroup.objects.create(key="test", name="Test")
+        Setting.objects.create(
+            group=settings_group,
+            setting="lazy_setting",
+            name="Lazy setting",
+            is_lazy=True,
+            field_extra={},
+        )
+
+        settings = DynamicSettings(cache_versions)
+        assert settings.lazy_setting is None
+
+    def test_accessing_attr_for_lazy_setting_with_value_returns_true(self):
+        settings_group = SettingsGroup.objects.create(key="test", name="Test")
+        Setting.objects.create(
+            group=settings_group,
+            setting="lazy_setting",
+            name="Lazy setting",
+            dry_value="Hello",
+            is_lazy=True,
+            field_extra={},
+        )
+
+        settings = DynamicSettings(cache_versions)
+        assert settings.lazy_setting is True
+
+    def test_lazy_setting_getter_for_lazy_setting_with_value_returns_real_value(self):
+        settings_group = SettingsGroup.objects.create(key="test", name="Test")
+        Setting.objects.create(
+            group=settings_group,
+            setting="lazy_setting",
+            name="Lazy setting",
+            dry_value="Hello",
+            is_lazy=True,
+            field_extra={},
+        )
+
+        settings = DynamicSettings(cache_versions)
+        assert settings.get_lazy_setting_value("lazy_setting") == "Hello"
+
+    def test_lazy_setting_getter_for_lazy_setting_makes_db_query(self):
+        settings_group = SettingsGroup.objects.create(key="test", name="Test")
+        Setting.objects.create(
+            group=settings_group,
+            setting="lazy_setting",
+            name="Lazy setting",
+            dry_value="Hello",
+            is_lazy=True,
+            field_extra={},
+        )
+
+        settings = DynamicSettings(cache_versions)
+        with self.assertNumQueries(1):
+            settings.get_lazy_setting_value("lazy_setting")
+
+    def test_lazy_setting_getter_for_lazy_setting_is_reusing_query_result(self):
+        settings_group = SettingsGroup.objects.create(key="test", name="Test")
+        Setting.objects.create(
+            group=settings_group,
+            setting="lazy_setting",
+            name="Lazy setting",
+            dry_value="Hello",
+            is_lazy=True,
+            field_extra={},
+        )
+
+        settings = DynamicSettings(cache_versions)
+        settings.get_lazy_setting_value("lazy_setting")
+        with self.assertNumQueries(0):
+            settings.get_lazy_setting_value("lazy_setting")
+
+    def test_lazy_setting_getter_for_undefined_setting_raises_attribute_error(self):
+        settings = DynamicSettings(cache_versions)
+        with self.assertRaises(AttributeError):
+            settings.get_lazy_setting_value("undefined")
+
+    def test_lazy_setting_getter_for_not_lazy_setting_raises_value_error(self):
+        settings = DynamicSettings(cache_versions)
+        with self.assertRaises(ValueError):
+            settings.get_lazy_setting_value("forum_name")
+
+    def test_public_settings_getter_returns_dict_with_public_settings(self):
+        settings_group = SettingsGroup.objects.create(key="test", name="Test")
+        Setting.objects.create(
+            group=settings_group,
+            setting="public_setting",
+            name="Public setting",
+            dry_value="Hello",
+            is_public=True,
+            field_extra={},
+        )
+
+        settings = DynamicSettings(cache_versions)
+        public_settings = settings.get_public_settings()
+        assert public_settings["public_setting"] == "Hello"
+
+    def test_public_settings_getter_excludes_private_settings_from_dict(self):
+        settings_group = SettingsGroup.objects.create(key="test", name="Test")
+        Setting.objects.create(
+            group=settings_group,
+            setting="private_setting",
+            name="Private setting",
+            dry_value="Hello",
+            is_public=False,
+            field_extra={},
+        )
+
+        settings = DynamicSettings(cache_versions)
+        public_settings = settings.get_public_settings()
+        assert "private_setting" not in public_settings

+ 33 - 0
misago/conf/tests/test_getting_static_settings_values.py

@@ -0,0 +1,33 @@
+from django.test import TestCase, override_settings
+
+from misago.conf.staticsettings import StaticSettings
+
+
+class GettingSettingValueTests(TestCase):
+    def test_accessing_attr_returns_setting_value_defined_in_settings_file(self):
+        settings = StaticSettings()
+        assert settings.STATIC_URL
+
+    def test_accessing_attr_returns_setting_value_defined_in_misago_defaults_file(self):
+        settings = StaticSettings()
+        assert settings.MISAGO_MOMENT_JS_LOCALES
+
+    def test_setting_value_can_be_overridden_using_django_util(self):
+        settings = StaticSettings()
+        with override_settings(STATIC_URL="/test/"):
+            assert settings.STATIC_URL == "/test/"
+
+    def test_default_setting_value_can_be_overridden_using_django_util(self):
+        settings = StaticSettings()
+        with override_settings(MISAGO_MOMENT_JS_LOCALES="test"):
+            assert settings.MISAGO_MOMENT_JS_LOCALES == "test"
+
+    def test_undefined_setting_value_can_be_overridden_using_django_util(self):
+        settings = StaticSettings()
+        with override_settings(UNDEFINED_SETTING="test"):
+            assert settings.UNDEFINED_SETTING == "test"
+
+    def test_accessing_attr_for_undefined_setting_raises_attribute_error(self):
+        settings = StaticSettings()
+        with self.assertRaises(AttributeError):
+            assert settings.UNDEFINED_SETTING

+ 68 - 0
misago/conf/tests/test_overridding_dynamic_settings.py

@@ -0,0 +1,68 @@
+from django.test import TestCase
+
+from misago.conf import SETTINGS_CACHE
+from misago.conf.dynamicsettings import DynamicSettings
+from misago.conf.models import Setting, SettingsGroup
+from misago.conftest import get_cache_versions
+
+from misago.conf.test import override_dynamic_settings
+
+cache_versions = get_cache_versions()
+
+
+class OverrideDynamicSettingsTests(TestCase):
+    def test_dynamic_setting_can_be_overridden_using_context_manager(self):
+        settings = DynamicSettings(cache_versions)
+        assert settings.forum_name == "Misago"
+
+        with override_dynamic_settings(forum_name="Overrided"):
+            assert settings.forum_name == "Overrided"
+
+        assert settings.forum_name == "Misago"
+
+    def test_dynamic_setting_can_be_overridden_using_decorator(self):
+        @override_dynamic_settings(forum_name="Overrided")
+        def decorated_function(settings):
+            return settings.forum_name
+
+        settings = DynamicSettings(cache_versions)
+        assert settings.forum_name == "Misago"
+        assert decorated_function(settings) == "Overrided"
+        assert settings.forum_name == "Misago"
+
+    def test_lazy_dynamic_setting_can_be_overridden_using_context_manager(self):
+        settings_group = SettingsGroup.objects.create(key="test", name="Test")
+        setting = Setting.objects.create(
+            group=settings_group,
+            setting="lazy_setting",
+            name="Lazy setting",
+            dry_value="Hello",
+            is_lazy=True,
+            field_extra={},
+        )
+
+        settings = DynamicSettings(cache_versions)
+        assert settings.get_lazy_setting_value("lazy_setting") == "Hello"
+        with override_dynamic_settings(lazy_setting="Overrided"):
+            assert settings.get_lazy_setting_value("lazy_setting") == "Overrided"
+        assert settings.get_lazy_setting_value("lazy_setting") == "Hello"
+
+    def test_lazy_dynamic_setting_can_be_overridden_using_decorator(self):
+        @override_dynamic_settings(lazy_setting="Overrided")
+        def decorated_function(settings):
+            return settings.get_lazy_setting_value("lazy_setting")
+
+        settings_group = SettingsGroup.objects.create(key="test", name="Test")
+        setting = Setting.objects.create(
+            group=settings_group,
+            setting="lazy_setting",
+            name="Lazy setting",
+            dry_value="Hello",
+            is_lazy=True,
+            field_extra={},
+        )
+        
+        settings = DynamicSettings(cache_versions)
+        assert settings.get_lazy_setting_value("lazy_setting") == "Hello"
+        assert decorated_function(settings) == "Overrided"
+        assert settings.get_lazy_setting_value("lazy_setting") == "Hello"

+ 0 - 132
misago/conf/tests/test_settings.py

@@ -1,132 +0,0 @@
-from django.apps import apps
-from django.conf import settings as dj_settings
-from django.test import TestCase, override_settings
-
-from misago.conf import defaults
-from misago.conf.dbsettings import db_settings
-from misago.conf.gateway import settings as gateway
-from misago.conf.migrationutils import migrate_settings_group
-from misago.core import threadstore
-from misago.core.cache import cache
-
-
-class DBSettingsTests(TestCase):
-    def test_get_existing_setting(self):
-        """forum_name is defined"""
-        self.assertEqual(db_settings.forum_name, 'Misago')
-
-        with self.assertRaises(AttributeError):
-            db_settings.MISAGO_THREADS_PER_PAGE
-
-
-class GatewaySettingsTests(TestCase):
-    def tearDown(self):
-        cache.clear()
-        threadstore.clear()
-
-    def test_get_existing_setting(self):
-        """forum_name is defined"""
-        self.assertEqual(gateway.forum_name, db_settings.forum_name)
-        self.assertEqual(gateway.INSTALLED_APPS, dj_settings.INSTALLED_APPS)
-        self.assertEqual(gateway.MISAGO_THREADS_PER_PAGE, defaults.MISAGO_THREADS_PER_PAGE)
-
-        with self.assertRaises(AttributeError):
-            gateway.LoremIpsum
-
-    @override_settings(MISAGO_THREADS_PER_PAGE=1234)
-    def test_override_file_setting(self):
-        """file settings are overrideable"""
-        self.assertEqual(gateway.MISAGO_THREADS_PER_PAGE, 1234)
-
-    def test_setting_public(self):
-        """get_public_settings returns public settings"""
-        test_group = {
-            'key': 'test_group',
-            'name': "Test settings",
-            'description': "Those are test settings.",
-            'settings': [
-                {
-                    'setting': 'fish_name',
-                    'name': "Fish's name",
-                    'value': "Public Eric",
-                    'field_extra': {
-                        'min_length': 2,
-                        'max_length': 255,
-                    },
-                    'is_public': True,
-                },
-                {
-                    'setting': 'private_fish_name',
-                    'name': "Fish's name",
-                    'value': "Private Eric",
-                    'field_extra': {
-                        'min_length': 2,
-                        'max_length': 255,
-                    },
-                    'is_public': False,
-                },
-            ],
-        }
-
-        migrate_settings_group(apps, test_group)
-
-        self.assertEqual(gateway.fish_name, 'Public Eric')
-        self.assertEqual(gateway.private_fish_name, 'Private Eric')
-
-        public_settings = gateway.get_public_settings().keys()
-        self.assertIn('fish_name', public_settings)
-        self.assertNotIn('private_fish_name', public_settings)
-
-    def test_setting_lazy(self):
-        """lazy settings work"""
-        test_group = {
-            'key': 'test_group',
-            'name': "Test settings",
-            'description': "Those are test settings.",
-            'settings': [
-                {
-                    'setting': 'fish_name',
-                    'name': "Fish's name",
-                    'value': "Greedy Eric",
-                    'field_extra': {
-                        'min_length': 2,
-                        'max_length': 255,
-                    },
-                    'is_lazy': False,
-                },
-                {
-                    'setting': 'lazy_fish_name',
-                    'name': "Fish's name",
-                    'value': "Lazy Eric",
-                    'field_extra': {
-                        'min_length': 2,
-                        'max_length': 255,
-                    },
-                    'is_lazy': True,
-                },
-                {
-                    'setting': 'lazy_empty_setting',
-                    'name': "Fish's name",
-                    'field_extra': {
-                        'min_length': 2,
-                        'max_length': 255,
-                    },
-                    'is_lazy': True,
-                },
-            ],
-        }
-
-        migrate_settings_group(apps, test_group)
-
-        self.assertTrue(gateway.lazy_fish_name)
-        self.assertTrue(db_settings.lazy_fish_name)
-
-        self.assertTrue(gateway.lazy_fish_name)
-        self.assertEqual(gateway.get_lazy_setting('lazy_fish_name'), 'Lazy Eric')
-        self.assertTrue(db_settings.lazy_fish_name)
-        self.assertEqual(db_settings.get_lazy_setting('lazy_fish_name'), 'Lazy Eric')
-
-        self.assertTrue(gateway.lazy_empty_setting is None)
-        self.assertTrue(db_settings.lazy_empty_setting is None)
-        with self.assertRaises(ValueError):
-            db_settings.get_lazy_setting('fish_name')

+ 2 - 2
misago/conf/views.py

@@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
 
 from misago.admin.views import render as mi_render
 
-from . import db_settings
+from .cache import clear_settings_cache
 from .forms import ChangeSettingsForm
 from .models import SettingsGroup
 
@@ -44,7 +44,7 @@ def group(request, key):
                 setting.value = new_values[setting.setting]
                 setting.save(update_fields=['dry_value'])
 
-            db_settings.flush_cache()
+            clear_settings_cache()
 
             messages.success(request, _("Changes in settings have been saved!"))
             return redirect('misago:admin:system:settings:group', key=key)

+ 10 - 0
misago/conftest.py

@@ -0,0 +1,10 @@
+from misago.acl import ACL_CACHE
+from misago.conf import SETTINGS_CACHE
+from misago.users.constants import BANS_CACHE
+
+def get_cache_versions():
+    return {
+        ACL_CACHE: "abcdefgh",
+        BANS_CACHE: "abcdefgh",
+        SETTINGS_CACHE: "abcdefgh",
+    }

+ 4 - 3
misago/core/mail.py

@@ -2,7 +2,7 @@ from django.core import mail as djmail
 from django.template.loader import render_to_string
 from django.utils.translation import get_language
 
-from misago.conf import db_settings, settings
+from misago.conf import settings
 
 from .utils import get_host_from_address
 
@@ -15,13 +15,14 @@ def build_mail(recipient, subject, template, sender=None, context=None):
         'LANGUAGE_CODE': get_language()[:2],
         'LOGIN_URL': settings.LOGIN_URL,
 
-        'misago_settings': db_settings,
-
         'user': recipient,
         'sender': sender,
         'subject': subject,
     })
 
+    if not context.get("settings"):
+        raise ValueError("settings key is missing from context")
+
     message_plain = render_to_string('%s.txt' % template, context)
     message_html = render_to_string('%s.html' % template, context)
 

+ 4 - 1
misago/core/tests/test_errorpages.py

@@ -6,6 +6,8 @@ from django.urls import reverse
 
 from misago.acl.useracl import get_user_acl
 from misago.users.models import AnonymousUser
+from misago.conf.dynamicsettings import DynamicSettings
+from misago.conftest import get_cache_versions
 from misago.core.testproject.views import mock_custom_403_error_page, mock_custom_404_error_page
 from misago.core.utils import encode_json_html
 
@@ -76,7 +78,8 @@ class ErrorPageViewsTests(TestCase):
 
 def test_request(url):
     request = RequestFactory().get(url)
-    request.cache_versions = {"acl": "abcdefgh"}
+    request.cache_versions = get_cache_versions()
+    request.settings = DynamicSettings(request.cache_versions)
     request.user = AnonymousUser()
     request.user_acl = get_user_acl(request.user, request.cache_versions)
     request.include_frontend_context = True

+ 5 - 1
misago/core/tests/test_exceptionhandler_middleware.py

@@ -4,6 +4,9 @@ from django.test.client import RequestFactory
 from django.urls import reverse
 
 from misago.acl.useracl import get_user_acl
+from misago.conf.dynamicsettings import DynamicSettings
+from misago.core.middleware import ExceptionHandlerMiddleware
+from misago.conftest import get_cache_versions
 from misago.users.models import AnonymousUser
 
 from misago.core.middleware import ExceptionHandlerMiddleware
@@ -11,7 +14,8 @@ from misago.core.middleware import ExceptionHandlerMiddleware
 
 def test_request():
     request = RequestFactory().get(reverse('misago:index'))
-    request.cache_versions = {"acl": "abcdefgh"}
+    request.cache_versions = get_cache_versions()
+    request.settings = DynamicSettings(request.cache_versions)
     request.user = AnonymousUser()
     request.user_acl = get_user_acl(request.user, request.cache_versions)
     request.include_frontend_context = True

+ 32 - 3
misago/core/tests/test_mail.py

@@ -3,18 +3,39 @@ from django.core import mail
 from django.test import TestCase
 from django.urls import reverse
 
-from misago.core.mail import mail_user, mail_users
+from misago.cache.versions import get_cache_versions
+from misago.conf.dynamicsettings import DynamicSettings
+
+from misago.core.mail import build_mail, mail_user, mail_users
 
 
 UserModel = get_user_model()
 
 
 class MailTests(TestCase):
+    def test_building_mail_without_context_raises_value_error(self):
+        user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        with self.assertRaises(ValueError):
+            build_mail(user, "Misago Test Mail", "misago/emails/base")
+
+    def test_building_mail_without_settings_in_context_raises_value_error(self):
+        user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        with self.assertRaises(ValueError):
+            build_mail(user, "Misago Test Mail", "misago/emails/base", context={"settings": {}})
+
     def test_mail_user(self):
         """mail_user sets message in backend"""
         user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
 
-        mail_user(user, "Misago Test Mail", "misago/emails/base")
+        cache_versions = get_cache_versions()
+        settings = DynamicSettings(cache_versions)
+
+        mail_user(
+            user,
+            "Misago Test Mail",
+            "misago/emails/base",
+            context={"settings": settings},
+        )
 
         self.assertEqual(mail.outbox[0].subject, "Misago Test Mail")
 
@@ -26,6 +47,9 @@ class MailTests(TestCase):
 
     def test_mail_users(self):
         """mail_users sets messages in backend"""
+        cache_versions = get_cache_versions()
+        settings = DynamicSettings(cache_versions)
+
         test_users = [
             UserModel.objects.create_user('Alpha', 'alpha@test.com', 'pass123'),
             UserModel.objects.create_user('Beta', 'beta@test.com', 'pass123'),
@@ -34,7 +58,12 @@ class MailTests(TestCase):
             UserModel.objects.create_user('Uniform', 'uniform@test.com', 'pass123'),
         ]
 
-        mail_users(test_users, "Misago Test Spam", "misago/emails/base")
+        mail_users(
+            test_users,
+            "Misago Test Spam",
+            "misago/emails/base",
+            context={"settings": settings},
+        )
 
         spams_sent = 0
         for message in mail.outbox:

+ 3 - 1
misago/markup/api.py

@@ -8,7 +8,9 @@ from .serializers import MarkupSerializer
 
 @api_view(['POST'])
 def parse_markup(request):
-    serializer = MarkupSerializer(data=request.data)
+    serializer = MarkupSerializer(
+        data=request.data, context={"settings": request.settings}
+    )
     if not serializer.is_valid():
         errors_list = list(serializer.errors.values())[0]
         return Response(

+ 2 - 1
misago/markup/serializers.py

@@ -7,5 +7,6 @@ class MarkupSerializer(serializers.Serializer):
     post = serializers.CharField(required=False, allow_blank=True)
 
     def validate(self, data):
-        validate_post_length(data.get('post', ''))
+        settings = self.context["settings"]
+        validate_post_length(settings, data.get("post", ""))
         return data

+ 2 - 1
misago/readtracker/tests/test_categoriestracker.py

@@ -7,13 +7,14 @@ from django.utils import timezone
 from misago.acl.useracl import get_user_acl
 from misago.categories.models import Category
 from misago.conf import settings
+from misago.conftest import get_cache_versions
 from misago.readtracker import poststracker, categoriestracker
 from misago.readtracker.models import PostRead
 from misago.threads import testutils
 
 User = get_user_model()
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 class AnonymousUser(object):

+ 2 - 1
misago/readtracker/tests/test_threadstracker.py

@@ -8,13 +8,14 @@ from misago.acl.objectacl import add_acl_to_obj
 from misago.acl.useracl import get_user_acl
 from misago.categories.models import Category
 from misago.conf import settings
+from misago.conftest import get_cache_versions
 from misago.readtracker import poststracker, threadstracker
 from misago.readtracker.models import PostRead
 from misago.threads import testutils
 
 User = get_user_model()
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 class AnonymousUser(object):

+ 3 - 3
misago/templates/misago/base.html

@@ -5,14 +5,14 @@
     <meta charset="utf-8">
     <meta http-equiv="X-UA-Compatible" content="IE=edge">
     <meta name="viewport" content="width=device-width,initial-scale=1">
-    <title>{% spaceless %}{% block title %}{{ misago_settings.forum_name }}{% endblock %}{% endspaceless %}</title>
+    <title>{% spaceless %}{% block title %}{{ settings.forum_name }}{% endblock %}{% endspaceless %}</title>
     <meta name="description" content="{% spaceless %}{% block meta-description %}{% endblock %}{% endspaceless %}">
     {% spaceless %}
       {% block meta-extra %}{% endblock meta-extra %}
       {% block og-tags %}
-        <meta property="og:site_name" content="{% spaceless %}{% block og-site-name %}{{ misago_settings.forum_name }}{% endblock og-site-name %}{% endspaceless %}" />
+        <meta property="og:site_name" content="{% spaceless %}{% block og-site-name %}{{ settings.forum_name }}{% endblock og-site-name %}{% endspaceless %}" />
         <meta property="og:title" content="{% spaceless %}{% block og-title %}{% endblock og-title %}{% endspaceless %}" />
-        <meta property="og:description" content="{% spaceless %}{% block og-description %}{{ misago_settings.forum_index_meta_description|default:'' }}{% endblock og-description %}{% endspaceless %}" />
+        <meta property="og:description" content="{% spaceless %}{% block og-description %}{{ settings.forum_index_meta_description|default:'' }}{% endblock og-description %}{% endspaceless %}" />
         <meta property="og:type" content="website" />
         <meta property="og:url" content="{% spaceless %}{% block og-url %}{{ SITE_ADDRESS }}{% endblock og-url %}{% endspaceless %}" />
         <meta property="og:image" content="{% spaceless %}{% block og-image %}{% static 'og-image.jpg' %}{% endblock og-image %}{% endspaceless %}" />

+ 12 - 12
misago/templates/misago/categories/base.html

@@ -6,20 +6,20 @@
   {% if THREADS_ON_INDEX %}
     {% trans "Categories" %} | {{ block.super }}
   {% else %}
-    {% if misago_settings.forum_index_title %}
-      {{ misago_settings.forum_index_title }}
+    {% if settings.forum_index_title %}
+      {{ settings.forum_index_title }}
     {% else %}
-      {{ misago_settings.forum_name }}
+      {{ settings.forum_name }}
     {% endif %}
   {% endif %}
 {% endblock title %}
 
 
 {% block meta-description %}
-  {% if not THREADS_ON_INDEX and misago_settings.forum_index_meta_description %}
-    {{ misago_settings.forum_index_meta_description }}
+  {% if not THREADS_ON_INDEX and settings.forum_index_meta_description %}
+    {{ settings.forum_index_meta_description }}
   {% else %}
-  {% blocktrans trimmed count categories=categories|length with forum_name=misago_settings.forum_name %}
+  {% blocktrans trimmed count categories=categories|length with forum_name=settings.forum_name %}
       There is {{ categories }} main category currenty available on the {{ forum_name }}.
     {% plural %}
       There are {{ categories }} main categories currenty available on the {{ forum_name }}.
@@ -32,20 +32,20 @@
   {% if THREADS_ON_INDEX %}
     {% trans "Categories" %}
   {% else %}
-    {% if misago_settings.forum_index_title %}
-      {{ misago_settings.forum_index_title }}
+    {% if settings.forum_index_title %}
+      {{ settings.forum_index_title }}
     {% else %}
-      {{ misago_settings.forum_name }}
+      {{ settings.forum_name }}
     {% endif %}
   {% endif %}
 {% endblock og-title %}
 
 
 {% block og-description %}
-  {% if not THREADS_ON_INDEX and misago_settings.forum_index_meta_description %}
-    {{ misago_settings.forum_index_meta_description }}
+  {% if not THREADS_ON_INDEX and settings.forum_index_meta_description %}
+    {{ settings.forum_index_meta_description }}
   {% else %}
-    {% blocktrans trimmed count categories=categories|length with forum_name=misago_settings.forum_name %}
+    {% blocktrans trimmed count categories=categories|length with forum_name=settings.forum_name %}
       There is {{ categories }} main category currenty available on the {{ forum_name }}.
     {% plural %}
       There are {{ categories }} main categories currenty available on the {{ forum_name }}.

+ 3 - 3
misago/templates/misago/categories/header.html

@@ -3,10 +3,10 @@
   <div class="page-header">
     <div class="container">
       {% if is_index %}
-        {% if misago_settings.forum_index_title %}
-          <h1>{{ misago_settings.forum_index_title }}</h1>
+        {% if settings.forum_index_title %}
+          <h1>{{ settings.forum_index_title }}</h1>
         {% else %}
-          <h1>{{ misago_settings.forum_name }}</h1>
+          <h1>{{ settings.forum_name }}</h1>
         {% endif %}
       {% else %}
         <h1>{% trans "Categories" %}</h1>

+ 2 - 2
misago/templates/misago/emails/base.html

@@ -55,7 +55,7 @@
 
               <table border="0" width="100%" height="100%" cellpadding="0" cellspacing="0">
                 <tr>
-                  <td valign="middle" style="font-size: 28px; line-height: 24px; color: #555555;">{{ misago_settings.forum_name }}</td>
+                  <td valign="middle" style="font-size: 28px; line-height: 24px; color: #555555;">{{ settings.forum_name }}</td>
                   <td align="center" valign="middle" width="30"><img src="{% absoluteurl 'misago:user-avatar' pk=user.pk size=32 %}" width="32" height="32" style="border-radius: 3px;" alt=""></td>
                 </tr>
               </table>
@@ -68,7 +68,7 @@
 
             <br>
             <div style="border-top: 1px solid #ddd; color: #666; font-size: 12px; line-height: 18px;">
-              {% if misago_settings.email_footer %}<br>{{ misago_settings.email_footer }}{% endif %}
+              {% if settings.email_footer %}<br>{{ settings.email_footer }}{% endif %}
               <br><a href="{{ SITE_ADDRESS }}" style="color: #888; text-decoration: underline;">Sent from {{ SITE_HOST }}</a>
             </div>
 

+ 2 - 2
misago/templates/misago/emails/base.txt

@@ -1,4 +1,4 @@
-{{ misago_settings.forum_name }}
+{{ settings.forum_name }}
 ================================================
 
 {% block title %}{{ subject }}{% endblock %}
@@ -7,5 +7,5 @@
 
 
 ------------------------------------------------
-{% if misago_settings.email_footer %}{{ misago_settings.email_footer }}{% endif %}
+{% if settings.email_footer %}{{ settings.email_footer }}{% endif %}
 Sent from {{ SITE_ADDRESS }}

+ 3 - 3
misago/templates/misago/footer.html

@@ -10,11 +10,11 @@
         </p>
       </noscript>
 
-      {% if TERMS_OF_SERVICE_URL or PRIVACY_POLICY_URL or misago_settings.forum_footnote %}
+      {% if TERMS_OF_SERVICE_URL or PRIVACY_POLICY_URL or settings.forum_footnote %}
         <ul class="list-inline footer-nav">
-        {% if misago_settings.forum_footnote %}
+        {% if settings.forum_footnote %}
           <li class="site-footnote">
-            {{ misago_settings.forum_footnote }}
+            {{ settings.forum_footnote }}
           </li>
         {% endif %}
         {% if TERMS_OF_SERVICE_URL %}

+ 4 - 4
misago/templates/misago/index.html

@@ -3,15 +3,15 @@
 
 
 {% block title %}
-{% if misago_settings.forum_index_title %}
-{{ misago_settings.forum_index_title }}
+{% if settings.forum_index_title %}
+{{ settings.forum_index_title }}
 {% else %}
-{{ misago_settings.forum_name }}
+{{ settings.forum_name }}
 {% endif %}
 {% endblock title %}
 
 
-{% block meta-description %}{{ misago_settings.forum_index_meta_description }}{% endblock meta-description %}
+{% block meta-description %}{{ settings.forum_index_meta_description }}{% endblock meta-description %}
 
 
 {% block content %}

+ 4 - 4
misago/templates/misago/navbar.html

@@ -2,11 +2,11 @@
 <nav class="navbar navbar-misago navbar-inverse navbar-static-top" role="navigation">
 
   <div class="container navbar-full navbar-desktop-nav">
-    {% if misago_settings.forum_branding_display %}
+    {% if settings.forum_branding_display %}
       <a href="{% url 'misago:index' %}" class="navbar-brand">
         <img src="{% static 'misago/img/logo.png' %}" alt="">
-        {% if misago_settings.forum_branding_text %}
-          <span class="hidden-xs hidden-sm">{{ misago_settings.forum_branding_text}}</span>
+        {% if settings.forum_branding_text %}
+          <span class="hidden-xs hidden-sm">{{ settings.forum_branding_text}}</span>
         {% endif %}
       </a>
     {% endif %}
@@ -46,7 +46,7 @@
   </div><!-- /full navbar -->
 
   <ul class="nav navbar-nav navbar-compact-nav" itemscope itemtype="http://schema.org/SiteNavigationElement">
-    {% if misago_settings.forum_branding_display %}
+    {% if settings.forum_branding_display %}
       <li>
         <a href="{% url 'misago:index' %}" class="brand-link">
           <img src="{% static 'misago/img/logo.png' %}" alt="">

+ 11 - 11
misago/templates/misago/threadslist/threads.html

@@ -6,10 +6,10 @@
   {% if THREADS_ON_INDEX and paginator.page == 1 %}
     {% if list_name %}
       {{ list_name }} | {{ block.super }}
-    {% elif misago_settings.forum_index_title %}
-      {{ misago_settings.forum_index_title }}
+    {% elif settings.forum_index_title %}
+      {{ settings.forum_index_title }}
     {% else %}
-      {{ misago_settings.forum_name }}
+      {{ settings.forum_name }}
     {% endif %}
   {% else %}
     {% if list_name %}
@@ -24,18 +24,18 @@
 
 
 {% block meta-description %}
-  {% if THREADS_ON_INDEX and misago_settings.forum_index_meta_description %}
-    {{ misago_settings.forum_index_meta_description }}
+  {% if THREADS_ON_INDEX and settings.forum_index_meta_description %}
+    {{ settings.forum_index_meta_description }}
   {% endif %}
 {% endblock meta-description %}
 
 
 {% block og-title %}
   {% if THREADS_ON_INDEX %}
-    {% if misago_settings.forum_index_title %}
-      {{ misago_settings.forum_index_title }}
+    {% if settings.forum_index_title %}
+      {{ settings.forum_index_title }}
     {% else %}
-      {{ misago_settings.forum_name }}
+      {{ settings.forum_name }}
     {% endif %}
   {% else %}
     {% trans "Threads" %}
@@ -54,10 +54,10 @@
         <div class="row">
           <div class="col-xs-12">
             {% if THREADS_ON_INDEX %}
-              {% if misago_settings.forum_index_title %}
-                <h1>{{ misago_settings.forum_index_title }}</h1>
+              {% if settings.forum_index_title %}
+                <h1>{{ settings.forum_index_title }}</h1>
               {% else %}
-                <h1>{{ misago_settings.forum_name }}</h1>
+                <h1>{{ settings.forum_name }}</h1>
               {% endif %}
             {% else %}
               <h1>{% trans "Threads" %}</h1>

+ 1 - 0
misago/threads/api/postendpoints/split.py

@@ -15,6 +15,7 @@ def posts_split_endpoint(request, thread):
     serializer = SplitPostsSerializer(
         data=request.data,
         context={
+            'settings': request.settings,
             'thread': thread,
             'user_acl': request.user_acl,
         },

+ 1 - 0
misago/threads/api/postingendpoint/__init__.py

@@ -29,6 +29,7 @@ class PostingEndpoint(object):
         self.kwargs.update({
             'mode': mode,
             'request': request,
+            'settings': request.settings,
             'user': request.user,
             'user_acl': request.user_acl,
         })

+ 3 - 2
misago/threads/api/postingendpoint/emailnotification.py

@@ -50,7 +50,8 @@ class EmailNotificationMiddleware(PostingMiddleware):
             'misago/emails/thread/reply',
             sender=self.user,
             context={
-                'thread': self.thread,
-                'post': self.post,
+                "settings": self.request.settings,
+                "thread": self.thread,
+                "post": self.post,
             },
         )

+ 11 - 3
misago/threads/api/postingendpoint/reply.py

@@ -4,7 +4,9 @@ from django.utils.translation import gettext_lazy
 
 from misago.markup import common_flavour
 from misago.threads.checksums import update_post_checksum
-from misago.threads.validators import validate_post, validate_post_length, validate_title
+from misago.threads.validators import (
+    validate_post, validate_post_length, validate_thread_title
+)
 from misago.users.audittrail import create_audit_trail
 
 from . import PostingEndpoint, PostingMiddleware
@@ -78,12 +80,15 @@ class ReplyMiddleware(PostingMiddleware):
 
 class ReplySerializer(serializers.Serializer):
     post = serializers.CharField(
-        validators=[validate_post_length],
         error_messages={
             'required': gettext_lazy("You have to enter a message."),
         }
     )
 
+    def validate_post(self, data):
+        validate_post_length(self.context["settings"], data)
+        return data
+
     def validate(self, data):
         if data.get('post'):
             data['parsing_result'] = self.parse_post(data['post'])
@@ -100,8 +105,11 @@ class ReplySerializer(serializers.Serializer):
 
 class ThreadSerializer(ReplySerializer):
     title = serializers.CharField(
-        validators=[validate_title],
         error_messages={
             'required': gettext_lazy("You have to enter thread title."),
         }
     )
+
+    def validate_title(self, data):
+        validate_thread_title(self.context["settings"], data)
+        return data

+ 4 - 4
misago/threads/api/postingendpoint/subscribe.py

@@ -20,20 +20,20 @@ class SubscribeMiddleware(PostingMiddleware):
         if self.mode != PostingEndpoint.START:
             return
 
-        if self.user.subscribe_to_started_threads == UserModel.SUBSCRIBE_NONE:
+        if self.user.subscribe_to_started_threads == UserModel.SUBSCRIPTION_NONE:
             return
 
         self.user.subscription_set.create(
             category=self.thread.category,
             thread=self.thread,
-            send_email=self.user.subscribe_to_started_threads == UserModel.SUBSCRIBE_ALL,
+            send_email=self.user.subscribe_to_started_threads == UserModel.SUBSCRIPTION_ALL,
         )
 
     def subscribe_replied_thread(self):
         if self.mode != PostingEndpoint.REPLY:
             return
 
-        if self.user.subscribe_to_replied_threads == UserModel.SUBSCRIBE_NONE:
+        if self.user.subscribe_to_replied_threads == UserModel.SUBSCRIPTION_NONE:
             return
 
         try:
@@ -55,5 +55,5 @@ class SubscribeMiddleware(PostingMiddleware):
         self.user.subscription_set.create(
             category=self.thread.category,
             thread=self.thread,
-            send_email=self.user.subscribe_to_replied_threads == UserModel.SUBSCRIBE_ALL,
+            send_email=self.user.subscribe_to_replied_threads == UserModel.SUBSCRIPTION_ALL,
         )

+ 4 - 1
misago/threads/api/threadendpoints/merge.py

@@ -88,7 +88,10 @@ def thread_merge_endpoint(request, thread, viewmodel):
 def threads_merge_endpoint(request):
     serializer = MergeThreadsSerializer(
         data=request.data,
-        context={'user_acl': request.user_acl},
+        context={
+            'settings': request.settings,
+            'user_acl': request.user_acl,
+        },
     )
 
     if not serializer.is_valid():

+ 2 - 2
misago/threads/api/threadendpoints/patch.py

@@ -26,7 +26,7 @@ from misago.threads.permissions import (
     allow_start_thread, allow_unhide_thread, allow_unmark_best_answer
 )
 from misago.threads.serializers import ThreadParticipantSerializer
-from misago.threads.validators import validate_title
+from misago.threads.validators import validate_thread_title
 
 PATCH_LIMIT = settings.MISAGO_THREADS_PER_PAGE + settings.MISAGO_THREADS_TAIL
 
@@ -54,7 +54,7 @@ def patch_title(request, thread, value):
         raise PermissionDenied(_('Not a valid string.'))
 
     try:
-        validate_title(value_cleaned)
+        validate_thread_title(request.settings, value_cleaned)
     except ValidationError as e:
         raise PermissionDenied(e.args[0])
 

+ 1 - 4
misago/threads/migrations/0004_update_settings.py

@@ -1,7 +1,6 @@
 from django.db import migrations
 
-from misago.conf.migrationutils import delete_settings_cache, migrate_settings_group
-
+from misago.conf.migrationutils import migrate_settings_group
 
 _ = lambda s: s
 
@@ -68,8 +67,6 @@ def update_threads_settings(apps, schema_editor):
         }
     )
 
-    delete_settings_cache()
-
 
 class Migration(migrations.Migration):
 

+ 5 - 2
misago/threads/participants.py

@@ -149,8 +149,11 @@ def build_noticiation_email(request, thread, user):
     }
 
     return build_mail(
-        user, subject % subject_formats, 'misago/emails/privatethread/added',
-        sender=request.user, context={'thread': thread}
+        user,
+        subject % subject_formats,
+        'misago/emails/privatethread/added',
+        sender=request.user,
+        context={'settings': request.settings, 'thread': thread},
     )
 
 

+ 4 - 2
misago/threads/serializers/moderation.py

@@ -17,7 +17,7 @@ from misago.threads.permissions import (
     can_start_thread, exclude_invisible_posts)
 from misago.threads.threadtypes import trees_map
 from misago.threads.utils import get_thread_id_from_url
-from misago.threads.validators import validate_category, validate_title
+from misago.threads.validators import validate_category, validate_thread_title
 
 
 POSTS_LIMIT = settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL
@@ -256,7 +256,9 @@ class NewThreadSerializer(serializers.Serializer):
     is_closed = serializers.NullBooleanField(required=False)
 
     def validate_title(self, title):
-        return validate_title(title)
+        settings = self.context["settings"]
+        validate_thread_title(settings, title)
+        return title
 
     def validate_category(self, category_id):
         user_acl = self.context['user_acl']

+ 4 - 1
misago/threads/tests/test_anonymize_data.py

@@ -2,7 +2,9 @@ from django.contrib.auth import get_user_model
 from django.test import RequestFactory
 from django.urls import reverse
 
+from misago.cache.versions import get_cache_versions
 from misago.categories.models import Category
+from misago.conf.dynamicsettings import DynamicSettings
 from misago.users.testutils import AuthenticatedUserTestCase
 
 from misago.threads import testutils
@@ -32,7 +34,8 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
         request = self.factory.get('/customer/details')
         request.user = user or self.user
         request.user_ip = '127.0.0.1'
-
+        request.cache_versions = get_cache_versions()
+        request.settings = DynamicSettings(request.cache_versions)
         request.include_frontend_context = False
         request.frontend_context = {}
 

+ 2 - 1
misago/threads/tests/test_attachments_middleware.py

@@ -6,6 +6,7 @@ from misago.acl import useracl
 from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.conf import settings
+from misago.conftest import get_cache_versions
 from misago.threads import testutils
 from misago.threads.api.postingendpoint import PostingEndpoint
 from misago.threads.api.postingendpoint.attachments import (
@@ -13,7 +14,7 @@ from misago.threads.api.postingendpoint.attachments import (
 from misago.threads.models import Attachment, AttachmentType
 from misago.users.testutils import AuthenticatedUserTestCase
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 def patch_attachments_acl(acl_patch=None):

+ 2 - 1
misago/threads/tests/test_events.py

@@ -7,11 +7,12 @@ from django.utils import timezone
 from misago.acl import useracl
 from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
+from misago.conftest import get_cache_versions
 from misago.threads.events import record_event
 from misago.threads.models import Thread
 
 User = get_user_model()
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 class EventsApiTests(TestCase):

+ 10 - 10
misago/threads/tests/test_subscription_middleware.py

@@ -25,8 +25,8 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_start_threads": True})
     def test_dont_subscribe(self):
         """middleware makes no subscription to thread"""
-        self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NONE
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NOTIFY
+        self.user.subscribe_to_started_threads = UserModel.SUBSCRIPTION_NONE
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_NOTIFY
         self.user.save()
 
         response = self.client.post(
@@ -45,7 +45,7 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_start_threads": True})
     def test_subscribe(self):
         """middleware subscribes thread"""
-        self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NOTIFY
+        self.user.subscribe_to_started_threads = UserModel.SUBSCRIPTION_NOTIFY
         self.user.save()
 
         response = self.client.post(
@@ -68,7 +68,7 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_start_threads": True})
     def test_email_subscribe(self):
         """middleware subscribes thread with an email"""
-        self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_ALL
+        self.user.subscribe_to_started_threads = UserModel.SUBSCRIPTION_ALL
         self.user.save()
 
         response = self.client.post(
@@ -102,8 +102,8 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_reply_threads": True})
     def test_dont_subscribe(self):
         """middleware makes no subscription to thread"""
-        self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NOTIFY
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NONE
+        self.user.subscribe_to_started_threads = UserModel.SUBSCRIPTION_NOTIFY
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_NONE
         self.user.save()
 
         response = self.client.post(
@@ -119,7 +119,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_reply_threads": True})
     def test_subscribe(self):
         """middleware subscribes thread"""
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NOTIFY
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_NOTIFY
         self.user.save()
 
         response = self.client.post(
@@ -138,7 +138,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_reply_threads": True})
     def test_email_subscribe(self):
         """middleware subscribes thread with an email"""
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_ALL
         self.user.save()
 
         response = self.client.post(
@@ -157,7 +157,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_reply_threads": True})
     def test_subscribe_with_events(self):
         """middleware omits events when testing for replied thread"""
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_ALL
         self.user.save()
 
         # set event in thread
@@ -181,7 +181,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_user_acl({"can_omit_flood_protection": True})
     def test_dont_subscribe_replied(self):
         """middleware omits threads user already replied"""
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_ALL
         self.user.save()
 
         response = self.client.post(

+ 2 - 1
misago/threads/tests/test_threads_editor_api.py

@@ -5,6 +5,7 @@ from django.urls import reverse
 from misago.acl import useracl
 from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
+from misago.conftest import get_cache_versions
 from misago.threads import testutils
 from misago.threads.models import Attachment
 from misago.threads.serializers import AttachmentSerializer
@@ -14,7 +15,7 @@ from misago.users.testutils import AuthenticatedUserTestCase
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 class EditorApiTestCase(AuthenticatedUserTestCase):

+ 2 - 1
misago/threads/tests/test_threads_merge_api.py

@@ -5,6 +5,7 @@ from django.urls import reverse
 from misago.acl import useracl
 from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
+from misago.conftest import get_cache_versions
 from misago.readtracker import poststracker
 from misago.threads import testutils
 from misago.threads.models import Poll, PollVote, Post, Thread
@@ -14,7 +15,7 @@ from misago.threads.test import patch_category_acl, patch_other_category_acl
 
 from .test_threads_api import ThreadsApiTestCase
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 class ThreadsMergeApiTests(ThreadsApiTestCase):

+ 2 - 1
misago/threads/tests/test_threadview.py

@@ -4,6 +4,7 @@ from misago.acl import useracl
 from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.conf import settings
+from misago.conftest import get_cache_versions
 from misago.threads import testutils
 from misago.threads.checksums import update_post_checksum
 from misago.threads.events import record_event
@@ -11,7 +12,7 @@ from misago.threads.moderation import threads as threads_moderation
 from misago.threads.moderation import hide_post
 from misago.users.testutils import AuthenticatedUserTestCase
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 def patch_category_acl(new_acl=None):

+ 33 - 26
misago/threads/tests/test_validators.py

@@ -1,36 +1,42 @@
+from unittest.mock import Mock
+
 from django.core.exceptions import ValidationError
 from django.test import TestCase
 
-from misago.conf import settings
-from misago.threads.validators import validate_post_length, validate_title
+from misago.threads.validators import validate_post_length, validate_thread_title
 
 
 class ValidatePostLengthTests(TestCase):
-    def test_valid_post(self):
+    def test_valid_post_length_passes_validation(self):
         """valid post passes validation"""
-        validate_post_length("Lorem ipsum dolor met sit amet elit.")
+        settings = Mock(post_length_min=1, post_length_max=50)
+        validate_post_length(settings, "Lorem ipsum dolor met sit amet elit.")
 
-    def test_empty_post(self):
+    def test_for_empty_post_validation_error_is_raised(self):
         """empty post is rejected"""
+        settings = Mock(post_length_min=3)
         with self.assertRaises(ValidationError):
-            validate_post_length("")
+            validate_post_length(settings, "")
 
-    def test_too_short_post(self):
+    def test_for_too_short_post_validation_error_is_raised(self):
         """too short post is rejected"""
+        settings = Mock(post_length_min=3)
         with self.assertRaises(ValidationError):
-            post = 'a' * settings.post_length_min
-            validate_post_length(post[1:])
+            validate_post_length(settings, "a")
 
-    def test_too_long_post(self):
+    def test_for_too_long_post_validation_error_is_raised(self):
         """too long post is rejected"""
+        settings = Mock(post_length_min=1, post_length_max=2)
         with self.assertRaises(ValidationError):
             post = 'a' * settings.post_length_max
-            validate_post_length(post * 2)
+            validate_post_length(settings, "abc")
+
 
+class ValidateThreadTitleTests(TestCase):
+    def test_valid_thread_titles_pass_validation(self):
+        """validate_thread_title is ok with valid titles"""
+        settings = Mock(thread_title_length_min=1, thread_title_length_max=50)
 
-class ValidateTitleTests(TestCase):
-    def test_valid_titles(self):
-        """validate_title is ok with valid titles"""
         VALID_TITLES = [
             'Lorem ipsum dolor met',
             '123 456 789 112'
@@ -38,27 +44,28 @@ class ValidateTitleTests(TestCase):
         ]
 
         for title in VALID_TITLES:
-            validate_title(title)
+            validate_thread_title(settings, title)
 
-    def test_empty_title(self):
+    def test_for_empty_thread_title_validation_error_is_raised(self):
         """empty title is rejected"""
+        settings = Mock(thread_title_length_min=3)
         with self.assertRaises(ValidationError):
-            validate_title("")
+            validate_thread_title(settings, "")
 
-    def test_too_short_title(self):
+    def test_for_too_short_thread_title_validation_error_is_raised(self):
         """too short title is rejected"""
+        settings = Mock(thread_title_length_min=3)
         with self.assertRaises(ValidationError):
-            title = 'a' * settings.thread_title_length_min
-            validate_title(title[1:])
+            validate_thread_title(settings, "a")
 
-    def test_too_long_title(self):
+    def test_for_too_long_thread_title_validation_error_is_raised(self):
         """too long title is rejected"""
+        settings = Mock(thread_title_length_min=1, thread_title_length_max=2)
         with self.assertRaises(ValidationError):
-            title = 'a' * settings.thread_title_length_max
-            validate_title(title * 2)
+            validate_thread_title(settings, "abc")
 
-    def test_unsluggable_title(self):
+    def test_for_unsluggable_thread_title_valdiation_error_is_raised(self):
         """unsluggable title is rejected"""
+        settings = Mock(thread_title_length_min=1, thread_title_length_max=9)
         with self.assertRaises(ValidationError):
-            title = '--' * settings.thread_title_length_min
-            validate_title(title)
+            validate_thread_title(settings, "-#%^&-")

+ 23 - 21
misago/threads/validators.py

@@ -34,13 +34,21 @@ def validate_category(user_acl, category_id, allow_root=False):
     return category
 
 
-def validate_title(title):
-    title_len = len(title)
+def validate_thread_title(settings, title):
+    validate_thread_title_length(settings, title)
 
-    if not title_len:
-        raise ValidationError(_("You have to enter thread title."))
+    error_not_sluggable = _("Thread title should contain alpha-numeric characters.")
+    error_slug_too_long = _("Thread title is too long.")
+    validate_sluggable(error_not_sluggable, error_slug_too_long)(title)
+
+
+def validate_thread_title_length(settings, value):
+    value_len = len(value)
+
+    if not value_len:
+        raise ValidationError(_("You have to enter an thread title."))
 
-    if title_len < settings.thread_title_length_min:
+    if value_len < settings.thread_title_length_min:
         message = ngettext(
             "Thread title should be at least %(limit_value)s character long (it has %(show_value)s).",
             "Thread title should be at least %(limit_value)s characters long (it has %(show_value)s).",
@@ -49,11 +57,11 @@ def validate_title(title):
         raise ValidationError(
             message % {
                 'limit_value': settings.thread_title_length_min,
-                'show_value': title_len,
+                'show_value': value_len,
             }
         )
 
-    if title_len > settings.thread_title_length_max:
+    if value_len > settings.thread_title_length_max:
         message = ngettext(
             "Thread title cannot be longer than %(limit_value)s character (it has %(show_value)s).",
             "Thread title cannot be longer than %(limit_value)s characters (it has %(show_value)s).",
@@ -62,24 +70,18 @@ def validate_title(title):
         raise ValidationError(
             message % {
                 'limit_value': settings.thread_title_length_max,
-                'show_value': title_len,
+                'show_value': value_len,
             }
         )
 
-    error_not_sluggable = _("Thread title should contain alpha-numeric characters.")
-    error_slug_too_long = _("Thread title is too long.")
-    validate_sluggable(error_not_sluggable, error_slug_too_long)(title)
-
-    return title
-
 
-def validate_post_length(post):
-    post_len = len(post)
+def validate_post_length(settings, value):
+    value_len = len(value)
 
-    if not post_len:
+    if not value_len:
         raise ValidationError(_("You have to enter a message."))
 
-    if post_len < settings.post_length_min:
+    if value_len < settings.post_length_min:
         message = ngettext(
             "Posted message should be at least %(limit_value)s character long (it has %(show_value)s).",
             "Posted message should be at least %(limit_value)s characters long (it has %(show_value)s).",
@@ -88,11 +90,11 @@ def validate_post_length(post):
         raise ValidationError(
             message % {
                 'limit_value': settings.post_length_min,
-                'show_value': post_len,
+                'show_value': value_len,
             }
         )
 
-    if settings.post_length_max and post_len > settings.post_length_max:
+    if settings.post_length_max and value_len > settings.post_length_max:
         message = ngettext(
             "Posted message cannot be longer than %(limit_value)s character (it has %(show_value)s).",
             "Posted message cannot be longer than %(limit_value)s characters (it has %(show_value)s).",
@@ -101,7 +103,7 @@ def validate_post_length(post):
         raise ValidationError(
             message % {
                 'limit_value': settings.post_length_max,
-                'show_value': post_len,
+                'show_value': value_len,
             }
         )
 

+ 8 - 6
misago/users/api/auth.py

@@ -69,8 +69,8 @@ def get_criteria(request):
     """GET /auth/criteria/ will return password and username criteria for accounts"""
     criteria = {
         'username': {
-            'min_length': settings.username_length_min,
-            'max_length': settings.username_length_max,
+            'min_length': request.settings.username_length_min,
+            'max_length': request.settings.username_length_max,
         },
         'password': [],
     }
@@ -100,7 +100,7 @@ def send_activation(request):
 
         mail_subject = _("Activate %(user)s account on %(forum_name)s forums") % {
             'user': requesting_user.username,
-            'forum_name': settings.forum_name,
+            'forum_name': request.settings.forum_name,
         }
 
         mail_user(
@@ -108,7 +108,8 @@ def send_activation(request):
             mail_subject,
             'misago/emails/activation/by_user',
             context={
-                'activation_token': make_activation_token(requesting_user),
+                "activation_token": make_activation_token(requesting_user),
+                "settings": request.settings,
             },
         )
 
@@ -138,7 +139,7 @@ def send_password_form(request):
 
         mail_subject = _("Change %(user)s password on %(forum_name)s forums") % {
             'user': requesting_user.username,
-            'forum_name': settings.forum_name,
+            'forum_name': request.settings.forum_name,
         }
 
         confirmation_token = make_password_change_token(requesting_user)
@@ -148,7 +149,8 @@ def send_password_form(request):
             mail_subject,
             'misago/emails/change_password_form_link',
             context={
-                'confirmation_token': confirmation_token,
+                "confirmation_token": confirmation_token,
+                "settings": request.settings,
             },
         )
 

+ 3 - 5
misago/users/api/captcha.py

@@ -3,15 +3,13 @@ from rest_framework.response import Response
 
 from django.http import Http404
 
-from misago.conf import settings
-
 
 @api_view()
 def question(request):
-    if settings.qa_question:
+    if request.settings.qa_question:
         return Response({
-            'question': settings.qa_question,
-            'help_text': settings.qa_help_text,
+            'question': request.settings.qa_question,
+            'help_text': request.settings.qa_help_text,
         })
     else:
         raise Http404()

+ 22 - 12
misago/users/api/userendpoints/avatar.py

@@ -30,14 +30,14 @@ def avatar_endpoint(request, pk=None):
             status=status.HTTP_403_FORBIDDEN,
         )
 
-    avatar_options = get_avatar_options(request.user)
+    avatar_options = get_avatar_options(request, request.user)
     if request.method == 'POST':
-        return avatar_post(avatar_options, request.user, request.data)
+        return avatar_post(request, avatar_options)
     else:
         return Response(avatar_options)
 
 
-def get_avatar_options(user):
+def get_avatar_options(request, user):
     options = {
         'avatars': user.avatars,
         'generated': True,
@@ -64,7 +64,7 @@ def get_avatar_options(user):
             })
 
     # Can't have custom avatar?
-    if not settings.allow_custom_avatars:
+    if not request.settings.allow_custom_avatars:
         return options
 
     # Allow Gravatar download
@@ -90,7 +90,7 @@ def get_avatar_options(user):
 
     # Allow upload conditions
     options['upload'] = {
-        'limit': settings.avatar_upload_limit * 1024,
+        'limit': request.settings.avatar_upload_limit * 1024,
         'allowed_extensions': avatars.uploaded.ALLOWED_EXTENSIONS,
         'allowed_mime_types': avatars.uploaded.ALLOWED_MIME_TYPES,
     }
@@ -102,9 +102,14 @@ class AvatarError(Exception):
     pass
 
 
-def avatar_post(options, user, data):
+def avatar_post(request, options):
+    user = request.user
+    data = request.data
+
+    avatar_type = data.get('avatar', 'nope')
+
     try:
-        type_options = options[data.get('avatar', 'nope')]
+        type_options = options[avatar_type]
         if not type_options:
             return Response(
                 {
@@ -113,7 +118,7 @@ def avatar_post(options, user, data):
                 status=status.HTTP_400_BAD_REQUEST,
             )
 
-        rpc_handler = AVATAR_TYPES[data.get('avatar', 'nope')]
+        avatar_strategy = AVATAR_TYPES[avatar_type]
     except KeyError:
         return Response(
             {
@@ -123,7 +128,11 @@ def avatar_post(options, user, data):
         )
 
     try:
-        response_dict = {'detail': rpc_handler(user, data)}
+        if avatar_type == "upload":
+            # avatar_upload strategy requires access to request.settings
+            response_dict = {'detail': avatar_upload(request, user, data)}
+        else:
+            response_dict = {'detail': avatar_strategy(user, data)}
     except AvatarError as e:
         return Response(
             {
@@ -134,7 +143,8 @@ def avatar_post(options, user, data):
 
     user.save()
 
-    response_dict.update(get_avatar_options(user))
+    updated_options = get_avatar_options(request, user)
+    response_dict.update(updated_options)
     return Response(response_dict)
 
 
@@ -165,13 +175,13 @@ def avatar_gallery(user, data):
         raise AvatarError(_("Incorrect image."))
 
 
-def avatar_upload(user, data):
+def avatar_upload(request, user, data):
     new_avatar = data.get('image')
     if not new_avatar:
         raise AvatarError(_("No file was sent."))
 
     try:
-        avatars.uploaded.handle_uploaded_file(user, new_avatar)
+        avatars.uploaded.handle_uploaded_file(request, user, new_avatar)
     except ValidationError as e:
         raise AvatarError(e.args[0])
 

+ 11 - 3
misago/users/api/userendpoints/changeemail.py

@@ -16,16 +16,24 @@ def change_email_endpoint(request, pk=None):
     )
 
     if serializer.is_valid():
-        token = store_new_credential(request, 'email', serializer.validated_data['new_email'])
+        token = store_new_credential(
+            request, 'email', serializer.validated_data['new_email']
+        )
 
         mail_subject = _("Confirm e-mail change on %(forum_name)s forums")
-        mail_subject = mail_subject % {'forum_name': settings.forum_name}
+        mail_subject = mail_subject % {'forum_name': request.settings.forum_name}
 
         # swap address with new one so email is sent to new address
         request.user.email = serializer.validated_data['new_email']
 
         mail_user(
-            request.user, mail_subject, 'misago/emails/change_email', context={'token': token}
+            request.user,
+            mail_subject,
+            'misago/emails/change_email',
+            context={
+                "settings": request.settings,
+                "token": token,
+            },
         )
 
         message = _("E-mail change confirmation link was sent to new address.")

+ 8 - 4
misago/users/api/userendpoints/changepassword.py

@@ -3,7 +3,6 @@ from rest_framework.response import Response
 
 from django.utils.translation import gettext as _
 
-from misago.conf import settings
 from misago.core.mail import mail_user
 from misago.users.credentialchange import store_new_credential
 from misago.users.serializers import ChangePasswordSerializer
@@ -21,11 +20,16 @@ def change_password_endpoint(request, pk=None):
         )
 
         mail_subject = _("Confirm password change on %(forum_name)s forums")
-        mail_subject = mail_subject % {'forum_name': settings.forum_name}
+        mail_subject = mail_subject % {'forum_name': request.settings.forum_name}
 
         mail_user(
-            request.user, mail_subject, 'misago/emails/change_password',
-            context={'token': token}
+            request.user,
+            mail_subject,
+            'misago/emails/change_password',
+            context={
+                "settings": request.settings,
+                "token": token,
+            },
         )
 
         return Response({

+ 5 - 6
misago/users/api/userendpoints/create.py

@@ -14,14 +14,14 @@ from misago.users.forms.register import RegisterForm
 from misago.users.registration import (
     get_registration_result_json, save_user_agreements, send_welcome_email
 )
-
+from misago.users.setupnewuser import setup_new_user
 
 UserModel = get_user_model()
 
 
 @csrf_protect
 def create_endpoint(request):
-    if settings.account_activation == 'closed':
+    if request.settings.account_activation == 'closed':
         raise PermissionDenied(_("New users registrations are currently closed."))
 
     form = RegisterForm(
@@ -40,9 +40,9 @@ def create_endpoint(request):
         return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
 
     activation_kwargs = {}
-    if settings.account_activation == 'user':
+    if request.settings.account_activation == 'user':
         activation_kwargs = {'requires_activation': UserModel.ACTIVATION_USER}
-    elif settings.account_activation == 'admin':
+    elif request.settings.account_activation == 'admin':
         activation_kwargs = {'requires_activation': UserModel.ACTIVATION_ADMIN}
 
     try:
@@ -50,9 +50,7 @@ def create_endpoint(request):
             form.cleaned_data['username'],
             form.cleaned_data['email'],
             form.cleaned_data['password'],
-            create_audit_trail=True,
             joined_from_ip=request.user_ip,
-            set_default_avatar=True,
             **activation_kwargs
         )
     except IntegrityError:
@@ -63,6 +61,7 @@ def create_endpoint(request):
             status=status.HTTP_400_BAD_REQUEST,
         )
 
+    setup_new_user(request.settings, new_user)
     save_user_agreements(new_user, form)
     send_welcome_email(request, new_user)
 

+ 18 - 15
misago/users/api/userendpoints/signature.py

@@ -4,7 +4,6 @@ from rest_framework.response import Response
 from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 
-from misago.conf import settings
 from misago.core.utils import format_plaintext_for_html
 from misago.users.serializers import EditSignatureSerializer
 from misago.users.signatures import is_user_signature_valid, set_user_signature
@@ -22,19 +21,21 @@ def signature_endpoint(request):
         else:
             reason = None
 
-        return Response({
-            'detail': _("Your signature is locked. You can't change it."),
-            'reason': reason
-        },
-                        status=status.HTTP_403_FORBIDDEN)
+        return Response(
+            {
+                'detail': _("Your signature is locked. You can't change it."),
+                'reason': reason
+            },
+            status=status.HTTP_403_FORBIDDEN
+        )
 
     if request.method == 'POST':
         return edit_signature(request, user)
 
-    return get_signature_options(user)
+    return get_signature_options(request.settings, user)
 
 
-def get_signature_options(user):
+def get_signature_options(settings, user):
     options = {
         'signature': None,
         'limit': settings.signature_length_max,
@@ -53,14 +54,16 @@ def get_signature_options(user):
 
 
 def edit_signature(request, user):
-    serializer = EditSignatureSerializer(user, data=request.data)
+    serializer = EditSignatureSerializer(
+        user, data=request.data, context={"settings": request.settings}
+    )
     if serializer.is_valid():
         signature = serializer.validated_data['signature']
         set_user_signature(request, user, request.user_acl, signature)
         user.save(update_fields=['signature', 'signature_parsed', 'signature_checksum'])
-        return get_signature_options(user)
-    else:
-        return Response({
-            'detail': serializer.errors['non_field_errors'][0]
-        },
-                        status=status.HTTP_400_BAD_REQUEST)
+        return get_signature_options(request.settings, user)
+
+    return Response(
+        {'detail': serializer.errors['non_field_errors'][0]},
+        status=status.HTTP_400_BAD_REQUEST
+    )

+ 9 - 7
misago/users/api/userendpoints/username.py

@@ -4,7 +4,6 @@ from rest_framework.response import Response
 from django.db import IntegrityError
 from django.utils.translation import gettext as _
 
-from misago.conf import settings
 from misago.users.namechanges import get_username_options
 from misago.users.serializers import ChangeUsernameSerializer
 
@@ -19,7 +18,7 @@ def username_endpoint(request):
 
 def get_username_options_from_request(request):
     return get_username_options(
-        settings, request.user, request.user_acl
+        request.settings, request.user, request.user_acl
     )
 
 
@@ -41,9 +40,9 @@ def change_username(request):
         )
 
     serializer = ChangeUsernameSerializer(
-        data=request.data, context={'user': request.user}
+        data=request.data,
+        context={'settings': request.settings, 'user': request.user},
     )
-
     if serializer.is_valid():
         try:
             serializer.change_username(changed_by=request.user)
@@ -74,7 +73,10 @@ def change_username(request):
 
 def moderate_username_endpoint(request, profile):
     if request.method == 'POST':
-        serializer = ChangeUsernameSerializer(data=request.data, context={'user': profile})
+        serializer = ChangeUsernameSerializer(
+            data=request.data,
+            context={'settings': request.settings, 'user': profile},
+        )
 
         if serializer.is_valid():
             try:
@@ -99,6 +101,6 @@ def moderate_username_endpoint(request, profile):
             )
     else:
         return Response({
-            'length_min': settings.username_length_min,
-            'length_max': settings.username_length_max,
+            'length_min': request.settings.username_length_min,
+            'length_max': request.settings.username_length_max,
         })

+ 22 - 23
misago/users/avatars/uploaded.py

@@ -9,12 +9,32 @@ from misago.conf import settings
 
 from . import store
 
-
 ALLOWED_EXTENSIONS = ('.gif', '.png', '.jpg', '.jpeg')
 ALLOWED_MIME_TYPES = ('image/gif', 'image/jpeg', 'image/png', 'image/mpo')
 
 
-def validate_file_size(uploaded_file):
+def handle_uploaded_file(request, user, uploaded_file):
+    image = validate_uploaded_file(request.settings, uploaded_file)
+    store.store_temporary_avatar(user, image)
+
+
+def validate_uploaded_file(settings, uploaded_file):
+    try:
+        validate_file_size(settings, uploaded_file)
+        validate_extension(uploaded_file)
+        validate_mime(uploaded_file)
+        return validate_dimensions(uploaded_file)
+    except ValidationError as e:
+        try:
+            temporary_file_path = Path(uploaded_file.temporary_file_path())
+            if temporary_file_path.exists():
+                temporary_file_path.unlink()
+        except Exception:
+            pass
+        raise e
+
+
+def validate_file_size(settings, uploaded_file):
     upload_limit = settings.avatar_upload_limit * 1024
     if uploaded_file.size > upload_limit:
         raise ValidationError(_("Uploaded file is too big."))
@@ -53,27 +73,6 @@ def validate_dimensions(uploaded_file):
     return image
 
 
-def validate_uploaded_file(uploaded_file):
-    try:
-        validate_file_size(uploaded_file)
-        validate_extension(uploaded_file)
-        validate_mime(uploaded_file)
-        return validate_dimensions(uploaded_file)
-    except ValidationError as e:
-        try:
-            temporary_file_path = Path(uploaded_file.temporary_file_path())
-            if temporary_file_path.exists():
-                temporary_file_path.unlink()
-        except Exception:
-            pass
-        raise e
-
-
-def handle_uploaded_file(user, uploaded_file):
-    image = validate_uploaded_file(uploaded_file)
-    store.store_temporary_avatar(user, image)
-
-
 def clean_crop(image, crop):
     message = _("Crop data is invalid. Please try again.")
 

+ 10 - 10
misago/users/captcha.py

@@ -3,14 +3,12 @@ import requests
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext as _
 
-from misago.conf import settings
-
 
 def recaptcha_test(request):
     r = requests.post(
         'https://www.google.com/recaptcha/api/siteverify',
         data={
-            'secret': settings.recaptcha_secret_key,
+            'secret': request.settings.recaptcha_secret_key,
             'response': request.data.get('captcha'),
             'remoteip': request.user_ip
         }
@@ -25,15 +23,17 @@ def recaptcha_test(request):
 
 
 def qacaptcha_test(request):
-    answer = request.data.get('captcha', '').lower()
-    for predefined_answer in settings.qa_answers.lower().splitlines():
-        predefined_answer = predefined_answer.strip().lower()
-        if answer == predefined_answer:
-            break
-    else:
+    answer = request.data.get('captcha', '').lower().strip()
+    valid_answers = get_valid_qacaptcha_answers(request.settings)
+    if answer not in valid_answers:
         raise ValidationError(_("Entered answer is incorrect."))
 
 
+def get_valid_qacaptcha_answers(settings):
+    valid_answers = [i.strip() for i in settings.qa_answers.lower().splitlines()]
+    return filter(len, valid_answers)
+
+
 def nocaptcha_test(request):
     return  # no captcha means no validation
 
@@ -46,4 +46,4 @@ CAPTCHA_TESTS = {
 
 
 def test_request(request):
-    CAPTCHA_TESTS[settings.captcha_type](request)
+    CAPTCHA_TESTS[request.settings.captcha_type](request)

+ 11 - 8
misago/users/forms/admin.py

@@ -10,12 +10,12 @@ from misago.admin.forms import IsoDateTimeField, YesNoSwitch
 from misago.conf import settings
 from misago.core import threadstore
 from misago.core.validators import validate_sluggable
+
 from misago.users.models import Ban, DataDownload, Rank
 from misago.users.profilefields import profilefields
 from misago.users.utils import hash_email
 from misago.users.validators import validate_email, validate_username
 
-
 UserModel = get_user_model()
 
 
@@ -28,9 +28,15 @@ class UserBaseForm(forms.ModelForm):
         model = UserModel
         fields = ['username', 'email', 'title']
 
+    def __init__(self, *args, **kwargs):
+        self.request = kwargs.pop('request')
+        self.settings = self.request.settings
+
+        super().__init__(*args, **kwargs)
+
     def clean_username(self):
         data = self.cleaned_data['username']
-        validate_username(data, exclude=self.instance)
+        validate_username(self.settings, data, exclude=self.instance)
         return data
 
     def clean_email(self):
@@ -165,10 +171,10 @@ class EditUserForm(UserBaseForm):
     )
 
     subscribe_to_started_threads = forms.TypedChoiceField(
-        label=_("Started threads"), coerce=int, choices=UserModel.SUBSCRIBE_CHOICES
+        label=_("Started threads"), coerce=int, choices=UserModel.SUBSCRIPTION_CHOICES
     )
     subscribe_to_replied_threads = forms.TypedChoiceField(
-        label=_("Replid threads"), coerce=int, choices=UserModel.SUBSCRIBE_CHOICES
+        label=_("Replid threads"), coerce=int, choices=UserModel.SUBSCRIPTION_CHOICES
     )
 
     class Meta:
@@ -191,10 +197,7 @@ class EditUserForm(UserBaseForm):
         ]
 
     def __init__(self, *args, **kwargs):
-        self.request = kwargs.pop('request')
-
         super().__init__(*args, **kwargs)
-
         profilefields.add_fields_to_admin_form(self.request, self.instance, self)
 
     def get_profile_fields_groups(self):
@@ -214,7 +217,7 @@ class EditUserForm(UserBaseForm):
     def clean_signature(self):
         data = self.cleaned_data['signature']
 
-        length_limit = settings.signature_length_max
+        length_limit = self.settings.signature_length_max
         if len(data) > length_limit:
             raise forms.ValidationError(
                 ngettext(

+ 8 - 5
misago/users/forms/register.py

@@ -4,16 +4,18 @@ from django.contrib.auth.password_validation import validate_password
 from django.core.exceptions import ValidationError
 from django.utils.translation import gettext as _
 
-from misago.users import validators
 from misago.users.bans import get_email_ban, get_ip_ban, get_username_ban
+from misago.users.validators import (
+    validate_email, validate_new_registration, validate_username
+)
 
 
 UserModel = get_user_model()
 
 
 class BaseRegisterForm(forms.Form):
-    username = forms.CharField(validators=[validators.validate_username])
-    email = forms.CharField(validators=[validators.validate_email])
+    username = forms.CharField()
+    email = forms.CharField(validators=[validate_email])
 
     terms_of_service = forms.IntegerField(required=False)
     privacy_policy = forms.IntegerField(required=False)
@@ -26,6 +28,7 @@ class BaseRegisterForm(forms.Form):
     def clean_username(self):
         data = self.cleaned_data['username']
 
+        validate_username(self.request.settings, data)
         ban = get_username_ban(data, registration_only=True)
         if ban:
             if ban.user_message:
@@ -67,7 +70,7 @@ class SocialAuthRegisterForm(BaseRegisterForm):
         self.clean_agreements(cleaned_data)
         self.raise_if_ip_banned()
 
-        validators.validate_new_registration(self.request, cleaned_data, self)
+        validate_new_registration(self.request, cleaned_data, self)
 
         return cleaned_data
 
@@ -99,6 +102,6 @@ class RegisterForm(BaseRegisterForm):
         except forms.ValidationError as e:
             self.add_error('password', e)
 
-        validators.validate_new_registration(self.request, cleaned_data, self.add_error)
+        validate_new_registration(self.request, cleaned_data, self.add_error)
 
         return cleaned_data

+ 15 - 10
misago/users/management/commands/createsuperuser.py

@@ -12,10 +12,13 @@ from django.core.management.base import BaseCommand
 from django.db import DEFAULT_DB_ALIAS, IntegrityError
 from django.utils.encoding import force_str
 
-from misago.users.validators import validate_email, validate_username
+from misago.cache.versions import get_cache_versions
+from misago.conf.dynamicsettings import DynamicSettings
 
+from misago.users.setupnewuser import setup_new_user
+from misago.users.validators import validate_email, validate_username
 
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class NotRunningInTTYException(Exception):
@@ -78,11 +81,14 @@ class Command(BaseCommand):
         interactive = options.get('interactive')
         verbosity = int(options.get('verbosity', 1))
 
+        cache_versions = get_cache_versions()
+        settings = DynamicSettings(cache_versions)
+
         # Validate initial inputs
         if username is not None:
             try:
                 username = username.strip()
-                validate_username(username)
+                validate_username(settings, username)
             except ValidationError as e:
                 self.stderr.write('\n'.join(e.messages))
                 username = None
@@ -103,7 +109,7 @@ class Command(BaseCommand):
         if not interactive:
             if username and email and password:
                 # Call User manager's create_superuser using our wrapper
-                self.create_superuser(username, email, password, verbosity)
+                self.create_superuser(username, email, password, settings, verbosity)
         else:
             try:
                 if hasattr(self.stdin, 'isatty') and not self.stdin.isatty():
@@ -142,7 +148,7 @@ class Command(BaseCommand):
                         continue
                     try:
                         validate_password(
-                            raw_value, user=UserModel(username=username, email=email)
+                            raw_value, user=User(username=username, email=email)
                         )
                     except ValidationError as e:
                         self.stderr.write('\n'.join(e.messages))
@@ -152,7 +158,7 @@ class Command(BaseCommand):
                     password = raw_value
 
                 # Call User manager's create_superuser using our wrapper
-                self.create_superuser(username, email, password, verbosity)
+                self.create_superuser(username, email, password, settings, verbosity)
 
             except KeyboardInterrupt:
                 self.stderr.write("\nOperation cancelled.")
@@ -164,11 +170,10 @@ class Command(BaseCommand):
                     "to create one manually."
                 )
 
-    def create_superuser(self, username, email, password, verbosity):
+    def create_superuser(self, username, email, password, settings, verbosity):
         try:
-            user = UserModel.objects.create_superuser(
-                username, email, password, set_default_avatar=True
-            )
+            user = User.objects.create_superuser(username, email, password)
+            setup_new_user(settings, user)
 
             if verbosity >= 1:
                 message = "Superuser #%s has been created successfully."

+ 6 - 0
misago/users/management/commands/prepareuserdatadownloads.py

@@ -3,7 +3,9 @@ import logging
 from django.core.management.base import BaseCommand
 from django.utils.translation import gettext
 
+from misago.cache.versions import get_cache_versions
 from misago.conf import settings
+from misago.conf.dynamicsettings import DynamicSettings
 from misago.core.mail import mail_user
 from misago.core.pgutils import chunk_queryset
 from misago.users.datadownloads import prepare_user_data_download
@@ -25,6 +27,9 @@ class Command(BaseCommand):
                 "this feature to work.")
             return
         
+        cache_versions = get_cache_versions()
+        dynamic_settings = DynamicSettings(cache_versions)
+
         downloads_prepared = 0
         queryset = DataDownload.objects.select_related('user')
         queryset = queryset.filter(status=DataDownload.STATUS_PENDING)
@@ -35,6 +40,7 @@ class Command(BaseCommand):
                 mail_user(user, subject, 'misago/emails/data_download', context={
                     'data_download': data_download,
                     'expires_in': settings.MISAGO_USER_DATA_DOWNLOADS_EXPIRE_IN_HOURS,
+                    "settings": dynamic_settings,
                 })
 
                 downloads_prepared += 1

+ 1 - 4
misago/users/migrations/0006_update_settings.py

@@ -1,8 +1,7 @@
 # Generated by Django 1.10.5 on 2017-02-05 14:34
 from django.db import migrations
 
-from misago.conf.migrationutils import delete_settings_cache, migrate_settings_group
-
+from misago.conf.migrationutils import migrate_settings_group
 
 _ = lambda s: s
 
@@ -165,8 +164,6 @@ def update_users_settings(apps, schema_editor):
         }
     )
 
-    delete_settings_cache()
-
 
 class Migration(migrations.Migration):
 

+ 2 - 1
misago/users/models/__init__.py

@@ -1,5 +1,6 @@
 from .rank import Rank
-from .user import AnonymousUser, Online, User, UsernameChange
+from .online import Online
+from .user import AnonymousUser, User, UsernameChange
 from .activityranking import ActivityRanking
 from .avatar import Avatar
 from .audittrail import AuditTrail

+ 19 - 0
misago/users/models/online.py

@@ -0,0 +1,19 @@
+from django.conf import settings
+from django.db import models
+from django.utils import timezone
+
+
+class Online(models.Model):
+    user = models.OneToOneField(
+        settings.AUTH_USER_MODEL,
+        primary_key=True,
+        related_name='online_tracker',
+        on_delete=models.CASCADE,
+    )
+    last_click = models.DateTimeField(default=timezone.now)
+
+    def save(self, *args, **kwargs):
+        try:
+            super().save(*args, **kwargs)
+        except IntegrityError:
+            pass  # first come is first serve in online tracker

+ 2 - 0
misago/users/models/rank.py

@@ -40,6 +40,8 @@ class Rank(models.Model):
             self.set_order()
         else:
             clear_acl_cache()
+        if not self.slug:
+            self.slug = slugify(self.name)
         return super().save(*args, **kwargs)
 
     def delete(self, *args, **kwargs):

+ 44 - 95
misago/users/models/user.py

@@ -20,102 +20,67 @@ from misago.users.audittrail import create_user_audit_trail
 from misago.users.signatures import is_user_signature_valid
 from misago.users.utils import hash_email
 
+from .online import Online
 from .rank import Rank
 
 
 class UserManager(BaseUserManager):
-    @transaction.atomic
-    def create_user(
-            self, username, email, password=None, create_audit_trail=False,
-            joined_from_ip=None, set_default_avatar=False, **extra_fields):
-        from misago.users.validators import validate_email, validate_username
-
-        email = self.normalize_email(email)
-        username = self.model.normalize_username(username)
-
+    def _create_user(self, username, email, password, **extra_fields):
+        """
+        Create and save a user with the given username, email, and password.
+        """
+        if not username:
+            raise ValueError("User must have an username.")
         if not email:
-            raise ValueError(_("User must have an email address."))
-
-        WATCH_DICT = {
-            'no': self.model.SUBSCRIBE_NONE,
-            'watch': self.model.SUBSCRIBE_NOTIFY,
-            'watch_email': self.model.SUBSCRIBE_ALL,
-        }
-
-        if not 'subscribe_to_started_threads' in extra_fields:
-            new_value = WATCH_DICT[settings.subscribe_start]
-            extra_fields['subscribe_to_started_threads'] = new_value
-
-        if not 'subscribe_to_replied_threads' in extra_fields:
-            new_value = WATCH_DICT[settings.subscribe_reply]
-            extra_fields['subscribe_to_replied_threads'] = new_value
-
-        extra_fields.update({'is_staff': False, 'is_superuser': False})
-
-        now = timezone.now()
-        user = self.model(
-            last_login=now, 
-            joined_on=now, 
-            joined_from_ip=joined_from_ip,
-            **extra_fields
-        )
+            raise ValueError("User must have an email address.")
 
+        user = self.model(**extra_fields)
         user.set_username(username)
         user.set_email(email)
         user.set_password(password)
 
-        validate_username(username)
-        validate_email(email)
-
         if not 'rank' in extra_fields:
             user.rank = Rank.objects.get_default()
 
+        now = timezone.now()
+        user.last_login = now
+        user.joined_on = now
+
         user.save(using=self._db)
+        self._assert_user_has_authenticated_role(user)
 
-        if set_default_avatar:
-            avatars.set_default_avatar(
-                user, settings.default_avatar, settings.default_gravatar_fallback
-            )
-        else:
-            # just for test purposes
-            user.avatars = [{'size': 400, 'url': 'http://placekitten.com/400/400'}]
+        Online.objects.create(user=user, last_click=now)
 
+        return user
+
+    def _assert_user_has_authenticated_role(self, user):
         authenticated_role = Role.objects.get(special_role='authenticated')
         if authenticated_role not in user.roles.all():
             user.roles.add(authenticated_role)
         user.update_acl_key()
+        user.save(update_fields=['acl_key'])
 
-        user.save(update_fields=['avatars', 'acl_key'])
-
-        if create_audit_trail:
-            create_user_audit_trail(user, user.joined_from_ip, user)
-
-        # populate online tracker with default value
-        Online.objects.create(user=user, last_click=now)
+    def create_user(self, username, email=None, password=None, **extra_fields):
+        extra_fields.setdefault('is_staff', False)
+        extra_fields.setdefault('is_superuser', False)
+        return self._create_user(username, email, password, **extra_fields)
 
-        return user
+    def create_superuser(self, username, email, password=None, **extra_fields):
+        extra_fields.setdefault('is_staff', True)
+        extra_fields.setdefault('is_superuser', True)
 
-    @transaction.atomic
-    def create_superuser(self, username, email, password, set_default_avatar=False):
-        user = self.create_user(
-            username,
-            email,
-            password=password,
-            set_default_avatar=set_default_avatar,
-        )
+        if extra_fields.get('is_staff') is not True:
+            raise ValueError('Superuser must have is_staff=True.')
+        if extra_fields.get('is_superuser') is not True:
+            raise ValueError('Superuser must have is_superuser=True.')
 
         try:
-            user.rank = Rank.objects.get(name=_("Forum team"))
-            user.update_acl_key()
+            if not extra_fields.get('rank'):
+                extra_fields["rank"] = Rank.objects.get(name=_("Forum team"))
         except Rank.DoesNotExist:
             pass
 
-        user.is_staff = True
-        user.is_superuser = True
-
-        updated_fields = ('rank', 'acl_key', 'is_staff', 'is_superuser')
-        user.save(update_fields=updated_fields, using=self._db)
-        return user
+        return self._create_user(username, email, password, **extra_fields)
 
     def get_by_username(self, username):
         return self.get(slug=slugify(username))
@@ -134,14 +99,14 @@ class User(AbstractBaseUser, PermissionsMixin):
     ACTIVATION_USER = 1
     ACTIVATION_ADMIN = 2
 
-    SUBSCRIBE_NONE = 0
-    SUBSCRIBE_NOTIFY = 1
-    SUBSCRIBE_ALL = 2
+    SUBSCRIPTION_NONE = 0
+    SUBSCRIPTION_NOTIFY = 1
+    SUBSCRIPTION_ALL = 2
 
-    SUBSCRIBE_CHOICES = [
-        (SUBSCRIBE_NONE, _("No")),
-        (SUBSCRIBE_NOTIFY, _("Notify")),
-        (SUBSCRIBE_ALL, _("Notify with e-mail")),
+    SUBSCRIPTION_CHOICES = [
+        (SUBSCRIPTION_NONE, _("No")),
+        (SUBSCRIPTION_NOTIFY, _("Notify")),
+        (SUBSCRIPTION_ALL, _("Notify with e-mail")),
     ]
 
     LIMIT_INVITES_TO_NONE = 0
@@ -251,12 +216,12 @@ class User(AbstractBaseUser, PermissionsMixin):
     sync_unread_private_threads = models.BooleanField(default=False)
 
     subscribe_to_started_threads = models.PositiveIntegerField(
-        default=SUBSCRIBE_NONE,
-        choices=SUBSCRIBE_CHOICES,
+        default=SUBSCRIPTION_NONE,
+        choices=SUBSCRIPTION_CHOICES,
     )
     subscribe_to_replied_threads = models.PositiveIntegerField(
-        default=SUBSCRIBE_NONE,
-        choices=SUBSCRIBE_CHOICES,
+        default=SUBSCRIPTION_NONE,
+        choices=SUBSCRIPTION_CHOICES,
     )
 
     threads = models.PositiveIntegerField(default=0)
@@ -465,22 +430,6 @@ class User(AbstractBaseUser, PermissionsMixin):
             return False
 
 
-class Online(models.Model):
-    user = models.OneToOneField(
-        settings.AUTH_USER_MODEL,
-        primary_key=True,
-        related_name='online_tracker',
-        on_delete=models.CASCADE,
-    )
-    last_click = models.DateTimeField(default=timezone.now)
-
-    def save(self, *args, **kwargs):
-        try:
-            super().save(*args, **kwargs)
-        except IntegrityError:
-            pass  # first come is first serve in online tracker
-
-
 class UsernameChange(models.Model):
     user = models.ForeignKey(
         settings.AUTH_USER_MODEL,

+ 9 - 2
misago/users/registration.py

@@ -1,6 +1,5 @@
 from django.utils.translation import gettext as _
 
-from misago.conf import settings
 from misago.core.mail import mail_user
 from misago.legal.models import Agreement
 from misago.legal.utils import save_user_agreement_acceptance
@@ -8,11 +7,18 @@ from misago.users.tokens import make_activation_token
 
 
 def send_welcome_email(request, user):
+    settings = request.settings
+
     mail_subject = _("Welcome on %(forum_name)s forums!")
     mail_subject = mail_subject % {'forum_name': settings.forum_name}
 
     if not user.requires_activation:
-        mail_user(user, mail_subject, 'misago/emails/register/complete')
+        mail_user(
+            user,
+            mail_subject,
+            'misago/emails/register/complete',
+            context={"settings": settings},
+        )
         return
 
     activation_token = make_activation_token(user)
@@ -28,6 +34,7 @@ def send_welcome_email(request, user):
             'activation_token': activation_token,
             'activation_by_admin': activation_by_admin,
             'activation_by_user': activation_by_user,
+            'settings': settings,
         }
     )
 

+ 8 - 6
misago/users/serializers/options.py

@@ -4,7 +4,6 @@ from django.contrib.auth import get_user_model, logout
 from django.contrib.auth.password_validation import validate_password
 from django.utils.translation import gettext as _
 
-from misago.conf import settings
 from misago.users.online.tracker import clear_tracking
 from misago.users.permissions import allow_delete_own_account
 from misago.users.validators import validate_email, validate_username
@@ -48,6 +47,7 @@ class EditSignatureSerializer(serializers.ModelSerializer):
         fields = ['signature']
 
     def validate(self, data):
+        settings = self.context["settings"]
         if len(data.get('signature', '')) > settings.signature_length_max:
             raise serializers.ValidationError(_("Signature is too long."))
 
@@ -59,20 +59,22 @@ class ChangeUsernameSerializer(serializers.Serializer):
 
     def validate(self, data):
         username = data.get('username')
-
         if not username:
             raise serializers.ValidationError(_("Enter new username."))
 
-        if username == self.context['user'].username:
+        user = self.context['user']
+        if username == user.username:
             raise serializers.ValidationError(_("New username is same as current one."))
 
-        validate_username(username)
+        settings = self.context['settings']
+        validate_username(settings, username)
 
         return data
 
     def change_username(self, changed_by):
-        self.context['user'].set_username(self.validated_data['username'], changed_by=changed_by)
-        self.context['user'].save(update_fields=['username', 'slug'])
+        user = self.context['user']
+        user.set_username(self.validated_data['username'], changed_by=changed_by)
+        user.save(update_fields=['username', 'slug'])
 
 
 class ChangePasswordSerializer(serializers.Serializer):

+ 29 - 0
misago/users/setupnewuser.py

@@ -0,0 +1,29 @@
+from .avatars import set_default_avatar
+from .audittrail import create_user_audit_trail
+from .models import User
+
+
+def setup_new_user(settings, user):
+    set_default_subscription_options(settings, user)
+    
+    set_default_avatar(
+        user, settings.default_avatar, settings.default_gravatar_fallback
+    )
+
+    if user.joined_from_ip:
+        create_user_audit_trail(user, user.joined_from_ip, user)
+
+
+SUBSCRIPTION_CHOICES = {
+    'no': User.SUBSCRIPTION_NONE,
+    'watch': User.SUBSCRIPTION_NOTIFY,
+    'watch_email': User.SUBSCRIPTION_ALL,
+}
+
+
+def set_default_subscription_options(settings, user):
+    started_threads = SUBSCRIPTION_CHOICES[settings.subscribe_start]
+    user.subscribe_to_started_threads = started_threads
+    
+    replied_threads = SUBSCRIPTION_CHOICES[settings.subscribe_reply]
+    user.subscribe_to_replied_threads = replied_threads

+ 14 - 10
misago/users/social/pipeline.py

@@ -8,7 +8,6 @@ from django.urls import reverse
 from django.utils.translation import gettext as _
 from social_core.pipeline.partial import partial
 
-from misago.conf import settings
 from misago.core.exceptions import SocialAuthFailed, SocialAuthBanned
 from misago.legal.models import Agreement
 
@@ -18,8 +17,10 @@ from misago.users.models import Ban
 from misago.users.registration import (
     get_registration_result_json, save_user_agreements, send_welcome_email
 )
+from misago.users.setupnewuser import setup_new_user
 from misago.users.validators import (
-    ValidationError, validate_new_registration, validate_email, validate_username)
+    ValidationError, validate_new_registration, validate_email, validate_username
+)
 
 from .utils import get_social_auth_backend_name, perpare_username
 
@@ -96,6 +97,8 @@ def get_username(strategy, details, backend, user=None, *args, **kwargs):
     if user:
         return None
 
+    settings = strategy.request.settings
+
     username = perpare_username(details.get('username', ''))
     full_name = perpare_username(details.get('full_name', ''))
     first_name = perpare_username(details.get('first_name', ''))
@@ -125,7 +128,7 @@ def get_username(strategy, details, backend, user=None, *args, **kwargs):
 
     for name in filter(bool, names_to_try):
         try:
-            validate_username(name)
+            validate_username(settings, name)
             return {'clean_username': name}
         except ValidationError:
             pass
@@ -137,6 +140,8 @@ def create_user(strategy, details, backend, user=None, *args, **kwargs):
         return None
     
     request = strategy.request
+    settings = request.settings
+
     email = details.get('email')
     username = kwargs.get('clean_username')
     
@@ -157,14 +162,13 @@ def create_user(strategy, details, backend, user=None, *args, **kwargs):
         activation_kwargs = {'requires_activation': UserModel.ACTIVATION_ADMIN}
 
     new_user = UserModel.objects.create_user(
-        username, 
-        email, 
-        create_audit_trail=True,
+        username,
+        email,
         joined_from_ip=request.user_ip, 
-        set_default_avatar=True,
         **activation_kwargs
     )
 
+    setup_new_user(settings, new_user)
     send_welcome_email(request, new_user)
 
     return {'user': new_user, 'is_new': True}
@@ -177,6 +181,7 @@ def create_user_with_form(strategy, details, backend, user=None, *args, **kwargs
         return None
 
     request = strategy.request
+    settings = request.settings
     backend_name = get_social_auth_backend_name(backend.name)
 
     if request.method == 'POST':
@@ -187,7 +192,7 @@ def create_user_with_form(strategy, details, backend, user=None, *args, **kwargs
             
         form = SocialAuthRegisterForm(
             request_data,
-            request=request,    
+            request=request,
             agreements=Agreement.objects.get_agreements(),
         )
         
@@ -206,11 +211,10 @@ def create_user_with_form(strategy, details, backend, user=None, *args, **kwargs
             new_user = UserModel.objects.create_user(
                 form.cleaned_data['username'],
                 form.cleaned_data['email'],
-                create_audit_trail=True,
                 joined_from_ip=request.user_ip,
-                set_default_avatar=True,
                 **activation_kwargs
             )
+            setup_new_user(settings, new_user)
         except IntegrityError:
             return JsonResponse({'__all__': _("Please try resubmitting the form.")}, status=400)
 

+ 15 - 15
misago/users/tests/test_activation_views.py

@@ -4,10 +4,10 @@ from django.urls import reverse
 
 from misago.core.utils import encode_json_html
 from misago.users.models import Ban
+from misago.users.testutils import create_test_user
 from misago.users.tokens import make_activation_token
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class ActivationViewsTests(TestCase):
@@ -18,8 +18,8 @@ class ActivationViewsTests(TestCase):
 
     def test_view_activate_banned(self):
         """activate banned user shows error"""
-        test_user = UserModel.objects.create_user(
-            'Bob', 'bob@test.com', 'Pass.123', requires_activation=1
+        test_user = create_test_user(
+            'Bob', 'bob@test.com', requires_activation=1
         )
         Ban.objects.create(
             check_type=Ban.USERNAME,
@@ -40,13 +40,13 @@ class ActivationViewsTests(TestCase):
         )
         self.assertContains(response, encode_json_html("<p>Nope!</p>"), status_code=403)
 
-        test_user = UserModel.objects.get(pk=test_user.pk)
+        test_user = User.objects.get(pk=test_user.pk)
         self.assertEqual(test_user.requires_activation, 1)
 
     def test_view_activate_invalid_token(self):
         """activate with invalid token shows error"""
-        test_user = UserModel.objects.create_user(
-            'Bob', 'bob@test.com', 'Pass.123', requires_activation=1
+        test_user = create_test_user(
+            'Bob', 'bob@test.com', requires_activation=1
         )
 
         activation_token = make_activation_token(test_user)
@@ -62,13 +62,13 @@ class ActivationViewsTests(TestCase):
         )
         self.assertEqual(response.status_code, 400)
 
-        test_user = UserModel.objects.get(pk=test_user.pk)
+        test_user = User.objects.get(pk=test_user.pk)
         self.assertEqual(test_user.requires_activation, 1)
 
     def test_view_activate_disabled(self):
         """activate disabled user shows error"""
-        test_user = UserModel.objects.create_user(
-            'Bob', 'bob@test.com', 'Pass.123', is_active=False
+        test_user = create_test_user(
+            'Bob', 'bob@test.com', is_active=False
         )
 
         activation_token = make_activation_token(test_user)
@@ -86,7 +86,7 @@ class ActivationViewsTests(TestCase):
 
     def test_view_activate_active(self):
         """activate active user shows error"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        test_user = create_test_user('Bob', 'bob@test.com')
 
         activation_token = make_activation_token(test_user)
 
@@ -101,13 +101,13 @@ class ActivationViewsTests(TestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        test_user = UserModel.objects.get(pk=test_user.pk)
+        test_user = User.objects.get(pk=test_user.pk)
         self.assertEqual(test_user.requires_activation, 0)
 
     def test_view_activate_inactive(self):
         """activate inactive user passess"""
-        test_user = UserModel.objects.create_user(
-            'Bob', 'bob@test.com', 'Pass.123', requires_activation=1
+        test_user = create_test_user(
+            'Bob', 'bob@test.com', requires_activation=1
         )
 
         activation_token = make_activation_token(test_user)
@@ -124,5 +124,5 @@ class ActivationViewsTests(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "your account has been activated!")
 
-        test_user = UserModel.objects.get(pk=test_user.pk)
+        test_user = User.objects.get(pk=test_user.pk)
         self.assertEqual(test_user.requires_activation, 0)

+ 12 - 10
misago/users/tests/test_avatars.py

@@ -1,4 +1,5 @@
 from pathlib import Path
+from unittest.mock import Mock
 
 from PIL import Image
 
@@ -11,20 +12,19 @@ from misago.conf import settings
 from misago.users.avatars import dynamic, gallery, gravatar, set_default_avatar, store, uploaded
 from misago.users.models import Avatar, AvatarGallery
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class AvatarsStoreTests(TestCase):
     def test_store(self):
         """store successfully stores and deletes avatar"""
-        user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        user = User.objects.create_user('Bob', 'bob@bob.com', 'pass123')
 
         test_image = Image.new("RGBA", (100, 100), 0)
         store.store_new_avatar(user, test_image)
 
         # reload user
-        UserModel.objects.get(pk=user.pk)
+        User.objects.get(pk=user.pk)
 
         # assert that avatars were stored in media
         avatars_dict = {}
@@ -85,7 +85,7 @@ class AvatarsStoreTests(TestCase):
 
 class AvatarSetterTests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user('Bob', 'kontakt@rpiton.com', 'pass123')
+        self.user = User.objects.create_user('Bob', 'kontakt@rpiton.com', 'pass123')
 
         self.user.avatars = None
         self.user.save()
@@ -94,7 +94,7 @@ class AvatarSetterTests(TestCase):
         store.delete_avatar(self.user)
 
     def get_current_user(self):
-        return UserModel.objects.get(pk=self.user.pk)
+        return User.objects.get(pk=self.user.pk)
 
     def assertNoAvatarIsSet(self):
         user = self.get_current_user()
@@ -215,12 +215,14 @@ class UploadedAvatarTests(TestCase):
 
     def test_uploaded_image_size_validation(self):
         """uploaded image size is validated"""
-        image = MockAvatarFile(size=settings.avatar_upload_limit * 2024)
+        settings = Mock(avatar_upload_limit=1)  # no. of MBs
+
+        image = MockAvatarFile(size=1025)
         with self.assertRaises(ValidationError):
-            uploaded.validate_file_size(image)
+            uploaded.validate_file_size(settings, image)
 
-        image = MockAvatarFile(size=settings.avatar_upload_limit * 1000)
-        uploaded.validate_file_size(image)
+        image = MockAvatarFile(size=1024)
+        uploaded.validate_file_size(settings, image)
 
     def test_uploaded_image_extension_validation(self):
         """uploaded image extension is validated"""

+ 3 - 4
misago/users/tests/test_bans.py

@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.utils import timezone
 
+from misago.conftest import get_cache_versions
 from misago.users.bans import (
     ban_ip, ban_user, get_email_ban, get_ip_ban, get_request_ip_ban, get_user_ban, get_username_ban)
 from misago.users.constants import BANS_CACHE
@@ -11,7 +12,7 @@ from misago.users.models import Ban
 
 UserModel = get_user_model()
 
-cache_versions = {"bans": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 class GetBanTests(TestCase):
@@ -191,9 +192,7 @@ class MockRequest(object):
     def __init__(self):
         self.user_ip = '127.0.0.1'
         self.session = {}
-        self.cache_versions = {
-            BANS_CACHE: "abcdefgh"
-        }
+        self.cache_versions = cache_versions
 
 
 class RequestIPBansTests(TestCase):

+ 10 - 11
misago/users/tests/test_captcha_api.py

@@ -1,31 +1,30 @@
 from django.test import TestCase
 from django.urls import reverse
 
-from misago.conf import settings
+from misago.conf.test import override_dynamic_settings
+
+test_qa_question = "Do you like pies?"
+test_qa_help_text = 'Type in "yes".'
 
 
 class AuthenticateApiTests(TestCase):
     def setUp(self):
         self.api_link = reverse('misago:api:captcha-question')
 
-    def tearDown(self):
-        settings.reset_settings()
-
+    @override_dynamic_settings(qa_question="")
     def test_api_no_qa_is_set(self):
         """qa api returns 404 if no QA question is set"""
-        settings.override_setting('qa_question', '')
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
 
+    @override_dynamic_settings(
+        qa_question=test_qa_question, qa_help_text=test_qa_help_text
+    )
     def test_api_get_question(self):
         """qa api returns valid QA question"""
-        settings.override_setting('qa_question', 'Do you like pies?')
-        settings.override_setting('qa_help_text', 'Type in "yes".')
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['question'], 'Do you like pies?')
-        self.assertEqual(response_json['help_text'], 'Type in "yes".')
+        self.assertEqual(response_json['question'], test_qa_question)
+        self.assertEqual(response_json['help_text'], test_qa_help_text)

+ 1 - 1
misago/users/tests/test_joinip_profilefield.py

@@ -27,7 +27,7 @@ class JoinIpProfileFieldTests(AdminTestCase):
         self.assertNotContains(response, "Join IP")
 
     def test_admin_edits_field(self):
-        """admin form allows admins to edit field"""
+        """join_ip is non-editable by admin"""
         response = self.client.post(
             self.test_link,
             data={

+ 5 - 10
misago/users/tests/test_lists_views.py

@@ -1,4 +1,3 @@
-from django.contrib.auth import get_user_model
 from django.urls import reverse
 
 from misago.acl.test import patch_user_acl
@@ -6,10 +5,7 @@ from misago.categories.models import Category
 from misago.threads.testutils import post_thread
 from misago.users.activepostersranking import build_active_posters_ranking
 from misago.users.models import Rank
-from misago.users.testutils import AuthenticatedUserTestCase
-
-
-UserModel = get_user_model()
+from misago.users.testutils import AuthenticatedUserTestCase, create_test_user
 
 
 class UsersListTestCase(AuthenticatedUserTestCase):
@@ -48,10 +44,9 @@ class ActivePostersTests(UsersListTestCase):
 
         # Create 50 test users and see if errors appeared
         for i in range(50):
-            user = UserModel.objects.create_user(
+            user = create_test_user(
                 'Bob%s' % i,
                 'm%s@te.com' % i,
-                'Pass.123',
                 posts=12345,
             )
             post_thread(category, poster=user)
@@ -65,7 +60,7 @@ class ActivePostersTests(UsersListTestCase):
 class UsersRankTests(UsersListTestCase):
     def test_ranks(self):
         """ranks lists are handled correctly"""
-        rank_user = UserModel.objects.create_user('Visible', 'visible@te.com', 'Pass.123')
+        rank_user = create_test_user('Visible', 'visible@te.com')
 
         for rank in Rank.objects.iterator():
             rank_user.rank = rank
@@ -87,7 +82,7 @@ class UsersRankTests(UsersListTestCase):
 
     def test_disabled_users(self):
         """ranks lists excludes disabled accounts"""
-        rank_user = UserModel.objects.create_user(
+        rank_user = create_test_user(
             'Visible',
             'visible@te.com',
             'Pass.123',
@@ -117,7 +112,7 @@ class UsersRankTests(UsersListTestCase):
         self.user.is_staff = True
         self.user.save()
 
-        rank_user = UserModel.objects.create_user(
+        rank_user = create_test_user(
             'Visible',
             'visible@te.com',
             'Pass.123',

+ 5 - 8
misago/users/tests/test_mention_api.py

@@ -1,11 +1,8 @@
-from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.urls import reverse
 
 from misago.conf import settings
-
-
-UserModel = get_user_model()
+from misago.users.testutils import create_test_user
 
 
 class AuthenticateApiTests(TestCase):
@@ -28,14 +25,14 @@ class AuthenticateApiTests(TestCase):
 
     def test_user_search(self):
         """api searches uses"""
-        UserModel.objects.create_user('BobBoberson', 'bob@test.com', 'pass123')
+        create_test_user('BobBoberson', 'bob@test.com')
 
         # exact case sensitive match
         response = self.client.get(self.api_link + '?q=BobBoberson')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), [
             {
-                'avatar': 'http://placekitten.com/400/400',
+                'avatar': 'http://placekitten.com/100/100',
                 'username': 'BobBoberson',
             }
         ])
@@ -45,7 +42,7 @@ class AuthenticateApiTests(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), [
             {
-                'avatar': 'http://placekitten.com/400/400',
+                'avatar': 'http://placekitten.com/100/100',
                 'username': 'BobBoberson',
             }
         ])
@@ -55,7 +52,7 @@ class AuthenticateApiTests(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), [
             {
-                'avatar': 'http://placekitten.com/400/400',
+                'avatar': 'http://placekitten.com/100/100',
                 'username': 'BobBoberson',
             }
         ])

+ 70 - 0
misago/users/tests/test_new_user_setup.py

@@ -0,0 +1,70 @@
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from misago.cache.versions import get_cache_versions
+from misago.conf.dynamicsettings import DynamicSettings
+from misago.conf.test import override_dynamic_settings
+
+from misago.users.setupnewuser import (
+    set_default_subscription_options, setup_new_user
+)
+
+User = get_user_model()
+
+
+class NewUserSetupTests(TestCase):
+    def test_default_avatar_is_set_for_user(self):
+        user = User.objects.create_user("User", "test@example.com")
+        cache_versions = get_cache_versions()
+        settings = DynamicSettings(cache_versions)
+        setup_new_user(settings, user)
+        assert user.avatars
+        assert user.avatar_set.exists()
+
+    def test_default_started_threads_subscription_option_is_set_for_user(self):
+        user = User.objects.create_user("User", "test@example.com")
+        cache_versions = get_cache_versions()
+        settings = DynamicSettings(cache_versions)
+
+        with override_dynamic_settings(subscribe_start="no"):
+            set_default_subscription_options(settings, user)
+            assert user.subscribe_to_started_threads == User.SUBSCRIPTION_NONE
+
+        with override_dynamic_settings(subscribe_start="watch"):
+            set_default_subscription_options(settings, user)
+            assert user.subscribe_to_started_threads == User.SUBSCRIPTION_NOTIFY
+
+        with override_dynamic_settings(subscribe_start="watch_email"):
+            set_default_subscription_options(settings, user)
+            assert user.subscribe_to_started_threads == User.SUBSCRIPTION_ALL
+
+    def test_default_replied_threads_subscription_option_is_set_for_user(self):
+        user = User.objects.create_user("User", "test@example.com")
+        cache_versions = get_cache_versions()
+        settings = DynamicSettings(cache_versions)
+
+        with override_dynamic_settings(subscribe_reply="no"):
+            set_default_subscription_options(settings, user)
+            assert user.subscribe_to_replied_threads == User.SUBSCRIPTION_NONE
+
+        with override_dynamic_settings(subscribe_reply="watch"):
+            set_default_subscription_options(settings, user)
+            assert user.subscribe_to_replied_threads == User.SUBSCRIPTION_NOTIFY
+
+        with override_dynamic_settings(subscribe_reply="watch_email"):
+            set_default_subscription_options(settings, user)
+            assert user.subscribe_to_replied_threads == User.SUBSCRIPTION_ALL
+
+    def test_if_user_ip_is_available_audit_trail_is_created_for_user(self):
+        user = User.objects.create_user("User", "test@example.com", joined_from_ip="0.0.0.0")
+        cache_versions = get_cache_versions()
+        settings = DynamicSettings(cache_versions)
+        setup_new_user(settings, user)
+        assert user.audittrail_set.count() == 1
+
+    def test_if_user_ip_is_not_available_audit_trail_is_not_created(self):
+        user = User.objects.create_user("User", "test@example.com")
+        cache_versions = get_cache_versions()
+        settings = DynamicSettings(cache_versions)
+        setup_new_user(settings, user)
+        assert user.audittrail_set.exists() is False

+ 6 - 4
misago/users/tests/test_profile_views.py

@@ -5,7 +5,9 @@ from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.users.models import Ban
-from misago.users.testutils import AuthenticatedUserTestCase
+from misago.users.testutils import (
+    AuthenticatedUserTestCase, create_test_user
+)
 
 
 UserModel = get_user_model()
@@ -34,7 +36,7 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
         self.user.is_staff = False
         self.user.save()
 
-        test_user = UserModel.objects.create_user('Tyrael', 't123@test.com', 'pass123')
+        test_user = create_test_user('Tyrael', 't123@test.com')
 
         test_user.is_active = False
         test_user.save()
@@ -50,7 +52,7 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
 
         # profile page displays notice about user being disabled
         response = self.client.get(response['location'])
-        self.assertContains(response, "account has been disabled", status_code=200)
+        self.assertContains(response, "account has been disabled")
 
     def test_user_posts_list(self):
         """user profile posts list has no showstoppers"""
@@ -184,7 +186,7 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
 
     def test_user_ban_details(self):
         """user ban details page has no showstoppers"""
-        test_user = UserModel.objects.create_user("Bob", "bob@bob.com", 'pass.123')
+        test_user = create_test_user("Bob", "bob@bob.com", 'pass.123')
         link_kwargs = {'slug': test_user.slug, 'pk': test_user.pk}
 
         with patch_user_acl({'can_see_ban_details': 0}):

+ 2 - 1
misago/users/tests/test_signatures.py

@@ -4,10 +4,11 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 
 from misago.acl.useracl import get_user_acl
+from misago.conftest import get_cache_versions
 from misago.users import signatures
 
 User = get_user_model()
-cache_versions = {"acl": "abcdefg"}
+cache_versions = get_cache_versions()
 
 
 class MockRequest(object):

+ 42 - 23
misago/users/tests/test_social_pipeline.py

@@ -2,16 +2,18 @@ import json
 
 from django.contrib.auth import get_user_model
 from django.core import mail
-from django.test import RequestFactory, override_settings
+from django.test import RequestFactory
 from social_core.backends.github import GithubOAuth2
 from social_django.utils import load_strategy
 
 from misago.acl import ACL_CACHE
 from misago.acl.useracl import get_user_acl
+from misago.conf.dynamicsettings import DynamicSettings
 from misago.core.exceptions import SocialAuthFailed, SocialAuthBanned
+from misago.conf.test import override_dynamic_settings
+from misago.conftest import get_cache_versions
 from misago.legal.models import Agreement
 
-from misago.users.constants import BANS_CACHE
 from misago.users.models import AnonymousUser, Ban, BanCache
 from misago.users.social.pipeline import (
     associate_by_email, create_user, create_user_with_form, get_username, require_activation,
@@ -30,15 +32,21 @@ def create_request(user_ip='0.0.0.0', data=None):
     else:
         request = factory.post('/', data=json.dumps(data), content_type='application/json')
     request.include_frontend_context = True
-    request.cache_versions = {BANS_CACHE: "abcdefgh", ACL_CACHE: "abcdefgh"}
+    request.cache_versions = get_cache_versions()
     request.frontend_context = {}
     request.session = {}
+    request.settings = DynamicSettings(request.cache_versions)
     request.user = AnonymousUser()
     request.user_acl = get_user_acl(request.user, request.cache_versions)
     request.user_ip = user_ip
     return request
 
 
+def create_strategy():
+    request = create_request()
+    return load_strategy(request=request)
+
+
 class MockStrategy(object):
     def __init__(self, user_ip='0.0.0.0'):
         self.cleaned_partial_token = None
@@ -53,7 +61,8 @@ class PipelineTestCase(UserTestCase):
         self.user = self.get_authenticated_user()
 
     def assertNewUserIsCorrect(
-            self, new_user, form_data=None, activation=None, email_verified=False):
+        self, new_user, form_data=None, activation=None, email_verified=False
+    ):
         self.assertFalse(new_user.has_usable_password())
         self.assertIn('Welcome', mail.outbox[0].subject)
 
@@ -180,7 +189,7 @@ class CreateUser(PipelineTestCase):
         )
         self.assertIsNone(result)
 
-    @override_settings(account_activation='none')
+    @override_dynamic_settings(account_activation='none')
     def test_user_created_no_activation(self):
         """pipeline step creates active user for valid data and disabled activation"""
         result = create_user(
@@ -197,7 +206,7 @@ class CreateUser(PipelineTestCase):
         self.assertEqual(new_user.username, 'NewUser')
         self.assertNewUserIsCorrect(new_user, email_verified=True, activation='none')
 
-    @override_settings(account_activation='user')
+    @override_dynamic_settings(account_activation='user')
     def test_user_created_activation_by_user(self):
         """pipeline step creates active user for valid data and user activation"""
         result = create_user(
@@ -214,7 +223,7 @@ class CreateUser(PipelineTestCase):
         self.assertEqual(new_user.username, 'NewUser')
         self.assertNewUserIsCorrect(new_user, email_verified=True, activation='user')
 
-    @override_settings(account_activation='admin')
+    @override_dynamic_settings(account_activation='admin')
     def test_user_created_activation_by_admin(self):
         """pipeline step creates in user for valid data and admin activation"""
         result = create_user(
@@ -314,7 +323,7 @@ class CreateUserWithFormTests(PipelineTestCase):
             'username': ["This username is not available."],
         })
 
-    @override_settings(account_activation='none')
+    @override_dynamic_settings(account_activation='none')
     def test_user_created_no_activation_verified_email(self):
         """active user is created for verified email and activation disabled"""
         form_data = {
@@ -338,7 +347,7 @@ class CreateUserWithFormTests(PipelineTestCase):
 
         self.assertNewUserIsCorrect(new_user, form_data, activation='none', email_verified=True)
 
-    @override_settings(account_activation='none')
+    @override_dynamic_settings(account_activation='none')
     def test_user_created_no_activation_nonverified_email(self):
         """active user is created for non-verified email and activation disabled"""
         form_data = {
@@ -362,7 +371,7 @@ class CreateUserWithFormTests(PipelineTestCase):
 
         self.assertNewUserIsCorrect(new_user, form_data, activation='none', email_verified=False)
 
-    @override_settings(account_activation='user')
+    @override_dynamic_settings(account_activation='user')
     def test_user_created_activation_by_user_verified_email(self):
         """active user is created for verified email and activation by user"""
         form_data = {
@@ -386,7 +395,7 @@ class CreateUserWithFormTests(PipelineTestCase):
 
         self.assertNewUserIsCorrect(new_user, form_data, activation='user', email_verified=True)
 
-    @override_settings(account_activation='user')
+    @override_dynamic_settings(account_activation='user')
     def test_user_created_activation_by_user_nonverified_email(self):
         """inactive user is created for non-verified email and activation by user"""
         form_data = {
@@ -410,7 +419,7 @@ class CreateUserWithFormTests(PipelineTestCase):
 
         self.assertNewUserIsCorrect(new_user, form_data, activation='user', email_verified=False)
 
-    @override_settings(account_activation='admin')
+    @override_dynamic_settings(account_activation='admin')
     def test_user_created_activation_by_admin_verified_email(self):
         """inactive user is created for verified email and activation by admin"""
         form_data = {
@@ -434,7 +443,7 @@ class CreateUserWithFormTests(PipelineTestCase):
 
         self.assertNewUserIsCorrect(new_user, form_data, activation='admin', email_verified=True)
 
-    @override_settings(account_activation='admin')
+    @override_dynamic_settings(account_activation='admin')
     def test_user_created_activation_by_admin_nonverified_email(self):
         """inactive user is created for non-verified email and activation by admin"""
         form_data = {
@@ -569,77 +578,87 @@ class CreateUserWithFormTests(PipelineTestCase):
 class GetUsernameTests(PipelineTestCase):
     def test_skip_if_user_is_set(self):
         """pipeline step is skipped if user was passed"""
-        result = get_username(None, {}, None, user=self.user)
+        strategy = create_strategy()
+        result = get_username(strategy, {}, None, user=self.user)
         self.assertIsNone(result)
 
     def test_skip_if_no_names(self):
         """pipeline step is skipped if API returned no names"""
-        result = get_username(None, {}, None)
+        strategy = create_strategy()
+        result = get_username(strategy, {}, None)
         self.assertIsNone(result)
 
     def test_resolve_to_username(self):
         """pipeline step resolves username"""
-        result = get_username(None, {'username': 'BobBoberson'}, None)
+        strategy = create_strategy()
+        result = get_username(strategy, {'username': 'BobBoberson'}, None)
         self.assertEqual(result, {'clean_username': 'BobBoberson'})
 
     def test_normalize_username(self):
         """pipeline step normalizes username"""
-        result = get_username(None, {'username': 'Błop Błoperson'}, None)
+        strategy = create_strategy()
+        result = get_username(strategy, {'username': 'Błop Błoperson'}, None)
         self.assertEqual(result, {'clean_username': 'BlopBloperson'})
 
     def test_resolve_to_first_name(self):
         """pipeline attempts to use first name because username is taken"""
+        strategy = create_strategy()
         details = {
             'username': self.user.username,
             'first_name': 'Błob',
         }
-        result = get_username(None, details, None)
+        result = get_username(strategy, details, None)
         self.assertEqual(result, {'clean_username': 'Blob'})
 
     def test_dont_resolve_to_last_name(self):
         """pipeline will not fallback to last name because username is taken"""
+        strategy = create_strategy()
         details = {
             'username': self.user.username,
             'last_name': 'Błob',
         }
-        result = get_username(None, details, None)
+        result = get_username(strategy, details, None)
         self.assertIsNone(result)
 
     def test_resolve_to_first_last_name_first_char(self):
         """pipeline will construct username from first name and first char of surname"""
+        strategy = create_strategy()
         details = {
             'first_name': self.user.username,
             'last_name': 'Błob',
         }
-        result = get_username(None, details, None)
+        result = get_username(strategy, details, None)
         self.assertEqual(result, {'clean_username': self.user.username + 'B'})
 
     def test_dont_resolve_to_banned_name(self):
         """pipeline will not resolve to banned name"""
+        strategy = create_strategy()
         Ban.objects.create(banned_value='*Admin*', check_type=Ban.USERNAME)
         details = {
             'username': 'Misago Admin',
             'first_name': 'Błob',
         }
-        result = get_username(None, details, None)
+        result = get_username(strategy, details, None)
         self.assertEqual(result, {'clean_username': 'Blob'})
 
     def test_resolve_full_name(self):
         """pipeline will resolve to full name"""
+        strategy = create_strategy()
         Ban.objects.create(banned_value='*Admin*', check_type=Ban.USERNAME)
         details = {
             'username': 'Misago Admin',
             'full_name': 'Błob Błopo',
         }
-        result = get_username(None, details, None)
+        result = get_username(strategy, details, None)
         self.assertEqual(result, {'clean_username': 'BlobBlopo'})
 
     def test_resolve_to_cut_name(self):
         """pipeline will resolve cut too long name on second pass"""
+        strategy = create_strategy()
         details = {
             'username': 'Abrakadabrapokuskonstantynopolitańczykowianeczkatrzy',
         }
-        result = get_username(None, details, None)
+        result = get_username(strategy, details, None)
         self.assertEqual(result, {'clean_username': 'Abrakadabrapok'})
 
 

+ 31 - 31
misago/users/tests/test_user_avatar_api.py

@@ -6,15 +6,15 @@ from django.contrib.auth import get_user_model
 
 from misago.acl.test import patch_user_acl
 from misago.conf import settings
+from misago.conf.test import override_dynamic_settings
 from misago.users.avatars import gallery, store
 from misago.users.models import AvatarGallery
 from misago.users.testutils import AuthenticatedUserTestCase
 
-
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TEST_AVATAR_PATH = os.path.join(TESTFILES_DIR, 'avatar.png')
 
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class UserAvatarTests(AuthenticatedUserTestCase):
@@ -26,40 +26,40 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.client.post(self.link, data={'avatar': 'generated'})
 
     def get_current_user(self):
-        return UserModel.objects.get(pk=self.user.pk)
+        return User.objects.get(pk=self.user.pk)
 
     def assertOldAvatarsAreDeleted(self, user):
         self.assertEqual(
             user.avatar_set.count(), len(settings.MISAGO_AVATARS_SIZES)
         )
 
+    @override_dynamic_settings(allow_custom_avatars=False)
     def test_avatars_off(self):
         """custom avatars are not allowed"""
-        with self.settings(allow_custom_avatars=False):
-            response = self.client.get(self.link)
-            self.assertEqual(response.status_code, 200)
-
-            options = response.json()
-            self.assertTrue(options['generated'])
-            self.assertFalse(options['gravatar'])
-            self.assertFalse(options['crop_src'])
-            self.assertFalse(options['crop_tmp'])
-            self.assertFalse(options['upload'])
-            self.assertFalse(options['galleries'])
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
 
+        options = response.json()
+        self.assertTrue(options['generated'])
+        self.assertFalse(options['gravatar'])
+        self.assertFalse(options['crop_src'])
+        self.assertFalse(options['crop_tmp'])
+        self.assertFalse(options['upload'])
+        self.assertFalse(options['galleries'])
+
+    @override_dynamic_settings(allow_custom_avatars=True)
     def test_avatars_on(self):
-        """custom avatars are not allowed"""
-        with self.settings(allow_custom_avatars=True):
-            response = self.client.get(self.link)
-            self.assertEqual(response.status_code, 200)
+        """custom avatars are allowed"""
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
 
-            options = response.json()
-            self.assertTrue(options['generated'])
-            self.assertTrue(options['gravatar'])
-            self.assertFalse(options['crop_src'])
-            self.assertFalse(options['crop_tmp'])
-            self.assertTrue(options['upload'])
-            self.assertFalse(options['galleries'])
+        options = response.json()
+        self.assertTrue(options['generated'])
+        self.assertTrue(options['gravatar'])
+        self.assertFalse(options['crop_src'])
+        self.assertFalse(options['crop_tmp'])
+        self.assertTrue(options['upload'])
+        self.assertFalse(options['galleries'])
 
     def test_gallery_exists(self):
         """api returns gallery"""
@@ -95,7 +95,7 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         })
 
         self.login_user(
-            UserModel.objects.create_user("BobUser", "bob@bob.com", self.USER_PASSWORD)
+            User.objects.create_user("BobUser", "bob@bob.com", self.USER_PASSWORD)
         )
 
         response = self.client.get(self.link)
@@ -347,7 +347,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.other_user = UserModel.objects.create_user("OtherUser", "other@user.com", "pass123")
+        self.other_user = User.objects.create_user("OtherUser", "other@user.com", "pass123")
 
         self.link = '/api/users/%s/moderate-avatar/' % self.other_user.pk
 
@@ -386,7 +386,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        other_user = UserModel.objects.get(pk=self.other_user.pk)
+        other_user = User.objects.get(pk=self.other_user.pk)
 
         options = response.json()
         self.assertEqual(other_user.is_avatar_locked, True)
@@ -411,7 +411,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        other_user = UserModel.objects.get(pk=self.other_user.pk)
+        other_user = User.objects.get(pk=self.other_user.pk)
         self.assertFalse(other_user.is_avatar_locked)
         self.assertIsNone(other_user.avatar_lock_user_message)
         self.assertIsNone(other_user.avatar_lock_staff_message)
@@ -435,7 +435,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        other_user = UserModel.objects.get(pk=self.other_user.pk)
+        other_user = User.objects.get(pk=self.other_user.pk)
         self.assertTrue(other_user.is_avatar_locked)
         self.assertEqual(other_user.avatar_lock_user_message, '')
         self.assertEqual(other_user.avatar_lock_staff_message, '')
@@ -457,7 +457,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        other_user = UserModel.objects.get(pk=self.other_user.pk)
+        other_user = User.objects.get(pk=self.other_user.pk)
         self.assertFalse(other_user.is_avatar_locked)
         self.assertEqual(other_user.avatar_lock_user_message, '')
         self.assertEqual(other_user.avatar_lock_staff_message, '')

+ 46 - 25
misago/users/tests/test_user_create_api.py

@@ -1,15 +1,13 @@
 from django.contrib.auth import get_user_model
 from django.core import mail
-from django.test import override_settings
 from django.urls import reverse
 
-from misago.conf import settings
+from misago.conf.test import override_dynamic_settings
 from misago.legal.models import Agreement
 from misago.users.models import Ban, Online
 from misago.users.testutils import UserTestCase
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class UserCreateTests(UserTestCase):
@@ -58,10 +56,9 @@ class UserCreateTests(UserTestCase):
             "detail": "This action is not available to signed in users."
         })
 
+    @override_dynamic_settings(account_activation="closed")
     def test_registration_off_request(self):
         """registrations off request errors with code 403"""
-        settings.override_setting('account_activation', 'closed')
-
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
@@ -290,7 +287,11 @@ class UserCreateTests(UserTestCase):
             "password": ["The password is too similar to the username."],
         })
 
-    @override_settings(captcha_type='qa', qa_question='Test', qa_answers='Lorem\nIpsum')
+    @override_dynamic_settings(
+        captcha_type='qa',
+        qa_question='Test',
+        qa_answers='Lorem\nIpsum'
+    )
     def test_registration_validates_captcha(self):
         """api validates captcha"""
         response = self.client.post(
@@ -323,6 +324,30 @@ class UserCreateTests(UserTestCase):
 
         self.assertEqual(response.status_code, 200)
 
+    @override_dynamic_settings(
+        captcha_type='qa',
+        qa_question='',
+        qa_answers='Lorem\n\nIpsum'
+    )
+    def test_qacaptcha_handles_empty_answers(self):
+        """api validates captcha"""
+        response = self.client.post(
+            self.api_link,
+            data={
+                'username': 'totallyNew',
+                'email': 'loremipsum@dolor.met',
+                'password': 'LoremP4ssword',
+                'captcha': ''
+            },
+        )
+
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json(), {
+                'captcha': ['Entered answer is incorrect.'],
+            }
+        )
+
     def test_registration_check_agreement(self):
         """api checks agreement"""
         agreement = Agreement.objects.create(
@@ -378,7 +403,7 @@ class UserCreateTests(UserTestCase):
 
         self.assertEqual(response.status_code, 200)
         
-        user = UserModel.objects.get(email='loremipsum@dolor.met')
+        user = User.objects.get(email='loremipsum@dolor.met')
         self.assertEqual(user.agreements, [agreement.id])
         self.assertEqual(user.useragreement_set.count(), 1)
 
@@ -402,7 +427,7 @@ class UserCreateTests(UserTestCase):
 
         self.assertEqual(response.status_code, 200)
         
-        user = UserModel.objects.get(email='loremipsum@dolor.met')
+        user = User.objects.get(email='loremipsum@dolor.met')
         self.assertEqual(user.agreements, [])
         self.assertEqual(user.useragreement_set.count(), 0)
 
@@ -422,10 +447,9 @@ class UserCreateTests(UserTestCase):
             "password": ["This password is too short. It must contain at least 7 characters."],
         })
 
+    @override_dynamic_settings(account_activation="none")
     def test_registration_creates_active_user(self):
         """api creates active and signed in user on POST"""
-        settings.override_setting('account_activation', 'none')
-
         response = self.client.post(
             self.api_link,
             data={
@@ -441,9 +465,9 @@ class UserCreateTests(UserTestCase):
             'email': 'bob@bob.com',
         })
 
-        UserModel.objects.get_by_username('Bob')
+        User.objects.get_by_username('Bob')
 
-        test_user = UserModel.objects.get_by_email('bob@bob.com')
+        test_user = User.objects.get_by_email('bob@bob.com')
         self.assertEqual(Online.objects.filter(user=test_user).count(), 1)
 
         self.assertTrue(test_user.check_password('pass123'))
@@ -456,10 +480,9 @@ class UserCreateTests(UserTestCase):
 
         self.assertEqual(test_user.audittrail_set.count(), 1)
 
+    @override_dynamic_settings(account_activation="user")
     def test_registration_creates_inactive_user(self):
         """api creates inactive user on POST"""
-        settings.override_setting('account_activation', 'user')
-
         response = self.client.post(
             self.api_link,
             data={
@@ -478,15 +501,14 @@ class UserCreateTests(UserTestCase):
         auth_json = self.client.get(reverse('misago:api:auth')).json()
         self.assertFalse(auth_json['is_authenticated'])
 
-        UserModel.objects.get_by_username('Bob')
-        UserModel.objects.get_by_email('bob@bob.com')
+        User.objects.get_by_username('Bob')
+        User.objects.get_by_email('bob@bob.com')
 
         self.assertIn('Welcome', mail.outbox[0].subject)
 
+    @override_dynamic_settings(account_activation="admin")
     def test_registration_creates_admin_activated_user(self):
         """api creates admin activated user on POST"""
-        settings.override_setting('account_activation', 'admin')
-
         response = self.client.post(
             self.api_link,
             data={
@@ -505,15 +527,14 @@ class UserCreateTests(UserTestCase):
         auth_json = self.client.get(reverse('misago:api:auth')).json()
         self.assertFalse(auth_json['is_authenticated'])
 
-        UserModel.objects.get_by_username('Bob')
-        UserModel.objects.get_by_email('bob@bob.com')
+        User.objects.get_by_username('Bob')
+        User.objects.get_by_email('bob@bob.com')
 
         self.assertIn('Welcome', mail.outbox[0].subject)
 
+    @override_dynamic_settings(account_activation="none")
     def test_registration_creates_user_with_whitespace_password(self):
         """api creates user with spaces around password"""
-        settings.override_setting('account_activation', 'none')
-
         response = self.client.post(
             self.api_link,
             data={
@@ -529,9 +550,9 @@ class UserCreateTests(UserTestCase):
             'email': 'bob@bob.com',
         })
 
-        UserModel.objects.get_by_username('Bob')
+        User.objects.get_by_username('Bob')
 
-        test_user = UserModel.objects.get_by_email('bob@bob.com')
+        test_user = User.objects.get_by_email('bob@bob.com')
         self.assertEqual(Online.objects.filter(user=test_user).count(), 1)
         self.assertTrue(test_user.check_password(' pass123 '))
 

+ 80 - 0
misago/users/tests/test_user_creation.py

@@ -0,0 +1,80 @@
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from misago.users.models import Rank
+from misago.users.utils import hash_email
+
+User = get_user_model()
+
+
+class UserCreationTests(TestCase):
+    def test_user_is_created(self):
+        user = User.objects.create_user("User", "test@example.com")
+        assert user.pk
+        assert user.joined_on
+
+    def test_user_is_created_with_username_and_slug(self):
+        user = User.objects.create_user("UserName", "test@example.com")
+        assert user.slug == "username"
+
+    def test_user_is_created_with_normalized_email_and_email_hash(self):
+        user = User.objects.create_user("User", "test@eXamPLe.com")
+        assert user.email == "test@example.com"
+        assert user.email_hash == hash_email(user.email)
+
+    def test_user_is_created_with_online_tracker(self):
+        user = User.objects.create_user("User", "test@example.com")
+        assert user.online_tracker
+        assert user.online_tracker.last_click == user.last_login
+
+    def test_user_is_created_with_useable_password(self):
+        password = "password"
+        user = User.objects.create_user("UserUserame", "test@example.com", password)
+        assert user.check_password(password)
+
+    def test_user_is_created_with_default_rank(self):
+        user = User.objects.create_user("User", "test@example.com")
+        assert user.rank == Rank.objects.get_default()
+    
+    def test_user_is_created_with_custom_rank(self):
+        rank = Rank.objects.create(name="Test rank")
+        user = User.objects.create_user("User", "test@example.com", rank=rank)
+        assert user.rank == rank
+    
+    def test_newly_created_user_last_login_is_same_as_join_date(self):
+        user = User.objects.create_user("User", "test@example.com")
+        assert user.last_login == user.joined_on
+    
+    def test_user_is_created_with_authenticated_role(self):
+        user = User.objects.create_user("User", "test@example.com")
+        assert user.roles.get(special_role="authenticated")
+
+    def test_user_is_created_with_diacritics_in_email(self):
+        email = "łóć@łexąmple.com"
+        user = User.objects.create_user("UserName", email)
+        assert user.email == email
+
+    def test_creating_user_without_username_raises_value_error(self):
+        with self.assertRaises(ValueError):
+            User.objects.create_user("", "test@example.com")
+
+    def test_creating_user_without_email_raises_value_error(self):
+        with self.assertRaises(ValueError):
+            User.objects.create_user("User", "")
+
+    def test_create_superuser(self):
+        user = User.objects.create_superuser("User", "test@example.com")
+        assert user.is_staff
+        assert user.is_superuser
+
+    def test_superuser_is_created_with_team_rank(self):
+        user = User.objects.create_superuser("User", "test@example.com")
+        assert "team" in str(user.rank)
+
+    def test_creating_superuser_without_staff_status_raises_value_error(self):
+        with self.assertRaises(ValueError):
+            user = User.objects.create_superuser("User", "test@example.com", is_staff=False)
+
+    def test_creating_superuser_without_superuser_status_raises_value_error(self):
+        with self.assertRaises(ValueError):
+            user = User.objects.create_superuser("User", "test@example.com", is_superuser=False)

+ 62 - 0
misago/users/tests/test_user_getters.py

@@ -0,0 +1,62 @@
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+User = get_user_model()
+
+
+class UserGettersTests(TestCase):
+    def test_get_user_by_username(self):
+        user = User.objects.create_user("User", "test@example.com")
+        assert User.objects.get_by_username("User") == user
+
+    def test_getting_user_by_username_is_case_insensitive(self):
+        user = User.objects.create_user("User", "test@example.com")
+        assert User.objects.get_by_username("uSeR") == user
+
+    def test_getting_user_by_username_raises_does_not_exist_for_no_result(self):
+        with self.assertRaises(User.DoesNotExist):
+            User.objects.get_by_username("user")
+
+    def test_getting_user_by_username_supports_diacritics(self):
+        with self.assertRaises(User.DoesNotExist):
+            User.objects.get_by_username("łóć")
+
+    def test_getting_user_by_username_is_not_doing_fuzzy_matching(self):
+        user = User.objects.create_user("User", "test@example.com")
+        with self.assertRaises(User.DoesNotExist):
+            User.objects.get_by_username("usere")
+
+    def test_get_user_by_email(self):
+        user = User.objects.create_user("User", "test@example.com")
+        assert User.objects.get_by_email("test@example.com") == user
+
+    def test_getting_user_by_email_is_case_insensitive(self):
+        user = User.objects.create_user("User", "test@example.com")
+        assert User.objects.get_by_email("tEsT@eXaMplE.com") == user
+
+    def test_getting_user_by_email_supports_diacritics(self):
+        user = User.objects.create_user("User", "łóć@łexĄmple.com")
+        assert User.objects.get_by_email("łÓć@ŁexĄMple.com") == user
+
+    def test_getting_user_by_email_raises_does_not_exist_for_no_result(self):
+        with self.assertRaises(User.DoesNotExist):
+            User.objects.get_by_email("test@example.com")
+
+    def test_get_user_by_username_using_combined_getter(self):
+        user = User.objects.create_user("User", "test@example.com")
+        assert User.objects.get_by_username_or_email("user") == user
+
+    def test_get_user_by_email_using_combined_getter(self):
+        user = User.objects.create_user("User", "test@example.com")
+        assert User.objects.get_by_username_or_email("test@example.com") == user
+
+    def test_combined_getter_handles_username_slug_and_email_collision(self):
+        email_match = User.objects.create_user("Bob", "test@test.test")
+        slug_match = User.objects.create_user("TestTestTest", "bob@test.com")
+
+        assert User.objects.get_by_username_or_email("test@test.test") == email_match
+        assert User.objects.get_by_username_or_email("TestTestTest") == slug_match
+
+    def test_combined_getter_raises_does_not_exist_for_no_result(self):
+        with self.assertRaises(User.DoesNotExist):
+            User.objects.get_by_username_or_email("User")

+ 0 - 74
misago/users/tests/test_user_model.py

@@ -1,6 +1,5 @@
 from pathlib import Path
 
-from django.core.exceptions import ValidationError
 from django.test import TestCase
 
 from misago.conf import settings
@@ -10,79 +9,6 @@ from misago.users.avatars import dynamic
 from misago.users.models import Avatar, User
 
 
-class UserManagerTests(TestCase):
-    def test_create_user(self):
-        """create_user created new user account successfully"""
-        user = User.objects.create_user(
-            'Bob',
-            'bob@test.com',
-            'Pass.123',
-            set_default_avatar=True,
-        )
-
-        db_user = User.objects.get(id=user.pk)
-
-        self.assertEqual(user.username, db_user.username)
-        self.assertEqual(user.slug, db_user.slug)
-        self.assertEqual(user.email, db_user.email)
-        self.assertEqual(user.email_hash, db_user.email_hash)
-
-    def test_create_user_twice(self):
-        """create_user is raising validation error for duplicate users"""
-        User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
-        with self.assertRaises(ValidationError):
-            User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
-
-    def test_create_superuser(self):
-        """create_superuser created new user account successfully"""
-        user = User.objects.create_superuser('Bob', 'bob@test.com', 'Pass.123')
-
-        db_user = User.objects.get(id=user.pk)
-
-        self.assertTrue(user.is_staff)
-        self.assertTrue(db_user.is_staff)
-        self.assertTrue(user.is_superuser)
-        self.assertTrue(db_user.is_superuser)
-
-    def test_get_user(self):
-        """get_by_ methods return user correctly"""
-        user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
-
-        db_user = User.objects.get_by_username(user.username)
-        self.assertEqual(user, db_user)
-
-        db_user = User.objects.get_by_email(user.email)
-        self.assertEqual(user, db_user)
-
-        db_user = User.objects.get_by_username_or_email(user.username)
-        self.assertEqual(user, db_user)
-
-        db_user = User.objects.get_by_username_or_email(user.email)
-        self.assertEqual(user, db_user)
-
-    def test_get_by_username_or_email_multiple_results(self):
-        """get_by_username_or_email method handles multiple results"""
-        email_match = User.objects.create_user('Bob', 'test@test.test', 'Pass.123')
-        slug_match = User.objects.create_user('TestTestTest', 'bob@test.com', 'Pass.123')
-
-        db_user = User.objects.get_by_username_or_email('test@test.test')
-        self.assertEqual(email_match, db_user)
-
-        db_user = User.objects.get_by_username_or_email('TestTestTest')
-        self.assertEqual(slug_match, db_user)
-
-    def test_getters_unicode_handling(self):
-        """get_by_ methods handle unicode"""
-        with self.assertRaises(User.DoesNotExist):
-            User.objects.get_by_username('łóć')
-
-        with self.assertRaises(User.DoesNotExist):
-            User.objects.get_by_email('łóć@polskimail.pl')
-
-        with self.assertRaises(User.DoesNotExist):
-            User.objects.get_by_username_or_email('łóć@polskimail.pl')
-
-
 class UserModelTests(TestCase):
     def test_anonymize_data(self):
         """anonymize_data sets username and slug to one defined in settings"""

+ 7 - 5
misago/users/tests/test_user_username_api.py

@@ -3,7 +3,7 @@ import json
 from django.contrib.auth import get_user_model
 
 from misago.acl.test import patch_user_acl
-from misago.conf import settings
+from misago.conf.test import override_dynamic_settings
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -17,6 +17,7 @@ class UserUsernameTests(AuthenticatedUserTestCase):
         super().setUp()
         self.link = '/api/users/%s/username/' % self.user.pk
 
+    @override_dynamic_settings(username_length_min=2, username_length_max=4)
     def test_get_change_username_options(self):
         """get to API returns options"""
         response = self.client.get(self.link)
@@ -25,8 +26,8 @@ class UserUsernameTests(AuthenticatedUserTestCase):
         response_json = response.json()
 
         self.assertIsNotNone(response_json['changes_left'])
-        self.assertEqual(response_json['length_min'], settings.username_length_min)
-        self.assertEqual(response_json['length_max'], settings.username_length_max)
+        self.assertEqual(response_json['length_min'], 2)
+        self.assertEqual(response_json['length_max'], 4)
         self.assertIsNone(response_json['next_on'])
 
         for i in range(response_json['changes_left']):
@@ -133,14 +134,15 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
         })
 
     @patch_user_acl({'can_rename_users': 1})
+    @override_dynamic_settings(username_length_min=3, username_length_max=12)
     def test_moderate_username(self):
         """moderate username"""
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
         options = response.json()
-        self.assertEqual(options['length_min'], settings.username_length_min)
-        self.assertEqual(options['length_max'], settings.username_length_max)
+        self.assertEqual(options['length_min'], 3)
+        self.assertEqual(options['length_max'], 12)
 
         response = self.client.post(
             self.link,

+ 69 - 90
misago/users/tests/test_useradmin_views.py

@@ -8,11 +8,12 @@ from misago.categories.models import Category
 from misago.legal.models import Agreement
 from misago.legal.utils import save_user_agreement_acceptance
 from misago.threads.testutils import post_thread, reply_thread
+
 from misago.users.datadownloads import request_user_data_download
 from misago.users.models import Ban, DataDownload, Rank
+from misago.users.testutils import create_test_user
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class UserAdminViewsTests(AdminTestCase):
@@ -42,9 +43,9 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.get(link_base)
         self.assertEqual(response.status_code, 200)
 
-        user_a = UserModel.objects.create_user('Tyrael', 't123@test.com', 'pass123')
-        user_b = UserModel.objects.create_user('Tyrion', 't321@test.com', 'pass123')
-        user_c = UserModel.objects.create_user('Karen', 't432@test.com', 'pass123')
+        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')
 
         # Search both
         response = self.client.get('%s&username=tyr' % link_base)
@@ -94,10 +95,9 @@ class UserAdminViewsTests(AdminTestCase):
         """users list activates multiple users"""
         user_pks = []
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 requires_activation=1,
             )
             user_pks.append(test_user.pk)
@@ -111,7 +111,7 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        inactive_qs = UserModel.objects.filter(
+        inactive_qs = User.objects.filter(
             id__in=user_pks,
             requires_activation=1,
         )
@@ -122,10 +122,9 @@ class UserAdminViewsTests(AdminTestCase):
         """users list bans multiple users"""
         user_pks = []
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 requires_activation=1,
             )
             user_pks.append(test_user.pk)
@@ -157,10 +156,9 @@ class UserAdminViewsTests(AdminTestCase):
         """users list bans multiple users that also have ips"""
         user_pks = []
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 joined_from_ip='73.95.67.27',
                 requires_activation=1,
             )
@@ -193,10 +191,9 @@ class UserAdminViewsTests(AdminTestCase):
         """users list requests data download for multiple users"""
         user_pks = []
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 requires_activation=1,
             )
             user_pks.append(test_user.pk)
@@ -216,10 +213,9 @@ class UserAdminViewsTests(AdminTestCase):
         """users list avoids excessive data download requests for multiple users"""
         user_pks = []
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 requires_activation=1,
             )
             request_user_data_download(test_user)
@@ -256,10 +252,9 @@ class UserAdminViewsTests(AdminTestCase):
         """its impossible to delete admin account"""
         user_pks = []
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
-                'pass123',
             )
             user_pks.append(test_user.pk)
 
@@ -279,16 +274,15 @@ class UserAdminViewsTests(AdminTestCase):
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "be deleted.")
 
-        self.assertEqual(UserModel.objects.count(), 11)
+        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 = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
-                'pass123',
             )
             user_pks.append(test_user.pk)
 
@@ -308,27 +302,25 @@ class UserAdminViewsTests(AdminTestCase):
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "be deleted.")
 
-        self.assertEqual(UserModel.objects.count(), 11)
+        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 = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 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 = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Weebl%s' % i,
                 'weebl%s@test.com' % i,
-                'pass123',
                 requires_activation=0,
             )
 
@@ -340,7 +332,7 @@ class UserAdminViewsTests(AdminTestCase):
             }
         )
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(UserModel.objects.count(), 11)
+        self.assertEqual(User.objects.count(), 11)
 
     def test_mass_delete_all_self(self):
         """its impossible to delete oneself with content"""
@@ -362,10 +354,9 @@ class UserAdminViewsTests(AdminTestCase):
         """its impossible to delete admin account and content"""
         user_pks = []
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
-                'pass123',
             )
             user_pks.append(test_user.pk)
 
@@ -385,16 +376,15 @@ class UserAdminViewsTests(AdminTestCase):
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "be deleted.")
 
-        self.assertEqual(UserModel.objects.count(), 11)
+        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 = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
-                'pass123',
             )
             user_pks.append(test_user.pk)
 
@@ -414,16 +404,15 @@ class UserAdminViewsTests(AdminTestCase):
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "be deleted.")
 
-        self.assertEqual(UserModel.objects.count(), 11)
+        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 = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 requires_activation=1,
             )
             user_pks.append(test_user.pk)
@@ -438,7 +427,7 @@ class UserAdminViewsTests(AdminTestCase):
         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(UserModel.objects.count(), 11)
+        self.assertEqual(User.objects.count(), 11)
 
     def test_new_view(self):
         """new user view creates account"""
@@ -461,8 +450,8 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        UserModel.objects.get_by_username('Bawww')
-        test_user = UserModel.objects.get_by_email('reg@stered.com')
+        User.objects.get_by_username('Bawww')
+        test_user = User.objects.get_by_email('reg@stered.com')
 
         self.assertTrue(test_user.check_password('pass123'))
 
@@ -487,14 +476,14 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        UserModel.objects.get_by_username('Bawww')
-        test_user = UserModel.objects.get_by_email('reg@stered.com')
+        User.objects.get_by_username('Bawww')
+        test_user = User.objects.get_by_email('reg@stered.com')
 
         self.assertTrue(test_user.check_password(' pass123 '))
 
     def test_edit_view(self):
         """edit user view changes account"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
@@ -525,13 +514,13 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        updated_user = User.objects.get(pk=test_user.pk)
         self.assertTrue(updated_user.check_password('newpass123'))
         self.assertEqual(updated_user.username, 'Bawww')
         self.assertEqual(updated_user.slug, 'bawww')
 
-        UserModel.objects.get_by_username('Bawww')
-        UserModel.objects.get_by_email('reg@stered.com')
+        User.objects.get_by_username('Bawww')
+        User.objects.get_by_email('reg@stered.com')
 
     def test_edit_dont_change_username(self):
         """
@@ -539,7 +528,7 @@ class UserAdminViewsTests(AdminTestCase):
 
         This is regression test for issue #640
         """
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
@@ -556,7 +545,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'signature': 'Hello world!',
                 'is_signature_locked': '1',
                 'is_hiding_presence': '0',
@@ -569,14 +557,14 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        updated_user = User.objects.get(pk=test_user.pk)
         self.assertEqual(updated_user.username, 'Bob')
         self.assertEqual(updated_user.slug, 'bob')
         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 = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
@@ -607,17 +595,17 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        updated_user = User.objects.get(pk=test_user.pk)
         self.assertTrue(updated_user.check_password(' newpass123 '))
         self.assertEqual(updated_user.username, 'Bawww')
         self.assertEqual(updated_user.slug, 'bawww')
 
-        UserModel.objects.get_by_username('Bawww')
-        UserModel.objects.get_by_email('reg@stered.com')
+        User.objects.get_by_username('Bawww')
+        User.objects.get_by_email('reg@stered.com')
 
     def test_edit_make_admin(self):
         """edit user view allows super admin to make other user admin"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
@@ -635,7 +623,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '1',
                 'is_superuser': '0',
                 'signature': 'Hello world!',
@@ -650,13 +637,13 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        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 = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
@@ -674,7 +661,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '0',
                 'is_superuser': '1',
                 'signature': 'Hello world!',
@@ -689,16 +675,15 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        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 = UserModel.objects.create_user(
+        test_user = create_test_user(
             'Bob',
             'bob@test.com',
-            'pass123',
             is_staff=True,
             is_superuser=True,
         )
@@ -720,7 +705,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '0',
                 'is_superuser': '0',
                 'signature': 'Hello world!',
@@ -735,7 +719,7 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        updated_user = User.objects.get(pk=test_user.pk)
         self.assertFalse(updated_user.is_staff)
         self.assertFalse(updated_user.is_superuser)
 
@@ -744,7 +728,7 @@ class UserAdminViewsTests(AdminTestCase):
         self.user.is_superuser = False
         self.user.save()
 
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
@@ -762,7 +746,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '1',
                 'is_superuser': '1',
                 'signature': 'Hello world!',
@@ -777,7 +760,7 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        updated_user = User.objects.get(pk=test_user.pk)
         self.assertFalse(updated_user.is_staff)
         self.assertFalse(updated_user.is_superuser)
 
@@ -786,7 +769,7 @@ class UserAdminViewsTests(AdminTestCase):
         self.user.is_superuser = False
         self.user.save()
 
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
@@ -804,7 +787,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '0',
                 'is_superuser': '0',
                 'signature': 'Hello world!',
@@ -821,7 +803,7 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        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!")
 
@@ -830,7 +812,7 @@ class UserAdminViewsTests(AdminTestCase):
         self.user.is_superuser = True
         self.user.save()
 
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
 
         test_user.is_staff = True
         test_user.save()
@@ -852,7 +834,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '1',
                 'is_superuser': '0',
                 'signature': 'Hello world!',
@@ -869,7 +850,7 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        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!")
 
@@ -878,7 +859,7 @@ class UserAdminViewsTests(AdminTestCase):
         self.user.is_superuser = False
         self.user.save()
 
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
 
         test_user.is_staff = True
         test_user.save()
@@ -900,7 +881,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '1',
                 'is_superuser': '0',
                 'signature': 'Hello world!',
@@ -917,13 +897,13 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        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 = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_user.mark_for_delete()
 
         test_link = reverse(
@@ -943,7 +923,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '1',
                 'is_superuser': '0',
                 'signature': 'Hello world!',
@@ -959,13 +938,13 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        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 = UserModel.objects.create_user('Bob', 'bob@test.com')
+        test_user = create_test_user('Bob', 'bob@test.com')
         self.assertFalse(test_user.has_usable_password())
 
         test_link = reverse(
@@ -1000,12 +979,12 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        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 = UserModel.objects.create_user('Bob', 'bob@test.com')
+        test_user = create_test_user('Bob', 'bob@test.com')
         self.assertFalse(test_user.has_usable_password())
 
         test_link = reverse(
@@ -1039,12 +1018,12 @@ class UserAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(response.status_code, 302)
 
-        updated_user = UserModel.objects.get(pk=test_user.pk)
+        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 = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
@@ -1084,7 +1063,7 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_delete_threads_view_staff(self):
         """delete user threads view validates if user deletes staff"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_user.is_staff = True
         test_user.save()
 
@@ -1102,7 +1081,7 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_delete_threads_view_superuser(self):
         """delete user threads view validates if user deletes superuser"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_user.is_superuser = True
         test_user.save()
 
@@ -1120,7 +1099,7 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_delete_threads_view(self):
         """delete user threads view deletes threads"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_link = reverse(
             'misago:admin:users:accounts:delete-threads', kwargs={
                 'pk': test_user.pk,
@@ -1160,7 +1139,7 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_delete_posts_view_staff(self):
         """delete user posts view validates if user deletes staff"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_user.is_staff = True
         test_user.save()
 
@@ -1178,7 +1157,7 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_delete_posts_view_superuser(self):
         """delete user posts view validates if user deletes superuser"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_user.is_superuser = True
         test_user.save()
 
@@ -1196,7 +1175,7 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_delete_posts_view(self):
         """delete user posts view deletes posts"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_link = reverse(
             'misago:admin:users:accounts:delete-posts', kwargs={
                 'pk': test_user.pk,
@@ -1237,7 +1216,7 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_delete_account_view_staff(self):
         """delete user account view validates if user deletes staff"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_user.is_staff = True
         test_user.save()
 
@@ -1255,7 +1234,7 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_delete_account_view_superuser(self):
         """delete user account view validates if user deletes superuser"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_user.is_superuser = True
         test_user.save()
 
@@ -1273,7 +1252,7 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_delete_account_view(self):
         """delete user account view deletes user account"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        test_user = create_test_user('Bob', 'bob@test.com')
         test_link = reverse(
             'misago:admin:users:accounts:delete-account', kwargs={
                 'pk': test_user.pk,

+ 12 - 8
misago/users/tests/test_validators.py

@@ -1,8 +1,9 @@
+from unittest.mock import Mock
+
 from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
 from django.test import TestCase
 
-from misago.conf import settings
 from misago.users.models import Ban
 from misago.users.validators import (
     validate_email, validate_email_available, validate_email_banned, validate_gmail_email,
@@ -56,14 +57,15 @@ class ValidateEmailTests(TestCase):
 class ValidateUsernameTests(TestCase):
     def test_validate_username(self):
         """validate_username has no crashes"""
-        validate_username('LeBob')
+        settings = Mock(username_length_min=1, username_length_max=5)
+        validate_username(settings, 'LeBob')
         with self.assertRaises(ValidationError):
-            validate_username('*')
+            validate_username(settings, '*')
 
 
 class ValidateUsernameAvailableTests(TestCase):
     def setUp(self):
-        self.test_user = UserModel.objects.create_user('EricTheFish', 'eric@test.com', 'pass123')
+        self.test_user = UserModel.objects.create_user('EricTheFish', 'eric@test.com')
 
     def test_valid_name(self):
         """validate_username_available allows available names"""
@@ -117,15 +119,17 @@ class ValidateUsernameContentTests(TestCase):
 class ValidateUsernameLengthTests(TestCase):
     def test_valid_name(self):
         """validate_username_length allows valid names"""
-        validate_username_length('a' * settings.username_length_min)
-        validate_username_length('a' * settings.username_length_max)
+        settings = Mock(username_length_min=1, username_length_max=5)
+        validate_username_length(settings, 'a' * settings.username_length_min)
+        validate_username_length(settings, 'a' * settings.username_length_max)
 
     def test_invalid_name(self):
         """validate_username_length disallows invalid names"""
+        settings = Mock(username_length_min=1, username_length_max=5)
         with self.assertRaises(ValidationError):
-            validate_username_length('a' * (settings.username_length_min - 1))
+            validate_username_length(settings, 'a' * (settings.username_length_min - 1))
         with self.assertRaises(ValidationError):
-            validate_username_length('a' * (settings.username_length_max + 1))
+            validate_username_length(settings, 'a' * (settings.username_length_max + 1))
 
 
 class ValidateGmailEmailTests(TestCase):

+ 32 - 9
misago/users/testutils.py

@@ -3,9 +3,9 @@ from django.contrib.auth import get_user_model
 from misago.core.testutils import MisagoTestCase
 
 from .models import AnonymousUser, Online
+from .setupnewuser import setup_new_user
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class UserTestCase(MisagoTestCase):
@@ -23,7 +23,7 @@ class UserTestCase(MisagoTestCase):
         return AnonymousUser()
 
     def get_authenticated_user(self):
-        return UserModel.objects.create_user(
+        return create_test_user(
             "TestUser",
             "test@user.com",
             self.USER_PASSWORD,
@@ -31,12 +31,12 @@ class UserTestCase(MisagoTestCase):
         )
 
     def get_superuser(self):
-        user = UserModel.objects.create_superuser(
-            "TestSuperUser", "test@superuser.com", self.USER_PASSWORD
+        return create_test_superuser(
+            "TestSuperUser",
+            "test@superuser.com",
+            self.USER_PASSWORD,
+            joined_from_ip=self.USER_IP,
         )
-        user.joined_from_ip = self.USER_IP
-        user.save()
-        return user
 
     def login_user(self, user, password=None):
         self.client.force_login(user)
@@ -53,10 +53,33 @@ class AuthenticatedUserTestCase(UserTestCase):
         self.login_user(self.user)
 
     def reload_user(self):
-        self.user = UserModel.objects.get(id=self.user.id)
+        self.user.refresh_from_db()
 
 
 class SuperUserTestCase(AuthenticatedUserTestCase):
     def get_initial_user(self):
         self.user = self.get_superuser()
         self.login_user(self.user)
+
+
+def create_test_user(username, email, password=None, **extra_fields):
+    """Faster counterpart of regular `create_user` followed by `setup_new_user`"""
+    if "avatars" not in extra_fields:
+        extra_fields["avatars"] = user_placeholder_avatars
+
+    return User.objects.create_user(username, email, password, **extra_fields)
+
+
+def create_test_superuser(username, email, password=None, **extra_fields):
+    """Faster counterpart of regular `create_superuser` followed by `setup_new_user`"""
+    if "avatars" not in extra_fields:
+        extra_fields["avatars"] = user_placeholder_avatars
+
+    return User.objects.create_superuser(username, email, password, **extra_fields)
+
+
+user_placeholder_avatars = [
+        {"size": 400, "url": "http://placekitten.com/400/400"},
+        {"size": 200, "url": "http://placekitten.com/200/200"},
+        {"size": 100, "url": "http://placekitten.com/100/100"},
+    ]

+ 18 - 16
misago/users/validators.py

@@ -22,6 +22,15 @@ UserModel = get_user_model()
 
 
 # E-mail validators
+
+def validate_email(value, exclude=None):
+    """shortcut function that does complete validation of email"""
+    validate_email_content(value)
+    validate_email_available(value, exclude)
+    validate_email_banned(value)
+
+
+
 def validate_email_available(value, exclude=None):
     try:
         user = UserModel.objects.get_by_email(value)
@@ -41,14 +50,15 @@ def validate_email_banned(value):
             raise ValidationError(_("This e-mail address is not allowed."))
 
 
-def validate_email(value, exclude=None):
-    """shortcut function that does complete validation of email"""
-    validate_email_content(value)
-    validate_email_available(value, exclude)
-    validate_email_banned(value)
+# Username validators
+def validate_username(settings, value, exclude=None):
+    """shortcut function that does complete validation of username"""
+    validate_username_length(settings, value)
+    validate_username_content(value)
+    validate_username_available(value, exclude)
+    validate_username_banned(value)
 
 
-# Username validators
 def validate_username_available(value, exclude=None):
     try:
         user = UserModel.objects.get_by_username(value)
@@ -73,7 +83,7 @@ def validate_username_content(value):
         raise ValidationError(_("Username can only contain latin alphabet letters and digits."))
 
 
-def validate_username_length(value):
+def validate_username_length(settings, value):
     if len(value) < settings.username_length_min:
         message = ngettext(
             "Username must be at least %(limit_value)s character long.",
@@ -91,14 +101,6 @@ def validate_username_length(value):
         raise ValidationError(message % {'limit_value': settings.username_length_max})
 
 
-def validate_username(value, exclude=None):
-    """shortcut function that does complete validation of username"""
-    validate_username_length(value)
-    validate_username_content(value)
-    validate_username_available(value, exclude)
-    validate_username_banned(value)
-
-
 # New account validators
 SFS_API_URL = 'http://api.stopforumspam.org/api?email=%(email)s&ip=%(ip)s&f=json&confidence'  # noqa
 
@@ -141,7 +143,7 @@ validators_list = settings.MISAGO_NEW_REGISTRATIONS_VALIDATORS
 REGISTRATION_VALIDATORS = list(map(import_string, validators_list))
 
 
-def raise_validation_error(fieldname, validation_error):
+def raise_validation_error(*_):
     raise ValidationError()
 
 

+ 25 - 11
misago/users/views/admin/users.py

@@ -9,7 +9,6 @@ from misago.acl.useracl import get_user_acl
 from misago.admin.auth import start_admin_session
 from misago.admin.views import generic
 from misago.categories.models import Category
-from misago.conf import settings
 from misago.core.mail import mail_users
 from misago.core.pgutils import chunk_queryset
 from misago.threads.models import Thread
@@ -19,16 +18,16 @@ from misago.users.forms.admin import (
     BanUsersForm, EditUserForm, EditUserFormFactory, NewUserForm, SearchUsersForm)
 from misago.users.models import Ban
 from misago.users.profilefields import profilefields
+from misago.users.setupnewuser import setup_new_user
 from misago.users.signatures import set_user_signature
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class UserAdmin(generic.AdminBaseMixin):
     root_link = 'misago:admin:users:accounts:index'
     templates_dir = 'misago/admin/users'
-    model = UserModel
+    model = User
 
     def create_form_type(self, request, target):
         add_is_active_fields = False
@@ -115,13 +114,18 @@ class UsersList(UserAdmin, generic.ListView):
             raise generic.MassActionError(message)
         else:
             activated_users_pks = [u.pk for u in inactive_users]
-            queryset = UserModel.objects.filter(pk__in=activated_users_pks)
-            queryset.update(requires_activation=UserModel.ACTIVATION_NONE)
+            queryset = User.objects.filter(pk__in=activated_users_pks)
+            queryset.update(requires_activation=User.ACTIVATION_NONE)
 
             subject = _("Your account on %(forum_name)s forums has been activated")
-            mail_subject = subject % {'forum_name': settings.forum_name}
+            mail_subject = subject % {'forum_name': request.settings.forum_name}
 
-            mail_users(inactive_users, mail_subject, 'misago/emails/activation/by_admin')
+            mail_users(
+                inactive_users,
+                mail_subject,
+                'misago/emails/activation/by_admin',
+                context={"settings": request.settings},
+            )
 
             messages.success(request, _("Selected users accounts have been activated."))
 
@@ -247,15 +251,25 @@ class NewUser(UserAdmin, generic.ModelFormView):
     template = 'new.html'
     message_submit = _('New user "%(user)s" has been registered.')
 
+    def initialize_form(self, form, request, target):
+        if request.method == 'POST':
+            return form(
+                request.POST,
+                request.FILES,
+                instance=target,
+                request=request,
+            )
+        else:
+            return form(instance=target, request=request)
+            
     def handle_form(self, form, request, target):
-        new_user = UserModel.objects.create_user(
+        new_user = User.objects.create_user(
             form.cleaned_data['username'],
             form.cleaned_data['email'],
             form.cleaned_data['new_password'],
             title=form.cleaned_data['title'],
             rank=form.cleaned_data.get('rank'),
             joined_from_ip=request.user_ip,
-            set_default_avatar=True
         )
 
         if form.cleaned_data.get('staff_level'):
@@ -265,7 +279,7 @@ class NewUser(UserAdmin, generic.ModelFormView):
             new_user.roles.add(*form.cleaned_data['roles'])
 
         new_user.update_acl_key()
-        new_user.save()
+        setup_new_user(request.settings, new_user)
 
         messages.success(request, self.message_submit % {'user': target.username})
         return redirect('misago:admin:users:accounts:edit', pk=new_user.pk)