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

+ 1 - 2
devproject/test_settings.py

@@ -17,8 +17,7 @@ DATABASES = {
 # Use in-memory cache
 # Use in-memory cache
 CACHES = {
 CACHES = {
     'default': {
     '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 django.test import TestCase
 
 
 from misago.acl.useracl import get_user_acl
 from misago.acl.useracl import get_user_acl
+from misago.conftest import get_cache_versions
 from misago.users.models import AnonymousUser
 from misago.users.models import AnonymousUser
 
 
 User = get_user_model()
 User = get_user_model()
 
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 class GettingUserACLTests(TestCase):
 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 import useracl
 from misago.acl.test import patch_user_acl
 from misago.acl.test import patch_user_acl
+from misago.conftest import get_cache_versions
 
 
 User = get_user_model()
 User = get_user_model()
 
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 def callable_acl_patch(user, user_acl):
 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 django.test import TestCase
 
 
 from misago.acl.useracl import get_user_acl, serialize_user_acl
 from misago.acl.useracl import get_user_acl, serialize_user_acl
+from misago.conftest import get_cache_versions
 
 
 User = get_user_model()
 User = get_user_model()
 
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 class SerializingUserACLTests(TestCase):
 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 django.test import TestCase
 
 
 from misago.acl.middleware import user_acl_middleware
 from misago.acl.middleware import user_acl_middleware
+from misago.conftest import get_cache_versions
 
 
 User = get_user_model()
 User = get_user_model()
 
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 class MiddlewareTests(TestCase):
 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):
 class CacheVersionsTests(TestCase):
     def test_db_getter_returns_cache_versions_from_db(self):
     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)
     @patch('django.core.cache.cache.get', return_value=True)
     def test_cache_getter_returns_cache_versions_from_cache(self, cache_get):
     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)
     @patch('django.core.cache.cache.get', return_value=True)
     def test_getter_reads_from_cache(self, cache_get):
     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)
         cache_get.assert_called_once_with(CACHE_NAME)
 
 
     @patch('django.core.cache.cache.set')
     @patch('django.core.cache.cache.set')
     @patch('django.core.cache.cache.get', return_value=None)
     @patch('django.core.cache.cache.get', return_value=None)
     def test_getter_reads_from_db_when_cache_is_not_available(self, cache_get, _):
     def test_getter_reads_from_db_when_cache_is_not_available(self, cache_get, _):
         db_caches = get_cache_versions_from_db()
         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)
         cache_get.assert_called_once_with(CACHE_NAME)
 
 
     @patch('django.core.cache.cache.set')
     @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.acl.useracl import get_user_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.categories.utils import get_categories_tree, get_category_path
 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.core import threadstore
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 def get_patched_user_acl(user):
 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'
 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 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 {
     return {
-        'DEBUG': misago_settings.DEBUG,
-        'LANGUAGE_CODE_SHORT': get_language()[:2],
-        'misago_settings': db_settings,
         'BLANK_AVATAR_URL': BLANK_AVATAR_URL,
         '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):
 def preload_settings_json(request):
-    preloaded_settings = db_settings.get_public_settings()
+    preloaded_settings = request.settings.get_public_settings()
 
 
     preloaded_settings.update({
     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(),
         'SOCIAL_AUTH': get_enabled_social_auth_sites_list(),
     })
     })
 
 
     request.frontend_context.update({
     request.frontend_context.update({
-        'SETTINGS': preloaded_settings,
-        'MISAGO_PATH': reverse('misago:index'),
         'BLANK_AVATAR_URL': BLANK_AVATAR_URL,
         '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 {}
     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
 from misago.admin.forms import YesNoSwitch
 
 
-
-__ALL__ = ['ChangeSettingsForm']
+__all__ = ['ChangeSettingsForm']
 
 
 
 
 class ValidateChoicesNum(object):
 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 .hydrators import dehydrate_value
 from .utils import get_setting_value, has_custom_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.field_extra = field_extra or {}
 
 
     setting.save()
     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 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
 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):
 class ContextProcessorsTests(TestCase):
@@ -15,10 +15,10 @@ class ContextProcessorsTests(TestCase):
 
 
     def test_db_settings(self):
     def test_db_settings(self):
         """DBSettings are exposed to templates"""
         """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):
     def test_preload_settings(self):
         """site configuration is preloaded by middleware"""
         """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 misago.admin.views import render as mi_render
 
 
-from . import db_settings
+from .cache import clear_settings_cache
 from .forms import ChangeSettingsForm
 from .forms import ChangeSettingsForm
 from .models import SettingsGroup
 from .models import SettingsGroup
 
 
@@ -44,7 +44,7 @@ def group(request, key):
                 setting.value = new_values[setting.setting]
                 setting.value = new_values[setting.setting]
                 setting.save(update_fields=['dry_value'])
                 setting.save(update_fields=['dry_value'])
 
 
-            db_settings.flush_cache()
+            clear_settings_cache()
 
 
             messages.success(request, _("Changes in settings have been saved!"))
             messages.success(request, _("Changes in settings have been saved!"))
             return redirect('misago:admin:system:settings:group', key=key)
             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.template.loader import render_to_string
 from django.utils.translation import get_language
 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
 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],
         'LANGUAGE_CODE': get_language()[:2],
         'LOGIN_URL': settings.LOGIN_URL,
         'LOGIN_URL': settings.LOGIN_URL,
 
 
-        'misago_settings': db_settings,
-
         'user': recipient,
         'user': recipient,
         'sender': sender,
         'sender': sender,
         'subject': subject,
         '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_plain = render_to_string('%s.txt' % template, context)
     message_html = render_to_string('%s.html' % 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.acl.useracl import get_user_acl
 from misago.users.models import AnonymousUser
 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.testproject.views import mock_custom_403_error_page, mock_custom_404_error_page
 from misago.core.utils import encode_json_html
 from misago.core.utils import encode_json_html
 
 
@@ -76,7 +78,8 @@ class ErrorPageViewsTests(TestCase):
 
 
 def test_request(url):
 def test_request(url):
     request = RequestFactory().get(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 = AnonymousUser()
     request.user_acl = get_user_acl(request.user, request.cache_versions)
     request.user_acl = get_user_acl(request.user, request.cache_versions)
     request.include_frontend_context = True
     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 django.urls import reverse
 
 
 from misago.acl.useracl import get_user_acl
 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.users.models import AnonymousUser
 
 
 from misago.core.middleware import ExceptionHandlerMiddleware
 from misago.core.middleware import ExceptionHandlerMiddleware
@@ -11,7 +14,8 @@ from misago.core.middleware import ExceptionHandlerMiddleware
 
 
 def test_request():
 def test_request():
     request = RequestFactory().get(reverse('misago:index'))
     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 = AnonymousUser()
     request.user_acl = get_user_acl(request.user, request.cache_versions)
     request.user_acl = get_user_acl(request.user, request.cache_versions)
     request.include_frontend_context = True
     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.test import TestCase
 from django.urls import reverse
 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()
 UserModel = get_user_model()
 
 
 
 
 class MailTests(TestCase):
 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):
     def test_mail_user(self):
         """mail_user sets message in backend"""
         """mail_user sets message in backend"""
         user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
         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")
         self.assertEqual(mail.outbox[0].subject, "Misago Test Mail")
 
 
@@ -26,6 +47,9 @@ class MailTests(TestCase):
 
 
     def test_mail_users(self):
     def test_mail_users(self):
         """mail_users sets messages in backend"""
         """mail_users sets messages in backend"""
+        cache_versions = get_cache_versions()
+        settings = DynamicSettings(cache_versions)
+
         test_users = [
         test_users = [
             UserModel.objects.create_user('Alpha', 'alpha@test.com', 'pass123'),
             UserModel.objects.create_user('Alpha', 'alpha@test.com', 'pass123'),
             UserModel.objects.create_user('Beta', 'beta@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'),
             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
         spams_sent = 0
         for message in mail.outbox:
         for message in mail.outbox:

+ 3 - 1
misago/markup/api.py

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

+ 2 - 1
misago/markup/serializers.py

@@ -7,5 +7,6 @@ class MarkupSerializer(serializers.Serializer):
     post = serializers.CharField(required=False, allow_blank=True)
     post = serializers.CharField(required=False, allow_blank=True)
 
 
     def validate(self, data):
     def validate(self, data):
-        validate_post_length(data.get('post', ''))
+        settings = self.context["settings"]
+        validate_post_length(settings, data.get("post", ""))
         return data
         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.acl.useracl import get_user_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.conf import settings
+from misago.conftest import get_cache_versions
 from misago.readtracker import poststracker, categoriestracker
 from misago.readtracker import poststracker, categoriestracker
 from misago.readtracker.models import PostRead
 from misago.readtracker.models import PostRead
 from misago.threads import testutils
 from misago.threads import testutils
 
 
 User = get_user_model()
 User = get_user_model()
 
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 class AnonymousUser(object):
 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.acl.useracl import get_user_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.conf import settings
+from misago.conftest import get_cache_versions
 from misago.readtracker import poststracker, threadstracker
 from misago.readtracker import poststracker, threadstracker
 from misago.readtracker.models import PostRead
 from misago.readtracker.models import PostRead
 from misago.threads import testutils
 from misago.threads import testutils
 
 
 User = get_user_model()
 User = get_user_model()
 
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 class AnonymousUser(object):
 class AnonymousUser(object):

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

@@ -5,14 +5,14 @@
     <meta charset="utf-8">
     <meta charset="utf-8">
     <meta http-equiv="X-UA-Compatible" content="IE=edge">
     <meta http-equiv="X-UA-Compatible" content="IE=edge">
     <meta name="viewport" content="width=device-width,initial-scale=1">
     <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 %}">
     <meta name="description" content="{% spaceless %}{% block meta-description %}{% endblock %}{% endspaceless %}">
     {% spaceless %}
     {% spaceless %}
       {% block meta-extra %}{% endblock meta-extra %}
       {% block meta-extra %}{% endblock meta-extra %}
       {% block og-tags %}
       {% 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: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:type" content="website" />
         <meta property="og:url" content="{% spaceless %}{% block og-url %}{{ SITE_ADDRESS }}{% endblock og-url %}{% endspaceless %}" />
         <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 %}" />
         <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 %}
   {% if THREADS_ON_INDEX %}
     {% trans "Categories" %} | {{ block.super }}
     {% trans "Categories" %} | {{ block.super }}
   {% else %}
   {% else %}
-    {% if misago_settings.forum_index_title %}
-      {{ misago_settings.forum_index_title }}
+    {% if settings.forum_index_title %}
+      {{ settings.forum_index_title }}
     {% else %}
     {% else %}
-      {{ misago_settings.forum_name }}
+      {{ settings.forum_name }}
     {% endif %}
     {% endif %}
   {% endif %}
   {% endif %}
 {% endblock title %}
 {% endblock title %}
 
 
 
 
 {% block meta-description %}
 {% 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 %}
   {% 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 }}.
       There is {{ categories }} main category currenty available on the {{ forum_name }}.
     {% plural %}
     {% plural %}
       There are {{ categories }} main categories currenty available on the {{ forum_name }}.
       There are {{ categories }} main categories currenty available on the {{ forum_name }}.
@@ -32,20 +32,20 @@
   {% if THREADS_ON_INDEX %}
   {% if THREADS_ON_INDEX %}
     {% trans "Categories" %}
     {% trans "Categories" %}
   {% else %}
   {% else %}
-    {% if misago_settings.forum_index_title %}
-      {{ misago_settings.forum_index_title }}
+    {% if settings.forum_index_title %}
+      {{ settings.forum_index_title }}
     {% else %}
     {% else %}
-      {{ misago_settings.forum_name }}
+      {{ settings.forum_name }}
     {% endif %}
     {% endif %}
   {% endif %}
   {% endif %}
 {% endblock og-title %}
 {% endblock og-title %}
 
 
 
 
 {% block og-description %}
 {% 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 %}
   {% 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 }}.
       There is {{ categories }} main category currenty available on the {{ forum_name }}.
     {% plural %}
     {% plural %}
       There are {{ categories }} main categories currenty available on the {{ forum_name }}.
       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="page-header">
     <div class="container">
     <div class="container">
       {% if is_index %}
       {% 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 %}
         {% else %}
-          <h1>{{ misago_settings.forum_name }}</h1>
+          <h1>{{ settings.forum_name }}</h1>
         {% endif %}
         {% endif %}
       {% else %}
       {% else %}
         <h1>{% trans "Categories" %}</h1>
         <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">
               <table border="0" width="100%" height="100%" cellpadding="0" cellspacing="0">
                 <tr>
                 <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>
                   <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>
                 </tr>
               </table>
               </table>
@@ -68,7 +68,7 @@
 
 
             <br>
             <br>
             <div style="border-top: 1px solid #ddd; color: #666; font-size: 12px; line-height: 18px;">
             <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>
               <br><a href="{{ SITE_ADDRESS }}" style="color: #888; text-decoration: underline;">Sent from {{ SITE_HOST }}</a>
             </div>
             </div>
 
 

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

@@ -1,4 +1,4 @@
-{{ misago_settings.forum_name }}
+{{ settings.forum_name }}
 ================================================
 ================================================
 
 
 {% block title %}{{ subject }}{% endblock %}
 {% 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 }}
 Sent from {{ SITE_ADDRESS }}

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

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

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

@@ -3,15 +3,15 @@
 
 
 
 
 {% block title %}
 {% block title %}
-{% if misago_settings.forum_index_title %}
-{{ misago_settings.forum_index_title }}
+{% if settings.forum_index_title %}
+{{ settings.forum_index_title }}
 {% else %}
 {% else %}
-{{ misago_settings.forum_name }}
+{{ settings.forum_name }}
 {% endif %}
 {% endif %}
 {% endblock title %}
 {% 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 %}
 {% 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">
 <nav class="navbar navbar-misago navbar-inverse navbar-static-top" role="navigation">
 
 
   <div class="container navbar-full navbar-desktop-nav">
   <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">
       <a href="{% url 'misago:index' %}" class="navbar-brand">
         <img src="{% static 'misago/img/logo.png' %}" alt="">
         <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 %}
         {% endif %}
       </a>
       </a>
     {% endif %}
     {% endif %}
@@ -46,7 +46,7 @@
   </div><!-- /full navbar -->
   </div><!-- /full navbar -->
 
 
   <ul class="nav navbar-nav navbar-compact-nav" itemscope itemtype="http://schema.org/SiteNavigationElement">
   <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>
       <li>
         <a href="{% url 'misago:index' %}" class="brand-link">
         <a href="{% url 'misago:index' %}" class="brand-link">
           <img src="{% static 'misago/img/logo.png' %}" alt="">
           <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 THREADS_ON_INDEX and paginator.page == 1 %}
     {% if list_name %}
     {% if list_name %}
       {{ list_name }} | {{ block.super }}
       {{ 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 %}
     {% else %}
-      {{ misago_settings.forum_name }}
+      {{ settings.forum_name }}
     {% endif %}
     {% endif %}
   {% else %}
   {% else %}
     {% if list_name %}
     {% if list_name %}
@@ -24,18 +24,18 @@
 
 
 
 
 {% block meta-description %}
 {% 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 %}
   {% endif %}
 {% endblock meta-description %}
 {% endblock meta-description %}
 
 
 
 
 {% block og-title %}
 {% block og-title %}
   {% if THREADS_ON_INDEX %}
   {% 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 %}
     {% else %}
-      {{ misago_settings.forum_name }}
+      {{ settings.forum_name }}
     {% endif %}
     {% endif %}
   {% else %}
   {% else %}
     {% trans "Threads" %}
     {% trans "Threads" %}
@@ -54,10 +54,10 @@
         <div class="row">
         <div class="row">
           <div class="col-xs-12">
           <div class="col-xs-12">
             {% if THREADS_ON_INDEX %}
             {% 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 %}
               {% else %}
-                <h1>{{ misago_settings.forum_name }}</h1>
+                <h1>{{ settings.forum_name }}</h1>
               {% endif %}
               {% endif %}
             {% else %}
             {% else %}
               <h1>{% trans "Threads" %}</h1>
               <h1>{% trans "Threads" %}</h1>

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

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

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

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

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

@@ -50,7 +50,8 @@ class EmailNotificationMiddleware(PostingMiddleware):
             'misago/emails/thread/reply',
             'misago/emails/thread/reply',
             sender=self.user,
             sender=self.user,
             context={
             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.markup import common_flavour
 from misago.threads.checksums import update_post_checksum
 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 misago.users.audittrail import create_audit_trail
 
 
 from . import PostingEndpoint, PostingMiddleware
 from . import PostingEndpoint, PostingMiddleware
@@ -78,12 +80,15 @@ class ReplyMiddleware(PostingMiddleware):
 
 
 class ReplySerializer(serializers.Serializer):
 class ReplySerializer(serializers.Serializer):
     post = serializers.CharField(
     post = serializers.CharField(
-        validators=[validate_post_length],
         error_messages={
         error_messages={
             'required': gettext_lazy("You have to enter a message."),
             '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):
     def validate(self, data):
         if data.get('post'):
         if data.get('post'):
             data['parsing_result'] = self.parse_post(data['post'])
             data['parsing_result'] = self.parse_post(data['post'])
@@ -100,8 +105,11 @@ class ReplySerializer(serializers.Serializer):
 
 
 class ThreadSerializer(ReplySerializer):
 class ThreadSerializer(ReplySerializer):
     title = serializers.CharField(
     title = serializers.CharField(
-        validators=[validate_title],
         error_messages={
         error_messages={
             'required': gettext_lazy("You have to enter thread title."),
             '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:
         if self.mode != PostingEndpoint.START:
             return
             return
 
 
-        if self.user.subscribe_to_started_threads == UserModel.SUBSCRIBE_NONE:
+        if self.user.subscribe_to_started_threads == UserModel.SUBSCRIPTION_NONE:
             return
             return
 
 
         self.user.subscription_set.create(
         self.user.subscription_set.create(
             category=self.thread.category,
             category=self.thread.category,
             thread=self.thread,
             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):
     def subscribe_replied_thread(self):
         if self.mode != PostingEndpoint.REPLY:
         if self.mode != PostingEndpoint.REPLY:
             return
             return
 
 
-        if self.user.subscribe_to_replied_threads == UserModel.SUBSCRIBE_NONE:
+        if self.user.subscribe_to_replied_threads == UserModel.SUBSCRIPTION_NONE:
             return
             return
 
 
         try:
         try:
@@ -55,5 +55,5 @@ class SubscribeMiddleware(PostingMiddleware):
         self.user.subscription_set.create(
         self.user.subscription_set.create(
             category=self.thread.category,
             category=self.thread.category,
             thread=self.thread,
             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):
 def threads_merge_endpoint(request):
     serializer = MergeThreadsSerializer(
     serializer = MergeThreadsSerializer(
         data=request.data,
         data=request.data,
-        context={'user_acl': request.user_acl},
+        context={
+            'settings': request.settings,
+            'user_acl': request.user_acl,
+        },
     )
     )
 
 
     if not serializer.is_valid():
     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
     allow_start_thread, allow_unhide_thread, allow_unmark_best_answer
 )
 )
 from misago.threads.serializers import ThreadParticipantSerializer
 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
 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.'))
         raise PermissionDenied(_('Not a valid string.'))
 
 
     try:
     try:
-        validate_title(value_cleaned)
+        validate_thread_title(request.settings, value_cleaned)
     except ValidationError as e:
     except ValidationError as e:
         raise PermissionDenied(e.args[0])
         raise PermissionDenied(e.args[0])
 
 

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

@@ -1,7 +1,6 @@
 from django.db import migrations
 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
 _ = lambda s: s
 
 
@@ -68,8 +67,6 @@ def update_threads_settings(apps, schema_editor):
         }
         }
     )
     )
 
 
-    delete_settings_cache()
-
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 

+ 5 - 2
misago/threads/participants.py

@@ -149,8 +149,11 @@ def build_noticiation_email(request, thread, user):
     }
     }
 
 
     return build_mail(
     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)
     can_start_thread, exclude_invisible_posts)
 from misago.threads.threadtypes import trees_map
 from misago.threads.threadtypes import trees_map
 from misago.threads.utils import get_thread_id_from_url
 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
 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)
     is_closed = serializers.NullBooleanField(required=False)
 
 
     def validate_title(self, title):
     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):
     def validate_category(self, category_id):
         user_acl = self.context['user_acl']
         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.test import RequestFactory
 from django.urls import reverse
 from django.urls import reverse
 
 
+from misago.cache.versions import get_cache_versions
 from misago.categories.models import Category
 from misago.categories.models import Category
+from misago.conf.dynamicsettings import DynamicSettings
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 from misago.threads import testutils
 from misago.threads import testutils
@@ -32,7 +34,8 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
         request = self.factory.get('/customer/details')
         request = self.factory.get('/customer/details')
         request.user = user or self.user
         request.user = user or self.user
         request.user_ip = '127.0.0.1'
         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.include_frontend_context = False
         request.frontend_context = {}
         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.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.conf import settings
+from misago.conftest import get_cache_versions
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.api.postingendpoint import PostingEndpoint
 from misago.threads.api.postingendpoint import PostingEndpoint
 from misago.threads.api.postingendpoint.attachments import (
 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.threads.models import Attachment, AttachmentType
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 def patch_attachments_acl(acl_patch=None):
 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 import useracl
 from misago.acl.objectacl import add_acl_to_obj
 from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.categories.models import Category
+from misago.conftest import get_cache_versions
 from misago.threads.events import record_event
 from misago.threads.events import record_event
 from misago.threads.models import Thread
 from misago.threads.models import Thread
 
 
 User = get_user_model()
 User = get_user_model()
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 class EventsApiTests(TestCase):
 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})
     @patch_category_acl({"can_start_threads": True})
     def test_dont_subscribe(self):
     def test_dont_subscribe(self):
         """middleware makes no subscription to thread"""
         """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()
         self.user.save()
 
 
         response = self.client.post(
         response = self.client.post(
@@ -45,7 +45,7 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_start_threads": True})
     @patch_category_acl({"can_start_threads": True})
     def test_subscribe(self):
     def test_subscribe(self):
         """middleware subscribes thread"""
         """middleware subscribes thread"""
-        self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NOTIFY
+        self.user.subscribe_to_started_threads = UserModel.SUBSCRIPTION_NOTIFY
         self.user.save()
         self.user.save()
 
 
         response = self.client.post(
         response = self.client.post(
@@ -68,7 +68,7 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_start_threads": True})
     @patch_category_acl({"can_start_threads": True})
     def test_email_subscribe(self):
     def test_email_subscribe(self):
         """middleware subscribes thread with an email"""
         """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()
         self.user.save()
 
 
         response = self.client.post(
         response = self.client.post(
@@ -102,8 +102,8 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_reply_threads": True})
     @patch_category_acl({"can_reply_threads": True})
     def test_dont_subscribe(self):
     def test_dont_subscribe(self):
         """middleware makes no subscription to thread"""
         """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()
         self.user.save()
 
 
         response = self.client.post(
         response = self.client.post(
@@ -119,7 +119,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_reply_threads": True})
     @patch_category_acl({"can_reply_threads": True})
     def test_subscribe(self):
     def test_subscribe(self):
         """middleware subscribes thread"""
         """middleware subscribes thread"""
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NOTIFY
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_NOTIFY
         self.user.save()
         self.user.save()
 
 
         response = self.client.post(
         response = self.client.post(
@@ -138,7 +138,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_reply_threads": True})
     @patch_category_acl({"can_reply_threads": True})
     def test_email_subscribe(self):
     def test_email_subscribe(self):
         """middleware subscribes thread with an email"""
         """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()
         self.user.save()
 
 
         response = self.client.post(
         response = self.client.post(
@@ -157,7 +157,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_category_acl({"can_reply_threads": True})
     @patch_category_acl({"can_reply_threads": True})
     def test_subscribe_with_events(self):
     def test_subscribe_with_events(self):
         """middleware omits events when testing for replied thread"""
         """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()
         self.user.save()
 
 
         # set event in thread
         # set event in thread
@@ -181,7 +181,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
     @patch_user_acl({"can_omit_flood_protection": True})
     @patch_user_acl({"can_omit_flood_protection": True})
     def test_dont_subscribe_replied(self):
     def test_dont_subscribe_replied(self):
         """middleware omits threads user already replied"""
         """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()
         self.user.save()
 
 
         response = self.client.post(
         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 import useracl
 from misago.acl.objectacl import add_acl_to_obj
 from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.categories.models import Category
+from misago.conftest import get_cache_versions
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Attachment
 from misago.threads.models import Attachment
 from misago.threads.serializers import AttachmentSerializer
 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')
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
 TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
 
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 class EditorApiTestCase(AuthenticatedUserTestCase):
 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 import useracl
 from misago.acl.objectacl import add_acl_to_obj
 from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.categories.models import Category
+from misago.conftest import get_cache_versions
 from misago.readtracker import poststracker
 from misago.readtracker import poststracker
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Poll, PollVote, Post, Thread
 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
 from .test_threads_api import ThreadsApiTestCase
 
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 class ThreadsMergeApiTests(ThreadsApiTestCase):
 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.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.conf import settings
+from misago.conftest import get_cache_versions
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.checksums import update_post_checksum
 from misago.threads.checksums import update_post_checksum
 from misago.threads.events import record_event
 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.threads.moderation import hide_post
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-cache_versions = {"acl": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 def patch_category_acl(new_acl=None):
 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.core.exceptions import ValidationError
 from django.test import TestCase
 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):
 class ValidatePostLengthTests(TestCase):
-    def test_valid_post(self):
+    def test_valid_post_length_passes_validation(self):
         """valid post passes validation"""
         """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"""
         """empty post is rejected"""
+        settings = Mock(post_length_min=3)
         with self.assertRaises(ValidationError):
         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"""
         """too short post is rejected"""
+        settings = Mock(post_length_min=3)
         with self.assertRaises(ValidationError):
         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"""
         """too long post is rejected"""
+        settings = Mock(post_length_min=1, post_length_max=2)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             post = 'a' * settings.post_length_max
             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 = [
         VALID_TITLES = [
             'Lorem ipsum dolor met',
             'Lorem ipsum dolor met',
             '123 456 789 112'
             '123 456 789 112'
@@ -38,27 +44,28 @@ class ValidateTitleTests(TestCase):
         ]
         ]
 
 
         for title in VALID_TITLES:
         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"""
         """empty title is rejected"""
+        settings = Mock(thread_title_length_min=3)
         with self.assertRaises(ValidationError):
         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"""
         """too short title is rejected"""
+        settings = Mock(thread_title_length_min=3)
         with self.assertRaises(ValidationError):
         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"""
         """too long title is rejected"""
+        settings = Mock(thread_title_length_min=1, thread_title_length_max=2)
         with self.assertRaises(ValidationError):
         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"""
         """unsluggable title is rejected"""
+        settings = Mock(thread_title_length_min=1, thread_title_length_max=9)
         with self.assertRaises(ValidationError):
         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
     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(
         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 character long (it has %(show_value)s).",
             "Thread title should be at least %(limit_value)s characters 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(
         raise ValidationError(
             message % {
             message % {
                 'limit_value': settings.thread_title_length_min,
                 '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(
         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 character (it has %(show_value)s).",
             "Thread title cannot be longer than %(limit_value)s characters (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(
         raise ValidationError(
             message % {
             message % {
                 'limit_value': settings.thread_title_length_max,
                 '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."))
         raise ValidationError(_("You have to enter a message."))
 
 
-    if post_len < settings.post_length_min:
+    if value_len < settings.post_length_min:
         message = ngettext(
         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 character long (it has %(show_value)s).",
             "Posted message should be at least %(limit_value)s characters 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(
         raise ValidationError(
             message % {
             message % {
                 'limit_value': settings.post_length_min,
                 '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(
         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 character (it has %(show_value)s).",
             "Posted message cannot be longer than %(limit_value)s characters (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(
         raise ValidationError(
             message % {
             message % {
                 'limit_value': settings.post_length_max,
                 '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"""
     """GET /auth/criteria/ will return password and username criteria for accounts"""
     criteria = {
     criteria = {
         'username': {
         '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': [],
         'password': [],
     }
     }
@@ -100,7 +100,7 @@ def send_activation(request):
 
 
         mail_subject = _("Activate %(user)s account on %(forum_name)s forums") % {
         mail_subject = _("Activate %(user)s account on %(forum_name)s forums") % {
             'user': requesting_user.username,
             'user': requesting_user.username,
-            'forum_name': settings.forum_name,
+            'forum_name': request.settings.forum_name,
         }
         }
 
 
         mail_user(
         mail_user(
@@ -108,7 +108,8 @@ def send_activation(request):
             mail_subject,
             mail_subject,
             'misago/emails/activation/by_user',
             'misago/emails/activation/by_user',
             context={
             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") % {
         mail_subject = _("Change %(user)s password on %(forum_name)s forums") % {
             'user': requesting_user.username,
             'user': requesting_user.username,
-            'forum_name': settings.forum_name,
+            'forum_name': request.settings.forum_name,
         }
         }
 
 
         confirmation_token = make_password_change_token(requesting_user)
         confirmation_token = make_password_change_token(requesting_user)
@@ -148,7 +149,8 @@ def send_password_form(request):
             mail_subject,
             mail_subject,
             'misago/emails/change_password_form_link',
             'misago/emails/change_password_form_link',
             context={
             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 django.http import Http404
 
 
-from misago.conf import settings
-
 
 
 @api_view()
 @api_view()
 def question(request):
 def question(request):
-    if settings.qa_question:
+    if request.settings.qa_question:
         return Response({
         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:
     else:
         raise Http404()
         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,
             status=status.HTTP_403_FORBIDDEN,
         )
         )
 
 
-    avatar_options = get_avatar_options(request.user)
+    avatar_options = get_avatar_options(request, request.user)
     if request.method == 'POST':
     if request.method == 'POST':
-        return avatar_post(avatar_options, request.user, request.data)
+        return avatar_post(request, avatar_options)
     else:
     else:
         return Response(avatar_options)
         return Response(avatar_options)
 
 
 
 
-def get_avatar_options(user):
+def get_avatar_options(request, user):
     options = {
     options = {
         'avatars': user.avatars,
         'avatars': user.avatars,
         'generated': True,
         'generated': True,
@@ -64,7 +64,7 @@ def get_avatar_options(user):
             })
             })
 
 
     # Can't have custom avatar?
     # Can't have custom avatar?
-    if not settings.allow_custom_avatars:
+    if not request.settings.allow_custom_avatars:
         return options
         return options
 
 
     # Allow Gravatar download
     # Allow Gravatar download
@@ -90,7 +90,7 @@ def get_avatar_options(user):
 
 
     # Allow upload conditions
     # Allow upload conditions
     options['upload'] = {
     options['upload'] = {
-        'limit': settings.avatar_upload_limit * 1024,
+        'limit': request.settings.avatar_upload_limit * 1024,
         'allowed_extensions': avatars.uploaded.ALLOWED_EXTENSIONS,
         'allowed_extensions': avatars.uploaded.ALLOWED_EXTENSIONS,
         'allowed_mime_types': avatars.uploaded.ALLOWED_MIME_TYPES,
         'allowed_mime_types': avatars.uploaded.ALLOWED_MIME_TYPES,
     }
     }
@@ -102,9 +102,14 @@ class AvatarError(Exception):
     pass
     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:
     try:
-        type_options = options[data.get('avatar', 'nope')]
+        type_options = options[avatar_type]
         if not type_options:
         if not type_options:
             return Response(
             return Response(
                 {
                 {
@@ -113,7 +118,7 @@ def avatar_post(options, user, data):
                 status=status.HTTP_400_BAD_REQUEST,
                 status=status.HTTP_400_BAD_REQUEST,
             )
             )
 
 
-        rpc_handler = AVATAR_TYPES[data.get('avatar', 'nope')]
+        avatar_strategy = AVATAR_TYPES[avatar_type]
     except KeyError:
     except KeyError:
         return Response(
         return Response(
             {
             {
@@ -123,7 +128,11 @@ def avatar_post(options, user, data):
         )
         )
 
 
     try:
     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:
     except AvatarError as e:
         return Response(
         return Response(
             {
             {
@@ -134,7 +143,8 @@ def avatar_post(options, user, data):
 
 
     user.save()
     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)
     return Response(response_dict)
 
 
 
 
@@ -165,13 +175,13 @@ def avatar_gallery(user, data):
         raise AvatarError(_("Incorrect image."))
         raise AvatarError(_("Incorrect image."))
 
 
 
 
-def avatar_upload(user, data):
+def avatar_upload(request, user, data):
     new_avatar = data.get('image')
     new_avatar = data.get('image')
     if not new_avatar:
     if not new_avatar:
         raise AvatarError(_("No file was sent."))
         raise AvatarError(_("No file was sent."))
 
 
     try:
     try:
-        avatars.uploaded.handle_uploaded_file(user, new_avatar)
+        avatars.uploaded.handle_uploaded_file(request, user, new_avatar)
     except ValidationError as e:
     except ValidationError as e:
         raise AvatarError(e.args[0])
         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():
     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 = _("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
         # swap address with new one so email is sent to new address
         request.user.email = serializer.validated_data['new_email']
         request.user.email = serializer.validated_data['new_email']
 
 
         mail_user(
         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.")
         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 django.utils.translation import gettext as _
 
 
-from misago.conf import settings
 from misago.core.mail import mail_user
 from misago.core.mail import mail_user
 from misago.users.credentialchange import store_new_credential
 from misago.users.credentialchange import store_new_credential
 from misago.users.serializers import ChangePasswordSerializer
 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 = _("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(
         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({
         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 (
 from misago.users.registration import (
     get_registration_result_json, save_user_agreements, send_welcome_email
     get_registration_result_json, save_user_agreements, send_welcome_email
 )
 )
-
+from misago.users.setupnewuser import setup_new_user
 
 
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
 
 
 @csrf_protect
 @csrf_protect
 def create_endpoint(request):
 def create_endpoint(request):
-    if settings.account_activation == 'closed':
+    if request.settings.account_activation == 'closed':
         raise PermissionDenied(_("New users registrations are currently closed."))
         raise PermissionDenied(_("New users registrations are currently closed."))
 
 
     form = RegisterForm(
     form = RegisterForm(
@@ -40,9 +40,9 @@ def create_endpoint(request):
         return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
         return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
 
 
     activation_kwargs = {}
     activation_kwargs = {}
-    if settings.account_activation == 'user':
+    if request.settings.account_activation == 'user':
         activation_kwargs = {'requires_activation': UserModel.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}
         activation_kwargs = {'requires_activation': UserModel.ACTIVATION_ADMIN}
 
 
     try:
     try:
@@ -50,9 +50,7 @@ def create_endpoint(request):
             form.cleaned_data['username'],
             form.cleaned_data['username'],
             form.cleaned_data['email'],
             form.cleaned_data['email'],
             form.cleaned_data['password'],
             form.cleaned_data['password'],
-            create_audit_trail=True,
             joined_from_ip=request.user_ip,
             joined_from_ip=request.user_ip,
-            set_default_avatar=True,
             **activation_kwargs
             **activation_kwargs
         )
         )
     except IntegrityError:
     except IntegrityError:
@@ -63,6 +61,7 @@ def create_endpoint(request):
             status=status.HTTP_400_BAD_REQUEST,
             status=status.HTTP_400_BAD_REQUEST,
         )
         )
 
 
+    setup_new_user(request.settings, new_user)
     save_user_agreements(new_user, form)
     save_user_agreements(new_user, form)
     send_welcome_email(request, new_user)
     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.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from misago.conf import settings
 from misago.core.utils import format_plaintext_for_html
 from misago.core.utils import format_plaintext_for_html
 from misago.users.serializers import EditSignatureSerializer
 from misago.users.serializers import EditSignatureSerializer
 from misago.users.signatures import is_user_signature_valid, set_user_signature
 from misago.users.signatures import is_user_signature_valid, set_user_signature
@@ -22,19 +21,21 @@ def signature_endpoint(request):
         else:
         else:
             reason = None
             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':
     if request.method == 'POST':
         return edit_signature(request, user)
         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 = {
     options = {
         'signature': None,
         'signature': None,
         'limit': settings.signature_length_max,
         'limit': settings.signature_length_max,
@@ -53,14 +54,16 @@ def get_signature_options(user):
 
 
 
 
 def edit_signature(request, 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():
     if serializer.is_valid():
         signature = serializer.validated_data['signature']
         signature = serializer.validated_data['signature']
         set_user_signature(request, user, request.user_acl, signature)
         set_user_signature(request, user, request.user_acl, signature)
         user.save(update_fields=['signature', 'signature_parsed', 'signature_checksum'])
         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.db import IntegrityError
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from misago.conf import settings
 from misago.users.namechanges import get_username_options
 from misago.users.namechanges import get_username_options
 from misago.users.serializers import ChangeUsernameSerializer
 from misago.users.serializers import ChangeUsernameSerializer
 
 
@@ -19,7 +18,7 @@ def username_endpoint(request):
 
 
 def get_username_options_from_request(request):
 def get_username_options_from_request(request):
     return get_username_options(
     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(
     serializer = ChangeUsernameSerializer(
-        data=request.data, context={'user': request.user}
+        data=request.data,
+        context={'settings': request.settings, 'user': request.user},
     )
     )
-
     if serializer.is_valid():
     if serializer.is_valid():
         try:
         try:
             serializer.change_username(changed_by=request.user)
             serializer.change_username(changed_by=request.user)
@@ -74,7 +73,10 @@ def change_username(request):
 
 
 def moderate_username_endpoint(request, profile):
 def moderate_username_endpoint(request, profile):
     if request.method == 'POST':
     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():
         if serializer.is_valid():
             try:
             try:
@@ -99,6 +101,6 @@ def moderate_username_endpoint(request, profile):
             )
             )
     else:
     else:
         return Response({
         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
 from . import store
 
 
-
 ALLOWED_EXTENSIONS = ('.gif', '.png', '.jpg', '.jpeg')
 ALLOWED_EXTENSIONS = ('.gif', '.png', '.jpg', '.jpeg')
 ALLOWED_MIME_TYPES = ('image/gif', 'image/jpeg', 'image/png', 'image/mpo')
 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
     upload_limit = settings.avatar_upload_limit * 1024
     if uploaded_file.size > upload_limit:
     if uploaded_file.size > upload_limit:
         raise ValidationError(_("Uploaded file is too big."))
         raise ValidationError(_("Uploaded file is too big."))
@@ -53,27 +73,6 @@ def validate_dimensions(uploaded_file):
     return image
     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):
 def clean_crop(image, crop):
     message = _("Crop data is invalid. Please try again.")
     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.core.exceptions import ValidationError
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from misago.conf import settings
-
 
 
 def recaptcha_test(request):
 def recaptcha_test(request):
     r = requests.post(
     r = requests.post(
         'https://www.google.com/recaptcha/api/siteverify',
         'https://www.google.com/recaptcha/api/siteverify',
         data={
         data={
-            'secret': settings.recaptcha_secret_key,
+            'secret': request.settings.recaptcha_secret_key,
             'response': request.data.get('captcha'),
             'response': request.data.get('captcha'),
             'remoteip': request.user_ip
             'remoteip': request.user_ip
         }
         }
@@ -25,15 +23,17 @@ def recaptcha_test(request):
 
 
 
 
 def qacaptcha_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."))
         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):
 def nocaptcha_test(request):
     return  # no captcha means no validation
     return  # no captcha means no validation
 
 
@@ -46,4 +46,4 @@ CAPTCHA_TESTS = {
 
 
 
 
 def test_request(request):
 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.conf import settings
 from misago.core import threadstore
 from misago.core import threadstore
 from misago.core.validators import validate_sluggable
 from misago.core.validators import validate_sluggable
+
 from misago.users.models import Ban, DataDownload, Rank
 from misago.users.models import Ban, DataDownload, Rank
 from misago.users.profilefields import profilefields
 from misago.users.profilefields import profilefields
 from misago.users.utils import hash_email
 from misago.users.utils import hash_email
 from misago.users.validators import validate_email, validate_username
 from misago.users.validators import validate_email, validate_username
 
 
-
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
 
 
@@ -28,9 +28,15 @@ class UserBaseForm(forms.ModelForm):
         model = UserModel
         model = UserModel
         fields = ['username', 'email', 'title']
         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):
     def clean_username(self):
         data = self.cleaned_data['username']
         data = self.cleaned_data['username']
-        validate_username(data, exclude=self.instance)
+        validate_username(self.settings, data, exclude=self.instance)
         return data
         return data
 
 
     def clean_email(self):
     def clean_email(self):
@@ -165,10 +171,10 @@ class EditUserForm(UserBaseForm):
     )
     )
 
 
     subscribe_to_started_threads = forms.TypedChoiceField(
     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(
     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:
     class Meta:
@@ -191,10 +197,7 @@ class EditUserForm(UserBaseForm):
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
-        self.request = kwargs.pop('request')
-
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
-
         profilefields.add_fields_to_admin_form(self.request, self.instance, self)
         profilefields.add_fields_to_admin_form(self.request, self.instance, self)
 
 
     def get_profile_fields_groups(self):
     def get_profile_fields_groups(self):
@@ -214,7 +217,7 @@ class EditUserForm(UserBaseForm):
     def clean_signature(self):
     def clean_signature(self):
         data = self.cleaned_data['signature']
         data = self.cleaned_data['signature']
 
 
-        length_limit = settings.signature_length_max
+        length_limit = self.settings.signature_length_max
         if len(data) > length_limit:
         if len(data) > length_limit:
             raise forms.ValidationError(
             raise forms.ValidationError(
                 ngettext(
                 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.core.exceptions import ValidationError
 from django.utils.translation import gettext as _
 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.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()
 UserModel = get_user_model()
 
 
 
 
 class BaseRegisterForm(forms.Form):
 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)
     terms_of_service = forms.IntegerField(required=False)
     privacy_policy = forms.IntegerField(required=False)
     privacy_policy = forms.IntegerField(required=False)
@@ -26,6 +28,7 @@ class BaseRegisterForm(forms.Form):
     def clean_username(self):
     def clean_username(self):
         data = self.cleaned_data['username']
         data = self.cleaned_data['username']
 
 
+        validate_username(self.request.settings, data)
         ban = get_username_ban(data, registration_only=True)
         ban = get_username_ban(data, registration_only=True)
         if ban:
         if ban:
             if ban.user_message:
             if ban.user_message:
@@ -67,7 +70,7 @@ class SocialAuthRegisterForm(BaseRegisterForm):
         self.clean_agreements(cleaned_data)
         self.clean_agreements(cleaned_data)
         self.raise_if_ip_banned()
         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
         return cleaned_data
 
 
@@ -99,6 +102,6 @@ class RegisterForm(BaseRegisterForm):
         except forms.ValidationError as e:
         except forms.ValidationError as e:
             self.add_error('password', 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
         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.db import DEFAULT_DB_ALIAS, IntegrityError
 from django.utils.encoding import force_str
 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):
 class NotRunningInTTYException(Exception):
@@ -78,11 +81,14 @@ class Command(BaseCommand):
         interactive = options.get('interactive')
         interactive = options.get('interactive')
         verbosity = int(options.get('verbosity', 1))
         verbosity = int(options.get('verbosity', 1))
 
 
+        cache_versions = get_cache_versions()
+        settings = DynamicSettings(cache_versions)
+
         # Validate initial inputs
         # Validate initial inputs
         if username is not None:
         if username is not None:
             try:
             try:
                 username = username.strip()
                 username = username.strip()
-                validate_username(username)
+                validate_username(settings, username)
             except ValidationError as e:
             except ValidationError as e:
                 self.stderr.write('\n'.join(e.messages))
                 self.stderr.write('\n'.join(e.messages))
                 username = None
                 username = None
@@ -103,7 +109,7 @@ class Command(BaseCommand):
         if not interactive:
         if not interactive:
             if username and email and password:
             if username and email and password:
                 # Call User manager's create_superuser using our wrapper
                 # 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:
         else:
             try:
             try:
                 if hasattr(self.stdin, 'isatty') and not self.stdin.isatty():
                 if hasattr(self.stdin, 'isatty') and not self.stdin.isatty():
@@ -142,7 +148,7 @@ class Command(BaseCommand):
                         continue
                         continue
                     try:
                     try:
                         validate_password(
                         validate_password(
-                            raw_value, user=UserModel(username=username, email=email)
+                            raw_value, user=User(username=username, email=email)
                         )
                         )
                     except ValidationError as e:
                     except ValidationError as e:
                         self.stderr.write('\n'.join(e.messages))
                         self.stderr.write('\n'.join(e.messages))
@@ -152,7 +158,7 @@ class Command(BaseCommand):
                     password = raw_value
                     password = raw_value
 
 
                 # Call User manager's create_superuser using our wrapper
                 # 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:
             except KeyboardInterrupt:
                 self.stderr.write("\nOperation cancelled.")
                 self.stderr.write("\nOperation cancelled.")
@@ -164,11 +170,10 @@ class Command(BaseCommand):
                     "to create one manually."
                     "to create one manually."
                 )
                 )
 
 
-    def create_superuser(self, username, email, password, verbosity):
+    def create_superuser(self, username, email, password, settings, verbosity):
         try:
         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:
             if verbosity >= 1:
                 message = "Superuser #%s has been created successfully."
                 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.core.management.base import BaseCommand
 from django.utils.translation import gettext
 from django.utils.translation import gettext
 
 
+from misago.cache.versions import get_cache_versions
 from misago.conf import settings
 from misago.conf import settings
+from misago.conf.dynamicsettings import DynamicSettings
 from misago.core.mail import mail_user
 from misago.core.mail import mail_user
 from misago.core.pgutils import chunk_queryset
 from misago.core.pgutils import chunk_queryset
 from misago.users.datadownloads import prepare_user_data_download
 from misago.users.datadownloads import prepare_user_data_download
@@ -25,6 +27,9 @@ class Command(BaseCommand):
                 "this feature to work.")
                 "this feature to work.")
             return
             return
         
         
+        cache_versions = get_cache_versions()
+        dynamic_settings = DynamicSettings(cache_versions)
+
         downloads_prepared = 0
         downloads_prepared = 0
         queryset = DataDownload.objects.select_related('user')
         queryset = DataDownload.objects.select_related('user')
         queryset = queryset.filter(status=DataDownload.STATUS_PENDING)
         queryset = queryset.filter(status=DataDownload.STATUS_PENDING)
@@ -35,6 +40,7 @@ class Command(BaseCommand):
                 mail_user(user, subject, 'misago/emails/data_download', context={
                 mail_user(user, subject, 'misago/emails/data_download', context={
                     'data_download': data_download,
                     'data_download': data_download,
                     'expires_in': settings.MISAGO_USER_DATA_DOWNLOADS_EXPIRE_IN_HOURS,
                     'expires_in': settings.MISAGO_USER_DATA_DOWNLOADS_EXPIRE_IN_HOURS,
+                    "settings": dynamic_settings,
                 })
                 })
 
 
                 downloads_prepared += 1
                 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
 # Generated by Django 1.10.5 on 2017-02-05 14:34
 from django.db import migrations
 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
 _ = lambda s: s
 
 
@@ -165,8 +164,6 @@ def update_users_settings(apps, schema_editor):
         }
         }
     )
     )
 
 
-    delete_settings_cache()
-
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 

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

@@ -1,5 +1,6 @@
 from .rank import Rank
 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 .activityranking import ActivityRanking
 from .avatar import Avatar
 from .avatar import Avatar
 from .audittrail import AuditTrail
 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()
             self.set_order()
         else:
         else:
             clear_acl_cache()
             clear_acl_cache()
+        if not self.slug:
+            self.slug = slugify(self.name)
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)
 
 
     def delete(self, *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.signatures import is_user_signature_valid
 from misago.users.utils import hash_email
 from misago.users.utils import hash_email
 
 
+from .online import Online
 from .rank import Rank
 from .rank import Rank
 
 
 
 
 class UserManager(BaseUserManager):
 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:
         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_username(username)
         user.set_email(email)
         user.set_email(email)
         user.set_password(password)
         user.set_password(password)
 
 
-        validate_username(username)
-        validate_email(email)
-
         if not 'rank' in extra_fields:
         if not 'rank' in extra_fields:
             user.rank = Rank.objects.get_default()
             user.rank = Rank.objects.get_default()
 
 
+        now = timezone.now()
+        user.last_login = now
+        user.joined_on = now
+
         user.save(using=self._db)
         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')
         authenticated_role = Role.objects.get(special_role='authenticated')
         if authenticated_role not in user.roles.all():
         if authenticated_role not in user.roles.all():
             user.roles.add(authenticated_role)
             user.roles.add(authenticated_role)
         user.update_acl_key()
         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:
         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:
         except Rank.DoesNotExist:
             pass
             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):
     def get_by_username(self, username):
         return self.get(slug=slugify(username))
         return self.get(slug=slugify(username))
@@ -134,14 +99,14 @@ class User(AbstractBaseUser, PermissionsMixin):
     ACTIVATION_USER = 1
     ACTIVATION_USER = 1
     ACTIVATION_ADMIN = 2
     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
     LIMIT_INVITES_TO_NONE = 0
@@ -251,12 +216,12 @@ class User(AbstractBaseUser, PermissionsMixin):
     sync_unread_private_threads = models.BooleanField(default=False)
     sync_unread_private_threads = models.BooleanField(default=False)
 
 
     subscribe_to_started_threads = models.PositiveIntegerField(
     subscribe_to_started_threads = models.PositiveIntegerField(
-        default=SUBSCRIBE_NONE,
-        choices=SUBSCRIBE_CHOICES,
+        default=SUBSCRIPTION_NONE,
+        choices=SUBSCRIPTION_CHOICES,
     )
     )
     subscribe_to_replied_threads = models.PositiveIntegerField(
     subscribe_to_replied_threads = models.PositiveIntegerField(
-        default=SUBSCRIBE_NONE,
-        choices=SUBSCRIBE_CHOICES,
+        default=SUBSCRIPTION_NONE,
+        choices=SUBSCRIPTION_CHOICES,
     )
     )
 
 
     threads = models.PositiveIntegerField(default=0)
     threads = models.PositiveIntegerField(default=0)
@@ -465,22 +430,6 @@ class User(AbstractBaseUser, PermissionsMixin):
             return False
             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):
 class UsernameChange(models.Model):
     user = models.ForeignKey(
     user = models.ForeignKey(
         settings.AUTH_USER_MODEL,
         settings.AUTH_USER_MODEL,

+ 9 - 2
misago/users/registration.py

@@ -1,6 +1,5 @@
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from misago.conf import settings
 from misago.core.mail import mail_user
 from misago.core.mail import mail_user
 from misago.legal.models import Agreement
 from misago.legal.models import Agreement
 from misago.legal.utils import save_user_agreement_acceptance
 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):
 def send_welcome_email(request, user):
+    settings = request.settings
+
     mail_subject = _("Welcome on %(forum_name)s forums!")
     mail_subject = _("Welcome on %(forum_name)s forums!")
     mail_subject = mail_subject % {'forum_name': settings.forum_name}
     mail_subject = mail_subject % {'forum_name': settings.forum_name}
 
 
     if not user.requires_activation:
     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
         return
 
 
     activation_token = make_activation_token(user)
     activation_token = make_activation_token(user)
@@ -28,6 +34,7 @@ def send_welcome_email(request, user):
             'activation_token': activation_token,
             'activation_token': activation_token,
             'activation_by_admin': activation_by_admin,
             'activation_by_admin': activation_by_admin,
             'activation_by_user': activation_by_user,
             '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.contrib.auth.password_validation import validate_password
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from misago.conf import settings
 from misago.users.online.tracker import clear_tracking
 from misago.users.online.tracker import clear_tracking
 from misago.users.permissions import allow_delete_own_account
 from misago.users.permissions import allow_delete_own_account
 from misago.users.validators import validate_email, validate_username
 from misago.users.validators import validate_email, validate_username
@@ -48,6 +47,7 @@ class EditSignatureSerializer(serializers.ModelSerializer):
         fields = ['signature']
         fields = ['signature']
 
 
     def validate(self, data):
     def validate(self, data):
+        settings = self.context["settings"]
         if len(data.get('signature', '')) > settings.signature_length_max:
         if len(data.get('signature', '')) > settings.signature_length_max:
             raise serializers.ValidationError(_("Signature is too long."))
             raise serializers.ValidationError(_("Signature is too long."))
 
 
@@ -59,20 +59,22 @@ class ChangeUsernameSerializer(serializers.Serializer):
 
 
     def validate(self, data):
     def validate(self, data):
         username = data.get('username')
         username = data.get('username')
-
         if not username:
         if not username:
             raise serializers.ValidationError(_("Enter new 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."))
             raise serializers.ValidationError(_("New username is same as current one."))
 
 
-        validate_username(username)
+        settings = self.context['settings']
+        validate_username(settings, username)
 
 
         return data
         return data
 
 
     def change_username(self, changed_by):
     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):
 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 django.utils.translation import gettext as _
 from social_core.pipeline.partial import partial
 from social_core.pipeline.partial import partial
 
 
-from misago.conf import settings
 from misago.core.exceptions import SocialAuthFailed, SocialAuthBanned
 from misago.core.exceptions import SocialAuthFailed, SocialAuthBanned
 from misago.legal.models import Agreement
 from misago.legal.models import Agreement
 
 
@@ -18,8 +17,10 @@ from misago.users.models import Ban
 from misago.users.registration import (
 from misago.users.registration import (
     get_registration_result_json, save_user_agreements, send_welcome_email
     get_registration_result_json, save_user_agreements, send_welcome_email
 )
 )
+from misago.users.setupnewuser import setup_new_user
 from misago.users.validators import (
 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
 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:
     if user:
         return None
         return None
 
 
+    settings = strategy.request.settings
+
     username = perpare_username(details.get('username', ''))
     username = perpare_username(details.get('username', ''))
     full_name = perpare_username(details.get('full_name', ''))
     full_name = perpare_username(details.get('full_name', ''))
     first_name = perpare_username(details.get('first_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):
     for name in filter(bool, names_to_try):
         try:
         try:
-            validate_username(name)
+            validate_username(settings, name)
             return {'clean_username': name}
             return {'clean_username': name}
         except ValidationError:
         except ValidationError:
             pass
             pass
@@ -137,6 +140,8 @@ def create_user(strategy, details, backend, user=None, *args, **kwargs):
         return None
         return None
     
     
     request = strategy.request
     request = strategy.request
+    settings = request.settings
+
     email = details.get('email')
     email = details.get('email')
     username = kwargs.get('clean_username')
     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}
         activation_kwargs = {'requires_activation': UserModel.ACTIVATION_ADMIN}
 
 
     new_user = UserModel.objects.create_user(
     new_user = UserModel.objects.create_user(
-        username, 
-        email, 
-        create_audit_trail=True,
+        username,
+        email,
         joined_from_ip=request.user_ip, 
         joined_from_ip=request.user_ip, 
-        set_default_avatar=True,
         **activation_kwargs
         **activation_kwargs
     )
     )
 
 
+    setup_new_user(settings, new_user)
     send_welcome_email(request, new_user)
     send_welcome_email(request, new_user)
 
 
     return {'user': new_user, 'is_new': True}
     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
         return None
 
 
     request = strategy.request
     request = strategy.request
+    settings = request.settings
     backend_name = get_social_auth_backend_name(backend.name)
     backend_name = get_social_auth_backend_name(backend.name)
 
 
     if request.method == 'POST':
     if request.method == 'POST':
@@ -187,7 +192,7 @@ def create_user_with_form(strategy, details, backend, user=None, *args, **kwargs
             
             
         form = SocialAuthRegisterForm(
         form = SocialAuthRegisterForm(
             request_data,
             request_data,
-            request=request,    
+            request=request,
             agreements=Agreement.objects.get_agreements(),
             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(
             new_user = UserModel.objects.create_user(
                 form.cleaned_data['username'],
                 form.cleaned_data['username'],
                 form.cleaned_data['email'],
                 form.cleaned_data['email'],
-                create_audit_trail=True,
                 joined_from_ip=request.user_ip,
                 joined_from_ip=request.user_ip,
-                set_default_avatar=True,
                 **activation_kwargs
                 **activation_kwargs
             )
             )
+            setup_new_user(settings, new_user)
         except IntegrityError:
         except IntegrityError:
             return JsonResponse({'__all__': _("Please try resubmitting the form.")}, status=400)
             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.core.utils import encode_json_html
 from misago.users.models import Ban
 from misago.users.models import Ban
+from misago.users.testutils import create_test_user
 from misago.users.tokens import make_activation_token
 from misago.users.tokens import make_activation_token
 
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 
 
 class ActivationViewsTests(TestCase):
 class ActivationViewsTests(TestCase):
@@ -18,8 +18,8 @@ class ActivationViewsTests(TestCase):
 
 
     def test_view_activate_banned(self):
     def test_view_activate_banned(self):
         """activate banned user shows error"""
         """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(
         Ban.objects.create(
             check_type=Ban.USERNAME,
             check_type=Ban.USERNAME,
@@ -40,13 +40,13 @@ class ActivationViewsTests(TestCase):
         )
         )
         self.assertContains(response, encode_json_html("<p>Nope!</p>"), status_code=403)
         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)
         self.assertEqual(test_user.requires_activation, 1)
 
 
     def test_view_activate_invalid_token(self):
     def test_view_activate_invalid_token(self):
         """activate with invalid token shows error"""
         """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)
         activation_token = make_activation_token(test_user)
@@ -62,13 +62,13 @@ class ActivationViewsTests(TestCase):
         )
         )
         self.assertEqual(response.status_code, 400)
         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)
         self.assertEqual(test_user.requires_activation, 1)
 
 
     def test_view_activate_disabled(self):
     def test_view_activate_disabled(self):
         """activate disabled user shows error"""
         """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)
         activation_token = make_activation_token(test_user)
@@ -86,7 +86,7 @@ class ActivationViewsTests(TestCase):
 
 
     def test_view_activate_active(self):
     def test_view_activate_active(self):
         """activate active user shows error"""
         """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)
         activation_token = make_activation_token(test_user)
 
 
@@ -101,13 +101,13 @@ class ActivationViewsTests(TestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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)
         self.assertEqual(test_user.requires_activation, 0)
 
 
     def test_view_activate_inactive(self):
     def test_view_activate_inactive(self):
         """activate inactive user passess"""
         """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)
         activation_token = make_activation_token(test_user)
@@ -124,5 +124,5 @@ class ActivationViewsTests(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "your account has been activated!")
         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)
         self.assertEqual(test_user.requires_activation, 0)

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

@@ -1,4 +1,5 @@
 from pathlib import Path
 from pathlib import Path
+from unittest.mock import Mock
 
 
 from PIL import Image
 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.avatars import dynamic, gallery, gravatar, set_default_avatar, store, uploaded
 from misago.users.models import Avatar, AvatarGallery
 from misago.users.models import Avatar, AvatarGallery
 
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 
 
 class AvatarsStoreTests(TestCase):
 class AvatarsStoreTests(TestCase):
     def test_store(self):
     def test_store(self):
         """store successfully stores and deletes avatar"""
         """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)
         test_image = Image.new("RGBA", (100, 100), 0)
         store.store_new_avatar(user, test_image)
         store.store_new_avatar(user, test_image)
 
 
         # reload user
         # reload user
-        UserModel.objects.get(pk=user.pk)
+        User.objects.get(pk=user.pk)
 
 
         # assert that avatars were stored in media
         # assert that avatars were stored in media
         avatars_dict = {}
         avatars_dict = {}
@@ -85,7 +85,7 @@ class AvatarsStoreTests(TestCase):
 
 
 class AvatarSetterTests(TestCase):
 class AvatarSetterTests(TestCase):
     def setUp(self):
     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.avatars = None
         self.user.save()
         self.user.save()
@@ -94,7 +94,7 @@ class AvatarSetterTests(TestCase):
         store.delete_avatar(self.user)
         store.delete_avatar(self.user)
 
 
     def get_current_user(self):
     def get_current_user(self):
-        return UserModel.objects.get(pk=self.user.pk)
+        return User.objects.get(pk=self.user.pk)
 
 
     def assertNoAvatarIsSet(self):
     def assertNoAvatarIsSet(self):
         user = self.get_current_user()
         user = self.get_current_user()
@@ -215,12 +215,14 @@ class UploadedAvatarTests(TestCase):
 
 
     def test_uploaded_image_size_validation(self):
     def test_uploaded_image_size_validation(self):
         """uploaded image size is validated"""
         """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):
         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):
     def test_uploaded_image_extension_validation(self):
         """uploaded image extension is validated"""
         """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.test import TestCase
 from django.utils import timezone
 from django.utils import timezone
 
 
+from misago.conftest import get_cache_versions
 from misago.users.bans import (
 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)
     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
 from misago.users.constants import BANS_CACHE
@@ -11,7 +12,7 @@ from misago.users.models import Ban
 
 
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
-cache_versions = {"bans": "abcdefgh"}
+cache_versions = get_cache_versions()
 
 
 
 
 class GetBanTests(TestCase):
 class GetBanTests(TestCase):
@@ -191,9 +192,7 @@ class MockRequest(object):
     def __init__(self):
     def __init__(self):
         self.user_ip = '127.0.0.1'
         self.user_ip = '127.0.0.1'
         self.session = {}
         self.session = {}
-        self.cache_versions = {
-            BANS_CACHE: "abcdefgh"
-        }
+        self.cache_versions = cache_versions
 
 
 
 
 class RequestIPBansTests(TestCase):
 class RequestIPBansTests(TestCase):

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

@@ -1,31 +1,30 @@
 from django.test import TestCase
 from django.test import TestCase
 from django.urls import reverse
 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):
 class AuthenticateApiTests(TestCase):
     def setUp(self):
     def setUp(self):
         self.api_link = reverse('misago:api:captcha-question')
         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):
     def test_api_no_qa_is_set(self):
         """qa api returns 404 if no QA question is set"""
         """qa api returns 404 if no QA question is set"""
-        settings.override_setting('qa_question', '')
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
         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):
     def test_api_get_question(self):
         """qa api returns valid QA question"""
         """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)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         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")
         self.assertNotContains(response, "Join IP")
 
 
     def test_admin_edits_field(self):
     def test_admin_edits_field(self):
-        """admin form allows admins to edit field"""
+        """join_ip is non-editable by admin"""
         response = self.client.post(
         response = self.client.post(
             self.test_link,
             self.test_link,
             data={
             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 django.urls import reverse
 
 
 from misago.acl.test import patch_user_acl
 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.threads.testutils import post_thread
 from misago.users.activepostersranking import build_active_posters_ranking
 from misago.users.activepostersranking import build_active_posters_ranking
 from misago.users.models import Rank
 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):
 class UsersListTestCase(AuthenticatedUserTestCase):
@@ -48,10 +44,9 @@ class ActivePostersTests(UsersListTestCase):
 
 
         # Create 50 test users and see if errors appeared
         # Create 50 test users and see if errors appeared
         for i in range(50):
         for i in range(50):
-            user = UserModel.objects.create_user(
+            user = create_test_user(
                 'Bob%s' % i,
                 'Bob%s' % i,
                 'm%s@te.com' % i,
                 'm%s@te.com' % i,
-                'Pass.123',
                 posts=12345,
                 posts=12345,
             )
             )
             post_thread(category, poster=user)
             post_thread(category, poster=user)
@@ -65,7 +60,7 @@ class ActivePostersTests(UsersListTestCase):
 class UsersRankTests(UsersListTestCase):
 class UsersRankTests(UsersListTestCase):
     def test_ranks(self):
     def test_ranks(self):
         """ranks lists are handled correctly"""
         """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():
         for rank in Rank.objects.iterator():
             rank_user.rank = rank
             rank_user.rank = rank
@@ -87,7 +82,7 @@ class UsersRankTests(UsersListTestCase):
 
 
     def test_disabled_users(self):
     def test_disabled_users(self):
         """ranks lists excludes disabled accounts"""
         """ranks lists excludes disabled accounts"""
-        rank_user = UserModel.objects.create_user(
+        rank_user = create_test_user(
             'Visible',
             'Visible',
             'visible@te.com',
             'visible@te.com',
             'Pass.123',
             'Pass.123',
@@ -117,7 +112,7 @@ class UsersRankTests(UsersListTestCase):
         self.user.is_staff = True
         self.user.is_staff = True
         self.user.save()
         self.user.save()
 
 
-        rank_user = UserModel.objects.create_user(
+        rank_user = create_test_user(
             'Visible',
             'Visible',
             'visible@te.com',
             'visible@te.com',
             'Pass.123',
             '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.test import TestCase
 from django.urls import reverse
 from django.urls import reverse
 
 
 from misago.conf import settings
 from misago.conf import settings
-
-
-UserModel = get_user_model()
+from misago.users.testutils import create_test_user
 
 
 
 
 class AuthenticateApiTests(TestCase):
 class AuthenticateApiTests(TestCase):
@@ -28,14 +25,14 @@ class AuthenticateApiTests(TestCase):
 
 
     def test_user_search(self):
     def test_user_search(self):
         """api searches uses"""
         """api searches uses"""
-        UserModel.objects.create_user('BobBoberson', 'bob@test.com', 'pass123')
+        create_test_user('BobBoberson', 'bob@test.com')
 
 
         # exact case sensitive match
         # exact case sensitive match
         response = self.client.get(self.api_link + '?q=BobBoberson')
         response = self.client.get(self.api_link + '?q=BobBoberson')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), [
         self.assertEqual(response.json(), [
             {
             {
-                'avatar': 'http://placekitten.com/400/400',
+                'avatar': 'http://placekitten.com/100/100',
                 'username': 'BobBoberson',
                 'username': 'BobBoberson',
             }
             }
         ])
         ])
@@ -45,7 +42,7 @@ class AuthenticateApiTests(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), [
         self.assertEqual(response.json(), [
             {
             {
-                'avatar': 'http://placekitten.com/400/400',
+                'avatar': 'http://placekitten.com/100/100',
                 'username': 'BobBoberson',
                 'username': 'BobBoberson',
             }
             }
         ])
         ])
@@ -55,7 +52,7 @@ class AuthenticateApiTests(TestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), [
         self.assertEqual(response.json(), [
             {
             {
-                'avatar': 'http://placekitten.com/400/400',
+                'avatar': 'http://placekitten.com/100/100',
                 'username': 'BobBoberson',
                 '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.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.users.models import Ban
 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()
 UserModel = get_user_model()
@@ -34,7 +36,7 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
         self.user.is_staff = False
         self.user.is_staff = False
         self.user.save()
         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.is_active = False
         test_user.save()
         test_user.save()
@@ -50,7 +52,7 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
 
 
         # profile page displays notice about user being disabled
         # profile page displays notice about user being disabled
         response = self.client.get(response['location'])
         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):
     def test_user_posts_list(self):
         """user profile posts list has no showstoppers"""
         """user profile posts list has no showstoppers"""
@@ -184,7 +186,7 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
 
 
     def test_user_ban_details(self):
     def test_user_ban_details(self):
         """user ban details page has no showstoppers"""
         """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}
         link_kwargs = {'slug': test_user.slug, 'pk': test_user.pk}
 
 
         with patch_user_acl({'can_see_ban_details': 0}):
         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 django.test import TestCase
 
 
 from misago.acl.useracl import get_user_acl
 from misago.acl.useracl import get_user_acl
+from misago.conftest import get_cache_versions
 from misago.users import signatures
 from misago.users import signatures
 
 
 User = get_user_model()
 User = get_user_model()
-cache_versions = {"acl": "abcdefg"}
+cache_versions = get_cache_versions()
 
 
 
 
 class MockRequest(object):
 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.contrib.auth import get_user_model
 from django.core import mail
 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_core.backends.github import GithubOAuth2
 from social_django.utils import load_strategy
 from social_django.utils import load_strategy
 
 
 from misago.acl import ACL_CACHE
 from misago.acl import ACL_CACHE
 from misago.acl.useracl import get_user_acl
 from misago.acl.useracl import get_user_acl
+from misago.conf.dynamicsettings import DynamicSettings
 from misago.core.exceptions import SocialAuthFailed, SocialAuthBanned
 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.legal.models import Agreement
 
 
-from misago.users.constants import BANS_CACHE
 from misago.users.models import AnonymousUser, Ban, BanCache
 from misago.users.models import AnonymousUser, Ban, BanCache
 from misago.users.social.pipeline import (
 from misago.users.social.pipeline import (
     associate_by_email, create_user, create_user_with_form, get_username, require_activation,
     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:
     else:
         request = factory.post('/', data=json.dumps(data), content_type='application/json')
         request = factory.post('/', data=json.dumps(data), content_type='application/json')
     request.include_frontend_context = True
     request.include_frontend_context = True
-    request.cache_versions = {BANS_CACHE: "abcdefgh", ACL_CACHE: "abcdefgh"}
+    request.cache_versions = get_cache_versions()
     request.frontend_context = {}
     request.frontend_context = {}
     request.session = {}
     request.session = {}
+    request.settings = DynamicSettings(request.cache_versions)
     request.user = AnonymousUser()
     request.user = AnonymousUser()
     request.user_acl = get_user_acl(request.user, request.cache_versions)
     request.user_acl = get_user_acl(request.user, request.cache_versions)
     request.user_ip = user_ip
     request.user_ip = user_ip
     return request
     return request
 
 
 
 
+def create_strategy():
+    request = create_request()
+    return load_strategy(request=request)
+
+
 class MockStrategy(object):
 class MockStrategy(object):
     def __init__(self, user_ip='0.0.0.0'):
     def __init__(self, user_ip='0.0.0.0'):
         self.cleaned_partial_token = None
         self.cleaned_partial_token = None
@@ -53,7 +61,8 @@ class PipelineTestCase(UserTestCase):
         self.user = self.get_authenticated_user()
         self.user = self.get_authenticated_user()
 
 
     def assertNewUserIsCorrect(
     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.assertFalse(new_user.has_usable_password())
         self.assertIn('Welcome', mail.outbox[0].subject)
         self.assertIn('Welcome', mail.outbox[0].subject)
 
 
@@ -180,7 +189,7 @@ class CreateUser(PipelineTestCase):
         )
         )
         self.assertIsNone(result)
         self.assertIsNone(result)
 
 
-    @override_settings(account_activation='none')
+    @override_dynamic_settings(account_activation='none')
     def test_user_created_no_activation(self):
     def test_user_created_no_activation(self):
         """pipeline step creates active user for valid data and disabled activation"""
         """pipeline step creates active user for valid data and disabled activation"""
         result = create_user(
         result = create_user(
@@ -197,7 +206,7 @@ class CreateUser(PipelineTestCase):
         self.assertEqual(new_user.username, 'NewUser')
         self.assertEqual(new_user.username, 'NewUser')
         self.assertNewUserIsCorrect(new_user, email_verified=True, activation='none')
         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):
     def test_user_created_activation_by_user(self):
         """pipeline step creates active user for valid data and user activation"""
         """pipeline step creates active user for valid data and user activation"""
         result = create_user(
         result = create_user(
@@ -214,7 +223,7 @@ class CreateUser(PipelineTestCase):
         self.assertEqual(new_user.username, 'NewUser')
         self.assertEqual(new_user.username, 'NewUser')
         self.assertNewUserIsCorrect(new_user, email_verified=True, activation='user')
         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):
     def test_user_created_activation_by_admin(self):
         """pipeline step creates in user for valid data and admin activation"""
         """pipeline step creates in user for valid data and admin activation"""
         result = create_user(
         result = create_user(
@@ -314,7 +323,7 @@ class CreateUserWithFormTests(PipelineTestCase):
             'username': ["This username is not available."],
             '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):
     def test_user_created_no_activation_verified_email(self):
         """active user is created for verified email and activation disabled"""
         """active user is created for verified email and activation disabled"""
         form_data = {
         form_data = {
@@ -338,7 +347,7 @@ class CreateUserWithFormTests(PipelineTestCase):
 
 
         self.assertNewUserIsCorrect(new_user, form_data, activation='none', email_verified=True)
         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):
     def test_user_created_no_activation_nonverified_email(self):
         """active user is created for non-verified email and activation disabled"""
         """active user is created for non-verified email and activation disabled"""
         form_data = {
         form_data = {
@@ -362,7 +371,7 @@ class CreateUserWithFormTests(PipelineTestCase):
 
 
         self.assertNewUserIsCorrect(new_user, form_data, activation='none', email_verified=False)
         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):
     def test_user_created_activation_by_user_verified_email(self):
         """active user is created for verified email and activation by user"""
         """active user is created for verified email and activation by user"""
         form_data = {
         form_data = {
@@ -386,7 +395,7 @@ class CreateUserWithFormTests(PipelineTestCase):
 
 
         self.assertNewUserIsCorrect(new_user, form_data, activation='user', email_verified=True)
         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):
     def test_user_created_activation_by_user_nonverified_email(self):
         """inactive user is created for non-verified email and activation by user"""
         """inactive user is created for non-verified email and activation by user"""
         form_data = {
         form_data = {
@@ -410,7 +419,7 @@ class CreateUserWithFormTests(PipelineTestCase):
 
 
         self.assertNewUserIsCorrect(new_user, form_data, activation='user', email_verified=False)
         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):
     def test_user_created_activation_by_admin_verified_email(self):
         """inactive user is created for verified email and activation by admin"""
         """inactive user is created for verified email and activation by admin"""
         form_data = {
         form_data = {
@@ -434,7 +443,7 @@ class CreateUserWithFormTests(PipelineTestCase):
 
 
         self.assertNewUserIsCorrect(new_user, form_data, activation='admin', email_verified=True)
         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):
     def test_user_created_activation_by_admin_nonverified_email(self):
         """inactive user is created for non-verified email and activation by admin"""
         """inactive user is created for non-verified email and activation by admin"""
         form_data = {
         form_data = {
@@ -569,77 +578,87 @@ class CreateUserWithFormTests(PipelineTestCase):
 class GetUsernameTests(PipelineTestCase):
 class GetUsernameTests(PipelineTestCase):
     def test_skip_if_user_is_set(self):
     def test_skip_if_user_is_set(self):
         """pipeline step is skipped if user was passed"""
         """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)
         self.assertIsNone(result)
 
 
     def test_skip_if_no_names(self):
     def test_skip_if_no_names(self):
         """pipeline step is skipped if API returned no names"""
         """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)
         self.assertIsNone(result)
 
 
     def test_resolve_to_username(self):
     def test_resolve_to_username(self):
         """pipeline step resolves username"""
         """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'})
         self.assertEqual(result, {'clean_username': 'BobBoberson'})
 
 
     def test_normalize_username(self):
     def test_normalize_username(self):
         """pipeline step normalizes username"""
         """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'})
         self.assertEqual(result, {'clean_username': 'BlopBloperson'})
 
 
     def test_resolve_to_first_name(self):
     def test_resolve_to_first_name(self):
         """pipeline attempts to use first name because username is taken"""
         """pipeline attempts to use first name because username is taken"""
+        strategy = create_strategy()
         details = {
         details = {
             'username': self.user.username,
             'username': self.user.username,
             'first_name': 'Błob',
             'first_name': 'Błob',
         }
         }
-        result = get_username(None, details, None)
+        result = get_username(strategy, details, None)
         self.assertEqual(result, {'clean_username': 'Blob'})
         self.assertEqual(result, {'clean_username': 'Blob'})
 
 
     def test_dont_resolve_to_last_name(self):
     def test_dont_resolve_to_last_name(self):
         """pipeline will not fallback to last name because username is taken"""
         """pipeline will not fallback to last name because username is taken"""
+        strategy = create_strategy()
         details = {
         details = {
             'username': self.user.username,
             'username': self.user.username,
             'last_name': 'Błob',
             'last_name': 'Błob',
         }
         }
-        result = get_username(None, details, None)
+        result = get_username(strategy, details, None)
         self.assertIsNone(result)
         self.assertIsNone(result)
 
 
     def test_resolve_to_first_last_name_first_char(self):
     def test_resolve_to_first_last_name_first_char(self):
         """pipeline will construct username from first name and first char of surname"""
         """pipeline will construct username from first name and first char of surname"""
+        strategy = create_strategy()
         details = {
         details = {
             'first_name': self.user.username,
             'first_name': self.user.username,
             'last_name': 'Błob',
             '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'})
         self.assertEqual(result, {'clean_username': self.user.username + 'B'})
 
 
     def test_dont_resolve_to_banned_name(self):
     def test_dont_resolve_to_banned_name(self):
         """pipeline will not resolve to banned name"""
         """pipeline will not resolve to banned name"""
+        strategy = create_strategy()
         Ban.objects.create(banned_value='*Admin*', check_type=Ban.USERNAME)
         Ban.objects.create(banned_value='*Admin*', check_type=Ban.USERNAME)
         details = {
         details = {
             'username': 'Misago Admin',
             'username': 'Misago Admin',
             'first_name': 'Błob',
             'first_name': 'Błob',
         }
         }
-        result = get_username(None, details, None)
+        result = get_username(strategy, details, None)
         self.assertEqual(result, {'clean_username': 'Blob'})
         self.assertEqual(result, {'clean_username': 'Blob'})
 
 
     def test_resolve_full_name(self):
     def test_resolve_full_name(self):
         """pipeline will resolve to full name"""
         """pipeline will resolve to full name"""
+        strategy = create_strategy()
         Ban.objects.create(banned_value='*Admin*', check_type=Ban.USERNAME)
         Ban.objects.create(banned_value='*Admin*', check_type=Ban.USERNAME)
         details = {
         details = {
             'username': 'Misago Admin',
             'username': 'Misago Admin',
             'full_name': 'Błob Błopo',
             'full_name': 'Błob Błopo',
         }
         }
-        result = get_username(None, details, None)
+        result = get_username(strategy, details, None)
         self.assertEqual(result, {'clean_username': 'BlobBlopo'})
         self.assertEqual(result, {'clean_username': 'BlobBlopo'})
 
 
     def test_resolve_to_cut_name(self):
     def test_resolve_to_cut_name(self):
         """pipeline will resolve cut too long name on second pass"""
         """pipeline will resolve cut too long name on second pass"""
+        strategy = create_strategy()
         details = {
         details = {
             'username': 'Abrakadabrapokuskonstantynopolitańczykowianeczkatrzy',
             'username': 'Abrakadabrapokuskonstantynopolitańczykowianeczkatrzy',
         }
         }
-        result = get_username(None, details, None)
+        result = get_username(strategy, details, None)
         self.assertEqual(result, {'clean_username': 'Abrakadabrapok'})
         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.acl.test import patch_user_acl
 from misago.conf import settings
 from misago.conf import settings
+from misago.conf.test import override_dynamic_settings
 from misago.users.avatars import gallery, store
 from misago.users.avatars import gallery, store
 from misago.users.models import AvatarGallery
 from misago.users.models import AvatarGallery
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TEST_AVATAR_PATH = os.path.join(TESTFILES_DIR, 'avatar.png')
 TEST_AVATAR_PATH = os.path.join(TESTFILES_DIR, 'avatar.png')
 
 
-UserModel = get_user_model()
+User = get_user_model()
 
 
 
 
 class UserAvatarTests(AuthenticatedUserTestCase):
 class UserAvatarTests(AuthenticatedUserTestCase):
@@ -26,40 +26,40 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.client.post(self.link, data={'avatar': 'generated'})
         self.client.post(self.link, data={'avatar': 'generated'})
 
 
     def get_current_user(self):
     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):
     def assertOldAvatarsAreDeleted(self, user):
         self.assertEqual(
         self.assertEqual(
             user.avatar_set.count(), len(settings.MISAGO_AVATARS_SIZES)
             user.avatar_set.count(), len(settings.MISAGO_AVATARS_SIZES)
         )
         )
 
 
+    @override_dynamic_settings(allow_custom_avatars=False)
     def test_avatars_off(self):
     def test_avatars_off(self):
         """custom avatars are not allowed"""
         """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):
     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):
     def test_gallery_exists(self):
         """api returns gallery"""
         """api returns gallery"""
@@ -95,7 +95,7 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         })
         })
 
 
         self.login_user(
         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)
         response = self.client.get(self.link)
@@ -347,7 +347,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         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
         self.link = '/api/users/%s/moderate-avatar/' % self.other_user.pk
 
 
@@ -386,7 +386,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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()
         options = response.json()
         self.assertEqual(other_user.is_avatar_locked, True)
         self.assertEqual(other_user.is_avatar_locked, True)
@@ -411,7 +411,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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.assertFalse(other_user.is_avatar_locked)
         self.assertIsNone(other_user.avatar_lock_user_message)
         self.assertIsNone(other_user.avatar_lock_user_message)
         self.assertIsNone(other_user.avatar_lock_staff_message)
         self.assertIsNone(other_user.avatar_lock_staff_message)
@@ -435,7 +435,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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.assertTrue(other_user.is_avatar_locked)
         self.assertEqual(other_user.avatar_lock_user_message, '')
         self.assertEqual(other_user.avatar_lock_user_message, '')
         self.assertEqual(other_user.avatar_lock_staff_message, '')
         self.assertEqual(other_user.avatar_lock_staff_message, '')
@@ -457,7 +457,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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.assertFalse(other_user.is_avatar_locked)
         self.assertEqual(other_user.avatar_lock_user_message, '')
         self.assertEqual(other_user.avatar_lock_user_message, '')
         self.assertEqual(other_user.avatar_lock_staff_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.contrib.auth import get_user_model
 from django.core import mail
 from django.core import mail
-from django.test import override_settings
 from django.urls import reverse
 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.legal.models import Agreement
 from misago.users.models import Ban, Online
 from misago.users.models import Ban, Online
 from misago.users.testutils import UserTestCase
 from misago.users.testutils import UserTestCase
 
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 
 
 class UserCreateTests(UserTestCase):
 class UserCreateTests(UserTestCase):
@@ -58,10 +56,9 @@ class UserCreateTests(UserTestCase):
             "detail": "This action is not available to signed in users."
             "detail": "This action is not available to signed in users."
         })
         })
 
 
+    @override_dynamic_settings(account_activation="closed")
     def test_registration_off_request(self):
     def test_registration_off_request(self):
         """registrations off request errors with code 403"""
         """registrations off request errors with code 403"""
-        settings.override_setting('account_activation', 'closed')
-
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
@@ -290,7 +287,11 @@ class UserCreateTests(UserTestCase):
             "password": ["The password is too similar to the username."],
             "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):
     def test_registration_validates_captcha(self):
         """api validates captcha"""
         """api validates captcha"""
         response = self.client.post(
         response = self.client.post(
@@ -323,6 +324,30 @@ class UserCreateTests(UserTestCase):
 
 
         self.assertEqual(response.status_code, 200)
         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):
     def test_registration_check_agreement(self):
         """api checks agreement"""
         """api checks agreement"""
         agreement = Agreement.objects.create(
         agreement = Agreement.objects.create(
@@ -378,7 +403,7 @@ class UserCreateTests(UserTestCase):
 
 
         self.assertEqual(response.status_code, 200)
         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.agreements, [agreement.id])
         self.assertEqual(user.useragreement_set.count(), 1)
         self.assertEqual(user.useragreement_set.count(), 1)
 
 
@@ -402,7 +427,7 @@ class UserCreateTests(UserTestCase):
 
 
         self.assertEqual(response.status_code, 200)
         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.agreements, [])
         self.assertEqual(user.useragreement_set.count(), 0)
         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."],
             "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):
     def test_registration_creates_active_user(self):
         """api creates active and signed in user on POST"""
         """api creates active and signed in user on POST"""
-        settings.override_setting('account_activation', 'none')
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -441,9 +465,9 @@ class UserCreateTests(UserTestCase):
             'email': 'bob@bob.com',
             '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.assertEqual(Online.objects.filter(user=test_user).count(), 1)
 
 
         self.assertTrue(test_user.check_password('pass123'))
         self.assertTrue(test_user.check_password('pass123'))
@@ -456,10 +480,9 @@ class UserCreateTests(UserTestCase):
 
 
         self.assertEqual(test_user.audittrail_set.count(), 1)
         self.assertEqual(test_user.audittrail_set.count(), 1)
 
 
+    @override_dynamic_settings(account_activation="user")
     def test_registration_creates_inactive_user(self):
     def test_registration_creates_inactive_user(self):
         """api creates inactive user on POST"""
         """api creates inactive user on POST"""
-        settings.override_setting('account_activation', 'user')
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -478,15 +501,14 @@ class UserCreateTests(UserTestCase):
         auth_json = self.client.get(reverse('misago:api:auth')).json()
         auth_json = self.client.get(reverse('misago:api:auth')).json()
         self.assertFalse(auth_json['is_authenticated'])
         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)
         self.assertIn('Welcome', mail.outbox[0].subject)
 
 
+    @override_dynamic_settings(account_activation="admin")
     def test_registration_creates_admin_activated_user(self):
     def test_registration_creates_admin_activated_user(self):
         """api creates admin activated user on POST"""
         """api creates admin activated user on POST"""
-        settings.override_setting('account_activation', 'admin')
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -505,15 +527,14 @@ class UserCreateTests(UserTestCase):
         auth_json = self.client.get(reverse('misago:api:auth')).json()
         auth_json = self.client.get(reverse('misago:api:auth')).json()
         self.assertFalse(auth_json['is_authenticated'])
         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)
         self.assertIn('Welcome', mail.outbox[0].subject)
 
 
+    @override_dynamic_settings(account_activation="none")
     def test_registration_creates_user_with_whitespace_password(self):
     def test_registration_creates_user_with_whitespace_password(self):
         """api creates user with spaces around password"""
         """api creates user with spaces around password"""
-        settings.override_setting('account_activation', 'none')
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -529,9 +550,9 @@ class UserCreateTests(UserTestCase):
             'email': 'bob@bob.com',
             '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.assertEqual(Online.objects.filter(user=test_user).count(), 1)
         self.assertTrue(test_user.check_password(' pass123 '))
         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 pathlib import Path
 
 
-from django.core.exceptions import ValidationError
 from django.test import TestCase
 from django.test import TestCase
 
 
 from misago.conf import settings
 from misago.conf import settings
@@ -10,79 +9,6 @@ from misago.users.avatars import dynamic
 from misago.users.models import Avatar, User
 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):
 class UserModelTests(TestCase):
     def test_anonymize_data(self):
     def test_anonymize_data(self):
         """anonymize_data sets username and slug to one defined in settings"""
         """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 django.contrib.auth import get_user_model
 
 
 from misago.acl.test import patch_user_acl
 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
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -17,6 +17,7 @@ class UserUsernameTests(AuthenticatedUserTestCase):
         super().setUp()
         super().setUp()
         self.link = '/api/users/%s/username/' % self.user.pk
         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):
     def test_get_change_username_options(self):
         """get to API returns options"""
         """get to API returns options"""
         response = self.client.get(self.link)
         response = self.client.get(self.link)
@@ -25,8 +26,8 @@ class UserUsernameTests(AuthenticatedUserTestCase):
         response_json = response.json()
         response_json = response.json()
 
 
         self.assertIsNotNone(response_json['changes_left'])
         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'])
         self.assertIsNone(response_json['next_on'])
 
 
         for i in range(response_json['changes_left']):
         for i in range(response_json['changes_left']):
@@ -133,14 +134,15 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
         })
         })
 
 
     @patch_user_acl({'can_rename_users': 1})
     @patch_user_acl({'can_rename_users': 1})
+    @override_dynamic_settings(username_length_min=3, username_length_max=12)
     def test_moderate_username(self):
     def test_moderate_username(self):
         """moderate username"""
         """moderate username"""
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         options = response.json()
         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(
         response = self.client.post(
             self.link,
             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.models import Agreement
 from misago.legal.utils import save_user_agreement_acceptance
 from misago.legal.utils import save_user_agreement_acceptance
 from misago.threads.testutils import post_thread, reply_thread
 from misago.threads.testutils import post_thread, reply_thread
+
 from misago.users.datadownloads import request_user_data_download
 from misago.users.datadownloads import request_user_data_download
 from misago.users.models import Ban, DataDownload, Rank
 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):
 class UserAdminViewsTests(AdminTestCase):
@@ -42,9 +43,9 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.get(link_base)
         response = self.client.get(link_base)
         self.assertEqual(response.status_code, 200)
         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
         # Search both
         response = self.client.get('%s&username=tyr' % link_base)
         response = self.client.get('%s&username=tyr' % link_base)
@@ -94,10 +95,9 @@ class UserAdminViewsTests(AdminTestCase):
         """users list activates multiple users"""
         """users list activates multiple users"""
         user_pks = []
         user_pks = []
         for i in range(10):
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 requires_activation=1,
                 requires_activation=1,
             )
             )
             user_pks.append(test_user.pk)
             user_pks.append(test_user.pk)
@@ -111,7 +111,7 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         self.assertEqual(response.status_code, 302)
 
 
-        inactive_qs = UserModel.objects.filter(
+        inactive_qs = User.objects.filter(
             id__in=user_pks,
             id__in=user_pks,
             requires_activation=1,
             requires_activation=1,
         )
         )
@@ -122,10 +122,9 @@ class UserAdminViewsTests(AdminTestCase):
         """users list bans multiple users"""
         """users list bans multiple users"""
         user_pks = []
         user_pks = []
         for i in range(10):
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 requires_activation=1,
                 requires_activation=1,
             )
             )
             user_pks.append(test_user.pk)
             user_pks.append(test_user.pk)
@@ -157,10 +156,9 @@ class UserAdminViewsTests(AdminTestCase):
         """users list bans multiple users that also have ips"""
         """users list bans multiple users that also have ips"""
         user_pks = []
         user_pks = []
         for i in range(10):
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 joined_from_ip='73.95.67.27',
                 joined_from_ip='73.95.67.27',
                 requires_activation=1,
                 requires_activation=1,
             )
             )
@@ -193,10 +191,9 @@ class UserAdminViewsTests(AdminTestCase):
         """users list requests data download for multiple users"""
         """users list requests data download for multiple users"""
         user_pks = []
         user_pks = []
         for i in range(10):
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 requires_activation=1,
                 requires_activation=1,
             )
             )
             user_pks.append(test_user.pk)
             user_pks.append(test_user.pk)
@@ -216,10 +213,9 @@ class UserAdminViewsTests(AdminTestCase):
         """users list avoids excessive data download requests for multiple users"""
         """users list avoids excessive data download requests for multiple users"""
         user_pks = []
         user_pks = []
         for i in range(10):
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 requires_activation=1,
                 requires_activation=1,
             )
             )
             request_user_data_download(test_user)
             request_user_data_download(test_user)
@@ -256,10 +252,9 @@ class UserAdminViewsTests(AdminTestCase):
         """its impossible to delete admin account"""
         """its impossible to delete admin account"""
         user_pks = []
         user_pks = []
         for i in range(10):
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'bob%s@test.com' % i,
-                'pass123',
             )
             )
             user_pks.append(test_user.pk)
             user_pks.append(test_user.pk)
 
 
@@ -279,16 +274,15 @@ class UserAdminViewsTests(AdminTestCase):
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "be deleted.")
         self.assertContains(response, "be deleted.")
 
 
-        self.assertEqual(UserModel.objects.count(), 11)
+        self.assertEqual(User.objects.count(), 11)
 
 
     def test_mass_delete_accounts_superadmin(self):
     def test_mass_delete_accounts_superadmin(self):
         """its impossible to delete superadmin account"""
         """its impossible to delete superadmin account"""
         user_pks = []
         user_pks = []
         for i in range(10):
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'bob%s@test.com' % i,
-                'pass123',
             )
             )
             user_pks.append(test_user.pk)
             user_pks.append(test_user.pk)
 
 
@@ -308,27 +302,25 @@ class UserAdminViewsTests(AdminTestCase):
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "be deleted.")
         self.assertContains(response, "be deleted.")
 
 
-        self.assertEqual(UserModel.objects.count(), 11)
+        self.assertEqual(User.objects.count(), 11)
 
 
     def test_mass_delete_accounts(self):
     def test_mass_delete_accounts(self):
         """users list deletes users"""
         """users list deletes users"""
         # create 10 users to delete
         # create 10 users to delete
         user_pks = []
         user_pks = []
         for i in range(10):
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 requires_activation=0,
                 requires_activation=0,
             )
             )
             user_pks.append(test_user.pk)
             user_pks.append(test_user.pk)
 
 
         # create 10 more users that won't be deleted
         # create 10 more users that won't be deleted
         for i in range(10):
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Weebl%s' % i,
                 'Weebl%s' % i,
                 'weebl%s@test.com' % i,
                 'weebl%s@test.com' % i,
-                'pass123',
                 requires_activation=0,
                 requires_activation=0,
             )
             )
 
 
@@ -340,7 +332,7 @@ class UserAdminViewsTests(AdminTestCase):
             }
             }
         )
         )
         self.assertEqual(response.status_code, 302)
         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):
     def test_mass_delete_all_self(self):
         """its impossible to delete oneself with content"""
         """its impossible to delete oneself with content"""
@@ -362,10 +354,9 @@ class UserAdminViewsTests(AdminTestCase):
         """its impossible to delete admin account and content"""
         """its impossible to delete admin account and content"""
         user_pks = []
         user_pks = []
         for i in range(10):
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'bob%s@test.com' % i,
-                'pass123',
             )
             )
             user_pks.append(test_user.pk)
             user_pks.append(test_user.pk)
 
 
@@ -385,16 +376,15 @@ class UserAdminViewsTests(AdminTestCase):
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "be deleted.")
         self.assertContains(response, "be deleted.")
 
 
-        self.assertEqual(UserModel.objects.count(), 11)
+        self.assertEqual(User.objects.count(), 11)
 
 
     def test_mass_delete_all_superadmin(self):
     def test_mass_delete_all_superadmin(self):
         """its impossible to delete superadmin account and content"""
         """its impossible to delete superadmin account and content"""
         user_pks = []
         user_pks = []
         for i in range(10):
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'bob%s@test.com' % i,
-                'pass123',
             )
             )
             user_pks.append(test_user.pk)
             user_pks.append(test_user.pk)
 
 
@@ -414,16 +404,15 @@ class UserAdminViewsTests(AdminTestCase):
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "be deleted.")
         self.assertContains(response, "be deleted.")
 
 
-        self.assertEqual(UserModel.objects.count(), 11)
+        self.assertEqual(User.objects.count(), 11)
 
 
     def test_mass_delete_all(self):
     def test_mass_delete_all(self):
         """users list mass deleting view has no showstoppers"""
         """users list mass deleting view has no showstoppers"""
         user_pks = []
         user_pks = []
         for i in range(10):
         for i in range(10):
-            test_user = UserModel.objects.create_user(
+            test_user = create_test_user(
                 'Bob%s' % i,
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'bob%s@test.com' % i,
-                'pass123',
                 requires_activation=1,
                 requires_activation=1,
             )
             )
             user_pks.append(test_user.pk)
             user_pks.append(test_user.pk)
@@ -438,7 +427,7 @@ class UserAdminViewsTests(AdminTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
          # asser that no user has been deleted, because actuall deleting happens in
          # asser that no user has been deleted, because actuall deleting happens in
          # dedicated views called via ajax from JavaScript
          # dedicated views called via ajax from JavaScript
-        self.assertEqual(UserModel.objects.count(), 11)
+        self.assertEqual(User.objects.count(), 11)
 
 
     def test_new_view(self):
     def test_new_view(self):
         """new user view creates account"""
         """new user view creates account"""
@@ -461,8 +450,8 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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'))
         self.assertTrue(test_user.check_password('pass123'))
 
 
@@ -487,14 +476,14 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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 '))
         self.assertTrue(test_user.check_password(' pass123 '))
 
 
     def test_edit_view(self):
     def test_edit_view(self):
         """edit user view changes account"""
         """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(
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
                 'pk': test_user.pk,
@@ -525,13 +514,13 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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.assertTrue(updated_user.check_password('newpass123'))
         self.assertEqual(updated_user.username, 'Bawww')
         self.assertEqual(updated_user.username, 'Bawww')
         self.assertEqual(updated_user.slug, '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):
     def test_edit_dont_change_username(self):
         """
         """
@@ -539,7 +528,7 @@ class UserAdminViewsTests(AdminTestCase):
 
 
         This is regression test for issue #640
         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(
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
                 'pk': test_user.pk,
@@ -556,7 +545,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'signature': 'Hello world!',
                 'signature': 'Hello world!',
                 'is_signature_locked': '1',
                 'is_signature_locked': '1',
                 'is_hiding_presence': '0',
                 'is_hiding_presence': '0',
@@ -569,14 +557,14 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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.username, 'Bob')
         self.assertEqual(updated_user.slug, 'bob')
         self.assertEqual(updated_user.slug, 'bob')
         self.assertEqual(updated_user.namechanges.count(), 0)
         self.assertEqual(updated_user.namechanges.count(), 0)
 
 
     def test_edit_change_password_whitespaces(self):
     def test_edit_change_password_whitespaces(self):
         """edit user view changes account password to include whitespaces"""
         """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(
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
                 'pk': test_user.pk,
@@ -607,17 +595,17 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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.assertTrue(updated_user.check_password(' newpass123 '))
         self.assertEqual(updated_user.username, 'Bawww')
         self.assertEqual(updated_user.username, 'Bawww')
         self.assertEqual(updated_user.slug, '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):
     def test_edit_make_admin(self):
         """edit user view allows super admin to make other user admin"""
         """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(
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
                 'pk': test_user.pk,
@@ -635,7 +623,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '1',
                 'is_staff': '1',
                 'is_superuser': '0',
                 'is_superuser': '0',
                 'signature': 'Hello world!',
                 'signature': 'Hello world!',
@@ -650,13 +637,13 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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.assertTrue(updated_user.is_staff)
         self.assertFalse(updated_user.is_superuser)
         self.assertFalse(updated_user.is_superuser)
 
 
     def test_edit_make_superadmin_admin(self):
     def test_edit_make_superadmin_admin(self):
         """edit user view allows super admin to make other user super admin"""
         """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(
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
                 'pk': test_user.pk,
@@ -674,7 +661,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '0',
                 'is_staff': '0',
                 'is_superuser': '1',
                 'is_superuser': '1',
                 'signature': 'Hello world!',
                 'signature': 'Hello world!',
@@ -689,16 +675,15 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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_staff)
         self.assertTrue(updated_user.is_superuser)
         self.assertTrue(updated_user.is_superuser)
 
 
     def test_edit_denote_superadmin(self):
     def test_edit_denote_superadmin(self):
         """edit user view allows super admin to denote other super admin"""
         """edit user view allows super admin to denote other super admin"""
-        test_user = UserModel.objects.create_user(
+        test_user = create_test_user(
             'Bob',
             'Bob',
             'bob@test.com',
             'bob@test.com',
-            'pass123',
             is_staff=True,
             is_staff=True,
             is_superuser=True,
             is_superuser=True,
         )
         )
@@ -720,7 +705,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '0',
                 'is_staff': '0',
                 'is_superuser': '0',
                 'is_superuser': '0',
                 'signature': 'Hello world!',
                 'signature': 'Hello world!',
@@ -735,7 +719,7 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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_staff)
         self.assertFalse(updated_user.is_superuser)
         self.assertFalse(updated_user.is_superuser)
 
 
@@ -744,7 +728,7 @@ class UserAdminViewsTests(AdminTestCase):
         self.user.is_superuser = False
         self.user.is_superuser = False
         self.user.save()
         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(
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
                 'pk': test_user.pk,
@@ -762,7 +746,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '1',
                 'is_staff': '1',
                 'is_superuser': '1',
                 'is_superuser': '1',
                 'signature': 'Hello world!',
                 'signature': 'Hello world!',
@@ -777,7 +760,7 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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_staff)
         self.assertFalse(updated_user.is_superuser)
         self.assertFalse(updated_user.is_superuser)
 
 
@@ -786,7 +769,7 @@ class UserAdminViewsTests(AdminTestCase):
         self.user.is_superuser = False
         self.user.is_superuser = False
         self.user.save()
         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(
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
                 'pk': test_user.pk,
@@ -804,7 +787,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '0',
                 'is_staff': '0',
                 'is_superuser': '0',
                 'is_superuser': '0',
                 'signature': 'Hello world!',
                 'signature': 'Hello world!',
@@ -821,7 +803,7 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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.assertFalse(updated_user.is_active)
         self.assertEqual(updated_user.is_active_staff_message, "Disabled in test!")
         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.is_superuser = True
         self.user.save()
         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.is_staff = True
         test_user.save()
         test_user.save()
@@ -852,7 +834,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '1',
                 'is_staff': '1',
                 'is_superuser': '0',
                 'is_superuser': '0',
                 'signature': 'Hello world!',
                 'signature': 'Hello world!',
@@ -869,7 +850,7 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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.assertFalse(updated_user.is_active)
         self.assertEqual(updated_user.is_active_staff_message, "Disabled in test!")
         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.is_superuser = False
         self.user.save()
         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.is_staff = True
         test_user.save()
         test_user.save()
@@ -900,7 +881,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '1',
                 'is_staff': '1',
                 'is_superuser': '0',
                 'is_superuser': '0',
                 'signature': 'Hello world!',
                 'signature': 'Hello world!',
@@ -917,13 +897,13 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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.assertTrue(updated_user.is_active)
         self.assertFalse(updated_user.is_active_staff_message)
         self.assertFalse(updated_user.is_active_staff_message)
 
 
     def test_edit_is_deleting_account_cant_reactivate(self):
     def test_edit_is_deleting_account_cant_reactivate(self):
         """users deleting own accounts can't be reactivated"""
         """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_user.mark_for_delete()
 
 
         test_link = reverse(
         test_link = reverse(
@@ -943,7 +923,6 @@ class UserAdminViewsTests(AdminTestCase):
                 'rank': str(test_user.rank_id),
                 'rank': str(test_user.rank_id),
                 'roles': str(test_user.roles.all()[0].pk),
                 'roles': str(test_user.roles.all()[0].pk),
                 'email': 'reg@stered.com',
                 'email': 'reg@stered.com',
-                'new_password': 'pass123',
                 'is_staff': '1',
                 'is_staff': '1',
                 'is_superuser': '0',
                 'is_superuser': '0',
                 'signature': 'Hello world!',
                 'signature': 'Hello world!',
@@ -959,13 +938,13 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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.assertFalse(updated_user.is_active)
         self.assertTrue(updated_user.is_deleting_account)
         self.assertTrue(updated_user.is_deleting_account)
 
 
     def test_edit_unusable_password(self):
     def test_edit_unusable_password(self):
         """admin edit form handles unusable passwords and lets setting new password"""
         """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())
         self.assertFalse(test_user.has_usable_password())
 
 
         test_link = reverse(
         test_link = reverse(
@@ -1000,12 +979,12 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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())
         self.assertTrue(updated_user.has_usable_password())
 
 
     def test_edit_keep_unusable_password(self):
     def test_edit_keep_unusable_password(self):
         """admin edit form handles unusable passwords and lets admin leave them unchanged"""
         """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())
         self.assertFalse(test_user.has_usable_password())
 
 
         test_link = reverse(
         test_link = reverse(
@@ -1039,12 +1018,12 @@ class UserAdminViewsTests(AdminTestCase):
         )
         )
         self.assertEqual(response.status_code, 302)
         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())
         self.assertFalse(updated_user.has_usable_password())
 
 
     def test_edit_agreements_list(self):
     def test_edit_agreements_list(self):
         """edit view displays list of user's agreements"""
         """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(
         test_link = reverse(
             'misago:admin:users:accounts:edit', kwargs={
             'misago:admin:users:accounts:edit', kwargs={
                 'pk': test_user.pk,
                 'pk': test_user.pk,
@@ -1084,7 +1063,7 @@ class UserAdminViewsTests(AdminTestCase):
 
 
     def test_delete_threads_view_staff(self):
     def test_delete_threads_view_staff(self):
         """delete user threads view validates if user deletes staff"""
         """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.is_staff = True
         test_user.save()
         test_user.save()
 
 
@@ -1102,7 +1081,7 @@ class UserAdminViewsTests(AdminTestCase):
 
 
     def test_delete_threads_view_superuser(self):
     def test_delete_threads_view_superuser(self):
         """delete user threads view validates if user deletes superuser"""
         """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.is_superuser = True
         test_user.save()
         test_user.save()
 
 
@@ -1120,7 +1099,7 @@ class UserAdminViewsTests(AdminTestCase):
 
 
     def test_delete_threads_view(self):
     def test_delete_threads_view(self):
         """delete user threads view deletes threads"""
         """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(
         test_link = reverse(
             'misago:admin:users:accounts:delete-threads', kwargs={
             'misago:admin:users:accounts:delete-threads', kwargs={
                 'pk': test_user.pk,
                 'pk': test_user.pk,
@@ -1160,7 +1139,7 @@ class UserAdminViewsTests(AdminTestCase):
 
 
     def test_delete_posts_view_staff(self):
     def test_delete_posts_view_staff(self):
         """delete user posts view validates if user deletes staff"""
         """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.is_staff = True
         test_user.save()
         test_user.save()
 
 
@@ -1178,7 +1157,7 @@ class UserAdminViewsTests(AdminTestCase):
 
 
     def test_delete_posts_view_superuser(self):
     def test_delete_posts_view_superuser(self):
         """delete user posts view validates if user deletes superuser"""
         """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.is_superuser = True
         test_user.save()
         test_user.save()
 
 
@@ -1196,7 +1175,7 @@ class UserAdminViewsTests(AdminTestCase):
 
 
     def test_delete_posts_view(self):
     def test_delete_posts_view(self):
         """delete user posts view deletes posts"""
         """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(
         test_link = reverse(
             'misago:admin:users:accounts:delete-posts', kwargs={
             'misago:admin:users:accounts:delete-posts', kwargs={
                 'pk': test_user.pk,
                 'pk': test_user.pk,
@@ -1237,7 +1216,7 @@ class UserAdminViewsTests(AdminTestCase):
 
 
     def test_delete_account_view_staff(self):
     def test_delete_account_view_staff(self):
         """delete user account view validates if user deletes staff"""
         """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.is_staff = True
         test_user.save()
         test_user.save()
 
 
@@ -1255,7 +1234,7 @@ class UserAdminViewsTests(AdminTestCase):
 
 
     def test_delete_account_view_superuser(self):
     def test_delete_account_view_superuser(self):
         """delete user account view validates if user deletes superuser"""
         """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.is_superuser = True
         test_user.save()
         test_user.save()
 
 
@@ -1273,7 +1252,7 @@ class UserAdminViewsTests(AdminTestCase):
 
 
     def test_delete_account_view(self):
     def test_delete_account_view(self):
         """delete user account view deletes user account"""
         """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(
         test_link = reverse(
             'misago:admin:users:accounts:delete-account', kwargs={
             'misago:admin:users:accounts:delete-account', kwargs={
                 'pk': test_user.pk,
                 '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.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.test import TestCase
 from django.test import TestCase
 
 
-from misago.conf import settings
 from misago.users.models import Ban
 from misago.users.models import Ban
 from misago.users.validators import (
 from misago.users.validators import (
     validate_email, validate_email_available, validate_email_banned, validate_gmail_email,
     validate_email, validate_email_available, validate_email_banned, validate_gmail_email,
@@ -56,14 +57,15 @@ class ValidateEmailTests(TestCase):
 class ValidateUsernameTests(TestCase):
 class ValidateUsernameTests(TestCase):
     def test_validate_username(self):
     def test_validate_username(self):
         """validate_username has no crashes"""
         """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):
         with self.assertRaises(ValidationError):
-            validate_username('*')
+            validate_username(settings, '*')
 
 
 
 
 class ValidateUsernameAvailableTests(TestCase):
 class ValidateUsernameAvailableTests(TestCase):
     def setUp(self):
     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):
     def test_valid_name(self):
         """validate_username_available allows available names"""
         """validate_username_available allows available names"""
@@ -117,15 +119,17 @@ class ValidateUsernameContentTests(TestCase):
 class ValidateUsernameLengthTests(TestCase):
 class ValidateUsernameLengthTests(TestCase):
     def test_valid_name(self):
     def test_valid_name(self):
         """validate_username_length allows valid names"""
         """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):
     def test_invalid_name(self):
         """validate_username_length disallows invalid names"""
         """validate_username_length disallows invalid names"""
+        settings = Mock(username_length_min=1, username_length_max=5)
         with self.assertRaises(ValidationError):
         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):
         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):
 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 misago.core.testutils import MisagoTestCase
 
 
 from .models import AnonymousUser, Online
 from .models import AnonymousUser, Online
+from .setupnewuser import setup_new_user
 
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 
 
 class UserTestCase(MisagoTestCase):
 class UserTestCase(MisagoTestCase):
@@ -23,7 +23,7 @@ class UserTestCase(MisagoTestCase):
         return AnonymousUser()
         return AnonymousUser()
 
 
     def get_authenticated_user(self):
     def get_authenticated_user(self):
-        return UserModel.objects.create_user(
+        return create_test_user(
             "TestUser",
             "TestUser",
             "test@user.com",
             "test@user.com",
             self.USER_PASSWORD,
             self.USER_PASSWORD,
@@ -31,12 +31,12 @@ class UserTestCase(MisagoTestCase):
         )
         )
 
 
     def get_superuser(self):
     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):
     def login_user(self, user, password=None):
         self.client.force_login(user)
         self.client.force_login(user)
@@ -53,10 +53,33 @@ class AuthenticatedUserTestCase(UserTestCase):
         self.login_user(self.user)
         self.login_user(self.user)
 
 
     def reload_user(self):
     def reload_user(self):
-        self.user = UserModel.objects.get(id=self.user.id)
+        self.user.refresh_from_db()
 
 
 
 
 class SuperUserTestCase(AuthenticatedUserTestCase):
 class SuperUserTestCase(AuthenticatedUserTestCase):
     def get_initial_user(self):
     def get_initial_user(self):
         self.user = self.get_superuser()
         self.user = self.get_superuser()
         self.login_user(self.user)
         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
 # 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):
 def validate_email_available(value, exclude=None):
     try:
     try:
         user = UserModel.objects.get_by_email(value)
         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."))
             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):
 def validate_username_available(value, exclude=None):
     try:
     try:
         user = UserModel.objects.get_by_username(value)
         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."))
         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:
     if len(value) < settings.username_length_min:
         message = ngettext(
         message = ngettext(
             "Username must be at least %(limit_value)s character long.",
             "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})
         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
 # New account validators
 SFS_API_URL = 'http://api.stopforumspam.org/api?email=%(email)s&ip=%(ip)s&f=json&confidence'  # noqa
 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))
 REGISTRATION_VALIDATORS = list(map(import_string, validators_list))
 
 
 
 
-def raise_validation_error(fieldname, validation_error):
+def raise_validation_error(*_):
     raise ValidationError()
     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.auth import start_admin_session
 from misago.admin.views import generic
 from misago.admin.views import generic
 from misago.categories.models import Category
 from misago.categories.models import Category
-from misago.conf import settings
 from misago.core.mail import mail_users
 from misago.core.mail import mail_users
 from misago.core.pgutils import chunk_queryset
 from misago.core.pgutils import chunk_queryset
 from misago.threads.models import Thread
 from misago.threads.models import Thread
@@ -19,16 +18,16 @@ from misago.users.forms.admin import (
     BanUsersForm, EditUserForm, EditUserFormFactory, NewUserForm, SearchUsersForm)
     BanUsersForm, EditUserForm, EditUserFormFactory, NewUserForm, SearchUsersForm)
 from misago.users.models import Ban
 from misago.users.models import Ban
 from misago.users.profilefields import profilefields
 from misago.users.profilefields import profilefields
+from misago.users.setupnewuser import setup_new_user
 from misago.users.signatures import set_user_signature
 from misago.users.signatures import set_user_signature
 
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 
 
 class UserAdmin(generic.AdminBaseMixin):
 class UserAdmin(generic.AdminBaseMixin):
     root_link = 'misago:admin:users:accounts:index'
     root_link = 'misago:admin:users:accounts:index'
     templates_dir = 'misago/admin/users'
     templates_dir = 'misago/admin/users'
-    model = UserModel
+    model = User
 
 
     def create_form_type(self, request, target):
     def create_form_type(self, request, target):
         add_is_active_fields = False
         add_is_active_fields = False
@@ -115,13 +114,18 @@ class UsersList(UserAdmin, generic.ListView):
             raise generic.MassActionError(message)
             raise generic.MassActionError(message)
         else:
         else:
             activated_users_pks = [u.pk for u in inactive_users]
             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")
             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."))
             messages.success(request, _("Selected users accounts have been activated."))
 
 
@@ -247,15 +251,25 @@ class NewUser(UserAdmin, generic.ModelFormView):
     template = 'new.html'
     template = 'new.html'
     message_submit = _('New user "%(user)s" has been registered.')
     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):
     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['username'],
             form.cleaned_data['email'],
             form.cleaned_data['email'],
             form.cleaned_data['new_password'],
             form.cleaned_data['new_password'],
             title=form.cleaned_data['title'],
             title=form.cleaned_data['title'],
             rank=form.cleaned_data.get('rank'),
             rank=form.cleaned_data.get('rank'),
             joined_from_ip=request.user_ip,
             joined_from_ip=request.user_ip,
-            set_default_avatar=True
         )
         )
 
 
         if form.cleaned_data.get('staff_level'):
         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.roles.add(*form.cleaned_data['roles'])
 
 
         new_user.update_acl_key()
         new_user.update_acl_key()
-        new_user.save()
+        setup_new_user(request.settings, new_user)
 
 
         messages.success(request, self.message_submit % {'user': target.username})
         messages.success(request, self.message_submit % {'user': target.username})
         return redirect('misago:admin:users:accounts:edit', pk=new_user.pk)
         return redirect('misago:admin:users:accounts:edit', pk=new_user.pk)