diff --git a/devproject/settings.py b/devproject/settings.py index ffebc62ee6..179925e5b7 100644 --- a/devproject/settings.py +++ b/devproject/settings.py @@ -188,6 +188,7 @@ # Misago apps 'misago.admin', 'misago.acl', + 'misago.cache', 'misago.core', 'misago.conf', 'misago.markup', @@ -223,12 +224,14 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'misago.cache.middleware.cache_versions_middleware', + 'misago.conf.middleware.dynamic_settings_middleware', 'misago.users.middleware.UserMiddleware', + 'misago.acl.middleware.user_acl_middleware', 'misago.core.middleware.ExceptionHandlerMiddleware', 'misago.users.middleware.OnlineTrackerMiddleware', 'misago.admin.middleware.AdminAuthMiddleware', 'misago.threads.middleware.UnreadThreadsCountMiddleware', - 'misago.core.middleware.ThreadStoreMiddleware', ] ROOT_URLCONF = 'devproject.urls' @@ -283,12 +286,12 @@ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'misago.acl.context_processors.user_acl', + 'misago.conf.context_processors.conf', 'misago.core.context_processors.site_address', 'misago.core.context_processors.momentjs_locale', - 'misago.conf.context_processors.settings', 'misago.search.context_processors.search_providers', 'misago.users.context_processors.user_links', - 'misago.legal.context_processors.legal_links', # Data preloaders 'misago.conf.context_processors.preload_settings_json', diff --git a/devproject/test_settings.py b/devproject/test_settings.py index 2dbfab942f..987d8777fa 100644 --- a/devproject/test_settings.py +++ b/devproject/test_settings.py @@ -17,8 +17,7 @@ # Use in-memory cache CACHES = { 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'uniqu3-sn0wf14k3' + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', } } diff --git a/misago/acl/__init__.py b/misago/acl/__init__.py index dc0f37dd08..f8a3165e74 100644 --- a/misago/acl/__init__.py +++ b/misago/acl/__init__.py @@ -1,3 +1,3 @@ -from .api import get_user_acl, add_acl, serialize_acl - default_app_config = 'misago.acl.apps.MisagoACLsConfig' + +ACL_CACHE = "acl" diff --git a/misago/acl/api.py b/misago/acl/api.py deleted file mode 100644 index d003162cfc..0000000000 --- a/misago/acl/api.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -Module functions for ACLs - -Workflow for ACLs in Misago is simple: - -First, you get user ACL. Its directory that you can introspect to find out user -permissions, or if you have objects, you can use this acl to make those objects -aware of their ACLs. This gives objects themselves special "acl" attribute with -properties defined by ACL providers within their "add_acl_to_target" -""" -import copy - -from misago.core import threadstore -from misago.core.cache import cache - -from . import version -from .builder import build_acl -from .providers import providers - - -def get_user_acl(user): - """get ACL for User""" - acl_key = 'acl_%s' % user.acl_key - - acl_cache = threadstore.get(acl_key) - if not acl_cache: - acl_cache = cache.get(acl_key) - - if acl_cache and version.is_valid(acl_cache.get('_acl_version')): - return acl_cache - else: - new_acl = build_acl(user.get_roles()) - new_acl['_acl_version'] = version.get_version() - - threadstore.set(acl_key, new_acl) - cache.set(acl_key, new_acl) - - return new_acl - - -def add_acl(user, target): - """add valid ACL to target (iterable of objects or single object)""" - if hasattr(target, '__iter__'): - for item in target: - _add_acl_to_target(user, item) - else: - _add_acl_to_target(user, target) - - -def _add_acl_to_target(user, target): - """add valid ACL to single target, helper for add_acl function""" - target.acl = {} - - for annotator in providers.get_obj_type_annotators(target): - annotator(user, target) - - -def serialize_acl(target): - """serialize authenticated user's ACL""" - serialized_acl = copy.deepcopy(target.acl_cache) - - for serializer in providers.get_obj_type_serializers(target): - serializer(serialized_acl) - - return serialized_acl diff --git a/misago/acl/apps.py b/misago/acl/apps.py index fe7fff16cc..7a3570e2c7 100644 --- a/misago/acl/apps.py +++ b/misago/acl/apps.py @@ -1,6 +1,7 @@ from django.apps import AppConfig from .providers import providers + class MisagoACLsConfig(AppConfig): name = 'misago.acl' label = 'misago_acl' diff --git a/misago/acl/builder.py b/misago/acl/buildacl.py similarity index 100% rename from misago/acl/builder.py rename to misago/acl/buildacl.py diff --git a/misago/acl/cache.py b/misago/acl/cache.py new file mode 100644 index 0000000000..3c349c27a7 --- /dev/null +++ b/misago/acl/cache.py @@ -0,0 +1,23 @@ +from django.core.cache import cache + +from misago.cache.versions import invalidate_cache + +from . import ACL_CACHE + + +def get_acl_cache(user, cache_versions): + key = get_cache_key(user, cache_versions) + return cache.get(key) + + +def set_acl_cache(user, cache_versions, user_acl): + key = get_cache_key(user, cache_versions) + cache.set(key, user_acl) + + +def get_cache_key(user, cache_versions): + return 'acl_%s_%s' % (user.acl_key, cache_versions[ACL_CACHE]) + + +def clear_acl_cache(): + invalidate_cache(ACL_CACHE) diff --git a/misago/acl/constants.py b/misago/acl/constants.py deleted file mode 100644 index c3a6c458ec..0000000000 --- a/misago/acl/constants.py +++ /dev/null @@ -1 +0,0 @@ -ACL_CACHEBUSTER = 'misago_acl' diff --git a/misago/acl/context_processors.py b/misago/acl/context_processors.py new file mode 100644 index 0000000000..93d70b6c76 --- /dev/null +++ b/misago/acl/context_processors.py @@ -0,0 +1,2 @@ +def user_acl(request): + return {"user_acl": request.user_acl} diff --git a/misago/acl/middleware.py b/misago/acl/middleware.py new file mode 100644 index 0000000000..f948c166ec --- /dev/null +++ b/misago/acl/middleware.py @@ -0,0 +1,12 @@ +from django.utils.functional import SimpleLazyObject + +from . import useracl + + +def user_acl_middleware(get_response): + """Sets request.user_acl attribute with dict containing current user acl.""" + def middleware(request): + request.user_acl = useracl.get_user_acl(request.user, request.cache_versions) + return get_response(request) + + return middleware diff --git a/misago/acl/migrations/0002_acl_version_tracker.py b/misago/acl/migrations/0002_acl_version_tracker.py index ffbfd319c8..f18598daa1 100644 --- a/misago/acl/migrations/0002_acl_version_tracker.py +++ b/misago/acl/migrations/0002_acl_version_tracker.py @@ -1,20 +1,12 @@ from django.db import migrations -from misago.acl.constants import ACL_CACHEBUSTER -from misago.core.migrationutils import cachebuster_register_cache - - -def register_acl_version_tracker(apps, schema_editor): - cachebuster_register_cache(apps, ACL_CACHEBUSTER) - class Migration(migrations.Migration): + """Superseded by 0004""" dependencies = [ ('misago_acl', '0001_initial'), ('misago_core', '0001_initial'), ] - operations = [ - migrations.RunPython(register_acl_version_tracker), - ] + operations = [] diff --git a/misago/acl/migrations/0004_cache_version.py b/misago/acl/migrations/0004_cache_version.py new file mode 100644 index 0000000000..5a0d768a87 --- /dev/null +++ b/misago/acl/migrations/0004_cache_version.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +from django.db import migrations + +from misago.acl import ACL_CACHE +from misago.cache.operations import StartCacheVersioning + + +class Migration(migrations.Migration): + + dependencies = [ + ('misago_acl', '0003_default_roles'), + ('misago_cache', '0001_initial'), + ] + + operations = [ + StartCacheVersioning(ACL_CACHE) + ] \ No newline at end of file diff --git a/misago/acl/models.py b/misago/acl/models.py index 4968b088fb..88888372f6 100644 --- a/misago/acl/models.py +++ b/misago/acl/models.py @@ -2,7 +2,7 @@ from django.db import models from django.utils.translation import gettext as _ -from . import version as acl_version +from .cache import clear_acl_cache def permissions_default(): @@ -22,11 +22,11 @@ def __str__(self): def save(self, *args, **kwargs): if self.pk: - acl_version.invalidate() + clear_acl_cache() return super().save(*args, **kwargs) def delete(self, *args, **kwargs): - acl_version.invalidate() + clear_acl_cache() return super().delete(*args, **kwargs) diff --git a/misago/acl/objectacl.py b/misago/acl/objectacl.py new file mode 100644 index 0000000000..2912b29ab3 --- /dev/null +++ b/misago/acl/objectacl.py @@ -0,0 +1,18 @@ +from .providers import providers + + +def add_acl_to_obj(user_acl, obj): + """add valid ACL to obj (iterable of objects or single object)""" + if hasattr(obj, '__iter__'): + for item in obj: + _add_acl_to_obj(user_acl, item) + else: + _add_acl_to_obj(user_acl, obj) + + +def _add_acl_to_obj(user_acl, obj): + """add valid ACL to single obj, helper for add_acl function""" + obj.acl = {} + + for annotator in providers.get_obj_type_annotators(obj): + annotator(user_acl, obj) diff --git a/misago/acl/panels.py b/misago/acl/panels.py index 19d346ca95..01c3ddbc94 100644 --- a/misago/acl/panels.py +++ b/misago/acl/panels.py @@ -24,7 +24,7 @@ def process_response(self, request, response): misago_user = None try: - misago_acl = misago_user.acl_cache + misago_acl = request.user_acl except AttributeError: misago_acl = {} diff --git a/misago/acl/providers.py b/misago/acl/providers.py index 9ded5d95e0..83c80714d6 100644 --- a/misago/acl/providers.py +++ b/misago/acl/providers.py @@ -5,13 +5,13 @@ _NOT_INITIALIZED_ERROR = ( "PermissionProviders instance has to load providers with load() " - "before get_obj_type_annotators(), get_obj_type_serializers(), " + "before get_obj_type_annotators(), get_user_acl_serializers(), " "list() or dict() methods will be available." ) _ALREADY_INITIALIZED_ERROR = ( "PermissionProviders instance has already loaded providers and " - "acl_annotator or acl_serializer are no longer available." + "acl_annotator or user_acl_serializer are no longer available." ) @@ -24,14 +24,16 @@ def __init__(self): self._providers_dict = {} self._annotators = {} - self._serializers = {} + self._user_acl_serializers = [] def load(self): - if not self._initialized: - self._register_providers() - self._change_lists_to_tupes(self._annotators) - self._change_lists_to_tupes(self._serializers) - self._initialized = True + if self._initialized: + raise RuntimeError("providers are already loaded") + + self._register_providers() + self._coerce_dict_values_to_tuples(self._annotators) + self._user_acl_serializers = tuple(self._user_acl_serializers) + self._initialized = True def _register_providers(self): for namespace in settings.MISAGO_ACL_EXTENSIONS: @@ -41,7 +43,7 @@ def _register_providers(self): if hasattr(self._providers_dict[namespace], 'register_with'): self._providers_dict[namespace].register_with(self) - def _change_lists_to_tupes(self, types_dict): + def _coerce_dict_values_to_tuples(self, types_dict): for hashType in types_dict.keys(): types_dict[hashType] = tuple(types_dict[hashType]) @@ -50,18 +52,18 @@ def acl_annotator(self, hashable_type, func): assert not self._initialized, _ALREADY_INITIALIZED_ERROR self._annotators.setdefault(hashable_type, []).append(func) - def acl_serializer(self, hashable_type, func): + def user_acl_serializer(self, func): """registers ACL serializer for specified types""" assert not self._initialized, _ALREADY_INITIALIZED_ERROR - self._serializers.setdefault(hashable_type, []).append(func) + self._user_acl_serializers.append(func) def get_obj_type_annotators(self, obj): assert self._initialized, _NOT_INITIALIZED_ERROR return self._annotators.get(obj.__class__, []) - def get_obj_type_serializers(self, obj): + def get_user_acl_serializers(self): assert self._initialized, _NOT_INITIALIZED_ERROR - return self._serializers.get(obj.__class__, []) + return self._user_acl_serializers def list(self): assert self._initialized, _NOT_INITIALIZED_ERROR diff --git a/misago/acl/test.py b/misago/acl/test.py new file mode 100644 index 0000000000..c5d32790f2 --- /dev/null +++ b/misago/acl/test.py @@ -0,0 +1,56 @@ +from contextlib import ContextDecorator, ExitStack, contextmanager +from functools import wraps +from unittest.mock import patch + +from .useracl import get_user_acl + +__all__ = ["patch_user_acl"] + + +class patch_user_acl(ContextDecorator, ExitStack): + """Testing utility that patches get_user_acl results + + Can be used as decorator or context manager. + + Patch should be a dict or callable. + """ + + _acl_patches = [] + + def __init__(self, acl_patch): + super().__init__() + self.acl_patch = acl_patch + + def patched_get_user_acl(self, user, cache_versions): + user_acl = get_user_acl(user, cache_versions) + self.apply_acl_patches(user, user_acl) + return user_acl + + def apply_acl_patches(self, user, user_acl): + for acl_patch in self._acl_patches: + self.apply_acl_patch(user, user_acl, acl_patch) + + def apply_acl_patch(self, user, user_acl, acl_patch): + if callable(acl_patch): + acl_patch(user, user_acl) + else: + user_acl.update(acl_patch) + + def __enter__(self): + super().__enter__() + self.enter_context(self.enable_acl_patch()) + self.enter_context(self.patch_user_acl()) + + @contextmanager + def enable_acl_patch(self): + try: + self._acl_patches.append(self.acl_patch) + yield + finally: + self._acl_patches.pop(-1) + + def patch_user_acl(self): + return patch( + "misago.acl.useracl.get_user_acl", + side_effect=self.patched_get_user_acl, + ) diff --git a/misago/acl/tests/test_api.py b/misago/acl/tests/test_api.py deleted file mode 100644 index 1d696bdf99..0000000000 --- a/misago/acl/tests/test_api.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.contrib.auth import get_user_model -from django.test import TestCase - -from misago.acl.api import get_user_acl -from misago.users.models import AnonymousUser - - -UserModel = get_user_model() - - -class GetUserACLTests(TestCase): - def test_get_authenticated_acl(self): - """get ACL for authenticated user""" - test_user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123') - - acl = get_user_acl(test_user) - - self.assertTrue(acl) - self.assertEqual(acl, test_user.acl_cache) - - def test_get_anonymous_acl(self): - """get ACL for unauthenticated user""" - acl = get_user_acl(AnonymousUser()) - - self.assertTrue(acl) - self.assertEqual(acl, AnonymousUser().acl_cache) diff --git a/misago/acl/tests/test_getting_user_acl.py b/misago/acl/tests/test_getting_user_acl.py new file mode 100644 index 0000000000..97c82ac25c --- /dev/null +++ b/misago/acl/tests/test_getting_user_acl.py @@ -0,0 +1,94 @@ +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from misago.acl.useracl import get_user_acl +from misago.conftest import get_cache_versions +from misago.users.models import AnonymousUser + +User = get_user_model() + +cache_versions = get_cache_versions() + + +class GettingUserACLTests(TestCase): + def test_getter_returns_authenticated_user_acl(self): + user = User.objects.create_user('Bob', 'bob@bob.com') + acl = get_user_acl(user, cache_versions) + + assert acl + assert acl["user_id"] == user.id + assert acl["is_authenticated"] is True + assert acl["is_anonymous"] is False + + def test_user_acl_includes_staff_and_superuser_false_status(self): + user = User.objects.create_user('Bob', 'bob@bob.com') + acl = get_user_acl(user, cache_versions) + + assert acl + assert acl["is_staff"] is False + assert acl["is_superuser"] is False + + def test_user_acl_includes_cache_versions(self): + user = User.objects.create_user('Bob', 'bob@bob.com') + acl = get_user_acl(user, cache_versions) + + assert acl + assert acl["cache_versions"] == cache_versions + + def test_getter_returns_anonymous_user_acl(self): + user = AnonymousUser() + acl = get_user_acl(user, cache_versions) + + assert acl + assert acl["user_id"] == user.id + assert acl["is_authenticated"] is False + assert acl["is_anonymous"] is True + + def test_superuser_acl_includes_staff_and_superuser_true_status(self): + user = User.objects.create_superuser('Bob', 'bob@bob.com', 'Pass.123') + acl = get_user_acl(user, cache_versions) + + assert acl + assert acl["is_staff"] is True + assert acl["is_superuser"] is True + + @patch('django.core.cache.cache.get', return_value=dict()) + def test_getter_returns_acl_from_cache(self, cache_get): + user = AnonymousUser() + get_user_acl(user, cache_versions) + cache_get.assert_called_once() + + @patch('django.core.cache.cache.set') + @patch('misago.acl.buildacl.build_acl', return_value=dict()) + @patch('django.core.cache.cache.get', return_value=None) + def test_getter_builds_new_acl_when_cache_is_not_available(self, cache_get, *_): + user = AnonymousUser() + get_user_acl(user, cache_versions) + cache_get.assert_called_once() + + @patch('django.core.cache.cache.set') + @patch('misago.acl.buildacl.build_acl', return_value=dict()) + @patch('django.core.cache.cache.get', return_value=None) + def test_getter_sets_new_cache_if_no_cache_is_set(self, cache_set, *_): + user = AnonymousUser() + get_user_acl(user, cache_versions) + cache_set.assert_called_once() + + + @patch('django.core.cache.cache.set') + @patch('misago.acl.buildacl.build_acl', return_value=dict()) + @patch('django.core.cache.cache.get', return_value=None) + def test_acl_cache_name_includes_cache_verssion(self, cache_set, *_): + user = AnonymousUser() + get_user_acl(user, cache_versions) + cache_key = cache_set.call_args[0][0] + assert cache_versions["acl"] in cache_key + + @patch('django.core.cache.cache.set') + @patch('django.core.cache.cache.get', return_value=dict()) + def test_getter_is_not_setting_new_cache_if_cache_is_set(self, _, cache_set): + user = AnonymousUser() + get_user_acl(user, cache_versions) + cache_set.assert_not_called() \ No newline at end of file diff --git a/misago/acl/tests/test_patching_user_acl.py b/misago/acl/tests/test_patching_user_acl.py new file mode 100644 index 0000000000..6aa6d7ed9e --- /dev/null +++ b/misago/acl/tests/test_patching_user_acl.py @@ -0,0 +1,79 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase + +from misago.acl import useracl +from misago.acl.test import patch_user_acl +from misago.conftest import get_cache_versions + +User = get_user_model() + +cache_versions = get_cache_versions() + + +def callable_acl_patch(user, user_acl): + user_acl["patched_for_user_id"] = user.id + + +class PatchingUserACLTests(TestCase): + @patch_user_acl({"is_patched": True}) + def test_decorator_patches_all_users_acls_in_test(self): + user = User.objects.create_user("User", "user@example.com") + user_acl = useracl.get_user_acl(user, cache_versions) + assert user_acl["is_patched"] + + def test_decorator_removes_patches_after_test(self): + user = User.objects.create_user("User", "user@example.com") + + @patch_user_acl({"is_patched": True}) + def test_function(patch_user_acl): + user_acl = useracl.get_user_acl(user, cache_versions) + assert user_acl["is_patched"] + + user_acl = useracl.get_user_acl(user, cache_versions) + assert "is_patched" not in user_acl + + def test_context_manager_patches_all_users_acls_in_test(self): + user = User.objects.create_user("User", "user@example.com") + with patch_user_acl({"can_rename_users": "patched"}): + user_acl = useracl.get_user_acl(user, cache_versions) + assert user_acl["can_rename_users"] == "patched" + + def test_context_manager_removes_patches_after_exit(self): + user = User.objects.create_user("User", "user@example.com") + + with patch_user_acl({"is_patched": True}): + user_acl = useracl.get_user_acl(user, cache_versions) + assert user_acl["is_patched"] + + user_acl = useracl.get_user_acl(user, cache_versions) + assert "is_patched" not in user_acl + + @patch_user_acl(callable_acl_patch) + def test_callable_patch_is_called_with_user_and_acl_by_decorator(self): + user = User.objects.create_user("User", "user@example.com") + user_acl = useracl.get_user_acl(user, cache_versions) + assert user_acl["patched_for_user_id"] == user.id + + def test_callable_patch_is_called_with_user_and_acl_by_context_manager(self): + user = User.objects.create_user("User", "user@example.com") + with patch_user_acl(callable_acl_patch): + user_acl = useracl.get_user_acl(user, cache_versions) + assert user_acl["patched_for_user_id"] == user.id + + @patch_user_acl({"acl_patch": 1}) + @patch_user_acl({"acl_patch": 2}) + def test_multiple_acl_patches_applied_by_decorator_stack(self): + user = User.objects.create_user("User", "user@example.com") + user_acl = useracl.get_user_acl(user, cache_versions) + assert user_acl["acl_patch"] == 2 + + def test_multiple_acl_patches_applied_by_context_manager_stack(self): + user = User.objects.create_user("User", "user@example.com") + with patch_user_acl({"acl_patch": 1}): + with patch_user_acl({"acl_patch": 2}): + user_acl = useracl.get_user_acl(user, cache_versions) + assert user_acl["acl_patch"] == 2 + user_acl = useracl.get_user_acl(user, cache_versions) + assert user_acl["acl_patch"] == 1 + user_acl = useracl.get_user_acl(user, cache_versions) + assert "acl_patch" not in user_acl \ No newline at end of file diff --git a/misago/acl/tests/test_providers.py b/misago/acl/tests/test_providers.py index 32d3e11923..9179b1ef6d 100644 --- a/misago/acl/tests/test_providers.py +++ b/misago/acl/tests/test_providers.py @@ -1,112 +1,85 @@ -from types import ModuleType - from django.test import TestCase from misago.acl.providers import PermissionProviders from misago.conf import settings -class TestType(object): - pass - - class PermissionProvidersTests(TestCase): - def test_initialization(self): - """providers manager is lazily initialized""" + def test_providers_are_not_loaded_on_container_init(self): providers = PermissionProviders() - self.assertTrue(providers._initialized is False) - self.assertTrue(not providers._providers) - self.assertTrue(not providers._providers_dict) - - # public api errors on non-loaded object - with self.assertRaises(AssertionError): - providers.get_obj_type_annotators(TestType()) - - with self.assertRaises(AssertionError): - providers.get_obj_type_serializers(TestType()) - - with self.assertRaises(AssertionError): - providers.list() - - self.assertTrue(providers._initialized is False) - self.assertTrue(not providers._providers) - self.assertTrue(not providers._providers_dict) + assert not providers._initialized + assert not providers._providers + assert not providers._annotators + assert not providers._user_acl_serializers - # load initializes providers + def test_container_loads_providers(self): providers = PermissionProviders() providers.load() - self.assertTrue(providers._initialized) - self.assertTrue(providers._providers) - self.assertTrue(providers._providers_dict) + assert providers._providers + assert providers._annotators + assert providers._user_acl_serializers - def test_list(self): - """providers manager list() returns iterable of tuples""" + def test_loading_providers_second_time_raises_runtime_error(self): providers = PermissionProviders() - - # providers.list() throws before loading providers - with self.assertRaises(AssertionError): - providers.list() - providers.load() - providers_list = providers.list() + with self.assertRaises(RuntimeError): + providers.load() + def test_container_returns_list_of_providers(self): + providers = PermissionProviders() + providers.load() + providers_setting = settings.MISAGO_ACL_EXTENSIONS - self.assertEqual(len(providers_list), len(providers_setting)) - - for extension, module in providers_list: - self.assertTrue(isinstance(extension, str)) - self.assertEqual(type(module), ModuleType) + self.assertEqual(len(providers.list()), len(providers_setting)) - def test_dict(self): - """providers manager dict() returns dict""" + def test_container_returns_dict_of_providers(self): providers = PermissionProviders() + providers.load() + + providers_setting = settings.MISAGO_ACL_EXTENSIONS + self.assertEqual(len(providers.dict()), len(providers_setting)) - # providers.dict() throws before loading providers + def test_accessing_providers_list_before_load_raises_assertion_error(self): + providers = PermissionProviders() + with self.assertRaises(AssertionError): + providers.list() + + def test_accessing_providers_dict_before_load_raises_assertion_error(self): + providers = PermissionProviders() with self.assertRaises(AssertionError): providers.dict() - providers.load() - - providers_dict = providers.dict() - - providers_setting = settings.MISAGO_ACL_EXTENSIONS - self.assertEqual(len(providers_dict), len(providers_setting)) + def test_getter_returns_registered_type_annotator(self): + class TestType(object): + pass - for extension, module in providers_dict.items(): - self.assertTrue(isinstance(extension, str)) - self.assertEqual(type(module), ModuleType) - def test_annotators(self): - """its possible to register and get annotators""" - def mock_annotator(*args): + def test_annotator(): pass + providers = PermissionProviders() - providers.acl_annotator(TestType, mock_annotator) + providers.acl_annotator(TestType, test_annotator) providers.load() - # providers.acl_annotator() throws after loading providers - with self.assertRaises(AssertionError): - providers.acl_annotator(TestType, mock_annotator) + assert test_annotator in providers.get_obj_type_annotators(TestType()) - annotators_list = providers.get_obj_type_annotators(TestType()) - self.assertEqual(annotators_list[0], mock_annotator) + def test_container_returns_list_of_user_acl_serializers(self): + providers = PermissionProviders() + providers.load() - def test_serializers(self): - """its possible to register and get annotators""" - def mock_serializer(*args): + assert providers.get_user_acl_serializers() + + def test_getter_returns_registered_user_acl_serializer(self): + def test_user_acl_serializer(): pass + providers = PermissionProviders() - providers.acl_serializer(TestType, mock_serializer) + providers.user_acl_serializer(test_user_acl_serializer) providers.load() - # providers.acl_serializer() throws after loading providers - with self.assertRaises(AssertionError): - providers.acl_serializer(TestType, mock_serializer) - - serializers_list = providers.get_obj_type_serializers(TestType()) - self.assertEqual(serializers_list[0], mock_serializer) + assert test_user_acl_serializer in providers.get_user_acl_serializers() diff --git a/misago/acl/tests/test_roleadmin_views.py b/misago/acl/tests/test_roleadmin_views.py index e4222676a9..11ed074929 100644 --- a/misago/acl/tests/test_roleadmin_views.py +++ b/misago/acl/tests/test_roleadmin_views.py @@ -1,7 +1,9 @@ from django.urls import reverse +from misago.acl import ACL_CACHE from misago.acl.models import Role from misago.acl.testutils import fake_post_data +from misago.cache.test import assert_invalidates_cache from misago.admin.testutils import AdminTestCase @@ -70,6 +72,25 @@ def test_edit_view(self): self.assertEqual(response.status_code, 200) self.assertContains(response, test_role.name) + def test_editing_role_invalidates_acl_cache(self): + self.client.post( + reverse('misago:admin:permissions:users:new'), data=fake_data({ + 'name': 'Test Role', + }) + ) + + test_role = Role.objects.get(name='Test Role') + + with assert_invalidates_cache(ACL_CACHE): + self.client.post( + reverse('misago:admin:permissions:users:edit', kwargs={ + 'pk': test_role.pk, + }), + data=fake_data({ + 'name': 'Top Lel', + }) + ) + def test_users_view(self): """users with this role view has no showstoppers""" response = self.client.post( @@ -106,3 +127,19 @@ def test_delete_view(self): self.client.get(reverse('misago:admin:permissions:users:index')) response = self.client.get(reverse('misago:admin:permissions:users:index')) self.assertNotContains(response, test_role.name) + + def test_deleting_role_invalidates_acl_cache(self): + self.client.post( + reverse('misago:admin:permissions:users:new'), data=fake_data({ + 'name': 'Test Role', + }) + ) + + test_role = Role.objects.get(name='Test Role') + + with assert_invalidates_cache(ACL_CACHE): + self.client.post( + reverse('misago:admin:permissions:users:delete', kwargs={ + 'pk': test_role.pk, + }) + ) \ No newline at end of file diff --git a/misago/acl/tests/test_serializing_user_acl.py b/misago/acl/tests/test_serializing_user_acl.py new file mode 100644 index 0000000000..b65cbd1d26 --- /dev/null +++ b/misago/acl/tests/test_serializing_user_acl.py @@ -0,0 +1,24 @@ +import json + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from misago.acl.useracl import get_user_acl, serialize_user_acl +from misago.conftest import get_cache_versions + +User = get_user_model() + +cache_versions = get_cache_versions() + + +class SerializingUserACLTests(TestCase): + def test_user_acl_is_serializeable(self): + user = User.objects.create_user('Bob', 'bob@bob.com') + acl = get_user_acl(user, cache_versions) + assert serialize_user_acl(acl) + + def test_user_acl_is_json_serializeable(self): + user = User.objects.create_user('Bob', 'bob@bob.com') + acl = get_user_acl(user, cache_versions) + serialized_acl = serialize_user_acl(acl) + assert json.dumps(serialized_acl) diff --git a/misago/acl/tests/test_testutils.py b/misago/acl/tests/test_testutils.py index a56d19735a..ab9b00f757 100644 --- a/misago/acl/tests/test_testutils.py +++ b/misago/acl/tests/test_testutils.py @@ -8,5 +8,4 @@ class FakeTestDataTests(TestCase): def test_fake_post_data_for_role(self): """fake data was created for Role""" test_data = fake_post_data(Role(), {'can_fly': 1}) - self.assertIn('can_fly', test_data) diff --git a/misago/acl/tests/test_user_acl_context_processor.py b/misago/acl/tests/test_user_acl_context_processor.py new file mode 100644 index 0000000000..f1148aa847 --- /dev/null +++ b/misago/acl/tests/test_user_acl_context_processor.py @@ -0,0 +1,12 @@ +from unittest.mock import Mock + +from django.test import TestCase + +from misago.acl.context_processors import user_acl + + +class ContextProcessorsTests(TestCase): + def test_context_processor_adds_request_user_acl_to_context(self): + test_acl = {"test": True} + context = user_acl(Mock(user_acl=test_acl)) + assert context == {"user_acl": test_acl} \ No newline at end of file diff --git a/misago/acl/tests/test_user_acl_middleware.py b/misago/acl/tests/test_user_acl_middleware.py new file mode 100644 index 0000000000..fd1323f64c --- /dev/null +++ b/misago/acl/tests/test_user_acl_middleware.py @@ -0,0 +1,30 @@ +from unittest.mock import Mock + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from misago.acl.middleware import user_acl_middleware +from misago.conftest import get_cache_versions + +User = get_user_model() + +cache_versions = get_cache_versions() + + +class MiddlewareTests(TestCase): + def test_middleware_sets_attr_on_request(self): + user = User.objects.create_user("User", "user@example.com") + + get_response = Mock() + request = Mock(user=user, cache_versions=cache_versions) + middleware = user_acl_middleware(get_response) + middleware(request) + assert request.user_acl + + def test_middleware_calls_get_response(self): + user = User.objects.create_user("User", "user@example.com") + get_response = Mock() + request = Mock(user=user, cache_versions=cache_versions) + middleware = user_acl_middleware(get_response) + middleware(request) + get_response.assert_called_once() diff --git a/misago/acl/testutils.py b/misago/acl/testutils.py index cce5157108..b66e9b8bbd 100644 --- a/misago/acl/testutils.py +++ b/misago/acl/testutils.py @@ -1,8 +1,3 @@ -from copy import deepcopy -from hashlib import md5 - -from misago.core import threadstore - from .forms import get_permissions_forms @@ -21,17 +16,3 @@ def fake_post_data(target, data_dict): else: data_dict[field.html_name] = field.value() return data_dict - - -def override_acl(user, new_acl): - """overrides user permissions with specified ones""" - final_cache = deepcopy(user.acl_cache) - final_cache.update(new_acl) - - if user.is_authenticated: - user._acl_cache = final_cache - user.acl_key = md5(str(user.pk).encode()).hexdigest()[:8] - user.save(update_fields=['acl_key']) - threadstore.set('acl_%s' % user.acl_key, final_cache) - else: - threadstore.set('acl_%s' % user.acl_key, final_cache) diff --git a/misago/acl/useracl.py b/misago/acl/useracl.py new file mode 100644 index 0000000000..53135ebfac --- /dev/null +++ b/misago/acl/useracl.py @@ -0,0 +1,30 @@ +import copy + +from . import buildacl +from .cache import get_acl_cache, set_acl_cache +from .providers import providers + + +def get_user_acl(user, cache_versions): + user_acl = get_acl_cache(user, cache_versions) + if user_acl is None: + user_acl = buildacl.build_acl(user.get_roles()) + set_acl_cache(user, cache_versions, user_acl) + user_acl["user_id"] = user.id + user_acl["is_authenticated"] = bool(user.is_authenticated) + user_acl["is_anonymous"] = bool(user.is_anonymous) + user_acl["is_staff"] = user.is_staff + user_acl["is_superuser"] = user.is_superuser + user_acl["cache_versions"] = cache_versions.copy() + return user_acl + + +def serialize_user_acl(user_acl): + """serialize authenticated user's ACL""" + serialized_acl = copy.deepcopy(user_acl) + serialized_acl.pop("cache_versions") + + for serializer in providers.get_user_acl_serializers(): + serializer(serialized_acl) + + return serialized_acl diff --git a/misago/acl/version.py b/misago/acl/version.py deleted file mode 100644 index cdda904c98..0000000000 --- a/misago/acl/version.py +++ /dev/null @@ -1,15 +0,0 @@ -from misago.core import cachebuster - -from .constants import ACL_CACHEBUSTER - - -def get_version(): - return cachebuster.get_version(ACL_CACHEBUSTER) - - -def is_valid(version): - return cachebuster.is_valid(ACL_CACHEBUSTER, version) - - -def invalidate(): - cachebuster.invalidate(ACL_CACHEBUSTER) diff --git a/misago/admin/views/generic/list.py b/misago/admin/views/generic/list.py index 16def88f7d..b1492be5ba 100644 --- a/misago/admin/views/generic/list.py +++ b/misago/admin/views/generic/list.py @@ -116,15 +116,15 @@ def dispatch(self, request, *args, **kwargs): # So address ball contains copy-friendly link refresh_querystring = True - SearchForm = self.get_search_form(request) - if SearchForm: - filtering_methods = self.get_filtering_methods(request) + search_form = self.get_search_form(request) + if search_form: + filtering_methods = self.get_filtering_methods(request, search_form) active_filters = self.get_filtering_method_to_use(filtering_methods) if request.GET.get('clear_filters'): # Clear filters from querystring request.session.pop(self.filters_session_key, None) active_filters = {} - self.apply_filtering_on_context(context, active_filters, SearchForm) + self.apply_filtering_on_context(context, active_filters, search_form) if (filtering_methods['GET'] and filtering_methods['GET'] != filtering_methods['session']): @@ -181,12 +181,23 @@ def get_search_form(self, request): def filters_session_key(self): return 'misago_admin_%s_filters' % self.root_link - def get_filters_from_GET(self, search_form, request): + def get_filtering_methods(self, request, search_form): + methods = { + 'GET': self.get_filters_from_GET(request, search_form), + 'session': self.get_filters_from_session(request, search_form), + } + + if request.GET.get('set_filters'): + methods['session'] = {} + + return methods + + def get_filters_from_GET(self, request, search_form): form = search_form(request.GET) form.is_valid() return self.clean_filtering_data(form.cleaned_data) - def get_filters_from_session(self, search_form, request): + def get_filters_from_session(self, request, search_form): session_filters = request.session.get(self.filters_session_key, {}) form = search_form(session_filters) form.is_valid() @@ -198,18 +209,6 @@ def clean_filtering_data(self, data): del data[key] return data - def get_filtering_methods(self, request): - SearchForm = self.get_search_form(request) - methods = { - 'GET': self.get_filters_from_GET(SearchForm, request), - 'session': self.get_filters_from_session(SearchForm, request), - } - - if request.GET.get('set_filters'): - methods['session'] = {} - - return methods - def get_filtering_method_to_use(self, methods): for method in ('GET', 'session'): if methods.get(method): diff --git a/misago/cache/__init__.py b/misago/cache/__init__.py new file mode 100644 index 0000000000..491c569199 --- /dev/null +++ b/misago/cache/__init__.py @@ -0,0 +1 @@ +default_app_config = 'misago.cache.apps.MisagoCacheConfig' \ No newline at end of file diff --git a/misago/cache/apps.py b/misago/cache/apps.py new file mode 100644 index 0000000000..0374fe03eb --- /dev/null +++ b/misago/cache/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class MisagoCacheConfig(AppConfig): + name = 'misago.cache' + label = 'misago_cache' + verbose_name = "Misago Cache" diff --git a/misago/cache/management/__init__.py b/misago/cache/management/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/misago/cache/management/commands/__init__.py b/misago/cache/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/misago/cache/management/commands/invalidateversionedcaches.py b/misago/cache/management/commands/invalidateversionedcaches.py new file mode 100644 index 0000000000..91825c12b3 --- /dev/null +++ b/misago/cache/management/commands/invalidateversionedcaches.py @@ -0,0 +1,11 @@ +from django.core.management.base import BaseCommand + +from misago.cache.versions import invalidate_all_caches + + +class Command(BaseCommand): + help = 'Invalidates versioned caches' + + def handle(self, *args, **options): + invalidate_all_caches() + self.stdout.write("Invalidated all versioned caches.") diff --git a/misago/cache/middleware.py b/misago/cache/middleware.py new file mode 100644 index 0000000000..579c399b17 --- /dev/null +++ b/misago/cache/middleware.py @@ -0,0 +1,10 @@ +from .versions import get_cache_versions + + +def cache_versions_middleware(get_response): + """Sets request.cache_versions attribute with dict of cache versions.""" + def middleware(request): + request.cache_versions = get_cache_versions() + return get_response(request) + + return middleware diff --git a/misago/cache/migrations/0001_initial.py b/misago/cache/migrations/0001_initial.py new file mode 100644 index 0000000000..006bd7f8be --- /dev/null +++ b/misago/cache/migrations/0001_initial.py @@ -0,0 +1,22 @@ +# Generated by Django 1.11.16 on 2018-11-25 15:15 +from django.db import migrations, models + +import misago.cache.utils + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CacheVersion', + fields=[ + ('cache', models.CharField(max_length=128, primary_key=True, serialize=False)), + ('version', models.CharField(default=misago.cache.utils.generate_version_string, max_length=8)), + ], + ), + ] diff --git a/misago/cache/migrations/__init__.py b/misago/cache/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/misago/cache/models.py b/misago/cache/models.py new file mode 100644 index 0000000000..31194b9806 --- /dev/null +++ b/misago/cache/models.py @@ -0,0 +1,8 @@ +from django.db import models + +from .utils import generate_version_string + + +class CacheVersion(models.Model): + cache = models.CharField(max_length=128, primary_key=True) + version = models.CharField(max_length=8, default=generate_version_string) diff --git a/misago/cache/operations.py b/misago/cache/operations.py new file mode 100644 index 0000000000..c68ae0651e --- /dev/null +++ b/misago/cache/operations.py @@ -0,0 +1,29 @@ +from django.db.migrations import RunPython + + +class StartCacheVersioning(RunPython): + def __init__(self, cache): + code = start_cache_versioning(cache) + reverse_code = stop_cache_versioning(cache) + super().__init__(code, reverse_code) + + +class StopCacheVersioning(RunPython): + def __init__(self, cache): + code = stop_cache_versioning(cache) + reverse_code = start_cache_versioning(cache) + super().__init__(code, reverse_code) + + +def start_cache_versioning(cache): + def migration_operation(apps, _): + CacheVersion = apps.get_model('misago_cache', 'CacheVersion') + CacheVersion.objects.create(cache=cache) + return migration_operation + + +def stop_cache_versioning(cache): + def migration_operation(apps, _): + CacheVersion = apps.get_model('misago_cache', 'CacheVersion') + CacheVersion.objects.filter(cache=cache).delete() + return migration_operation \ No newline at end of file diff --git a/misago/cache/test.py b/misago/cache/test.py new file mode 100644 index 0000000000..8e7189d169 --- /dev/null +++ b/misago/cache/test.py @@ -0,0 +1,21 @@ +from .versions import get_cache_versions_from_db + + +class assert_invalidates_cache: + def __init__(self, cache): + self.cache = cache + + def __enter__(self): + self.versions = get_cache_versions_from_db() + return self + + def __exit__(self, exc_type, *_): + if exc_type: + return False + + new_versions = get_cache_versions_from_db() + for cache, version in new_versions.items(): + if cache == self.cache: + message = "cache %s was not invalidated" % cache + assert self.versions[cache] != version, message + diff --git a/misago/cache/tests/__init__.py b/misago/cache/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/misago/cache/tests/conftest.py b/misago/cache/tests/conftest.py new file mode 100644 index 0000000000..ab098ec21d --- /dev/null +++ b/misago/cache/tests/conftest.py @@ -0,0 +1,5 @@ +from misago.cache.models import CacheVersion + + +def cache_version(): + return CacheVersion.objects.create(cache="test_cache") \ No newline at end of file diff --git a/misago/cache/tests/test_assert_invalidates_cache.py b/misago/cache/tests/test_assert_invalidates_cache.py new file mode 100644 index 0000000000..5f3d1eb030 --- /dev/null +++ b/misago/cache/tests/test_assert_invalidates_cache.py @@ -0,0 +1,25 @@ +from django.test import TestCase + +from misago.cache.models import CacheVersion +from misago.cache.test import assert_invalidates_cache +from misago.cache.versions import invalidate_cache + + +class AssertCacheVersionChangedTests(TestCase): + def test_assertion_fails_if_specified_cache_is_not_invaldiated(self): + CacheVersion.objects.create(cache="test") + with self.assertRaises(AssertionError): + with assert_invalidates_cache("test"): + pass + + def test_assertion_passess_if_specified_cache_is_invalidated(self): + CacheVersion.objects.create(cache="test") + with assert_invalidates_cache("test"): + invalidate_cache("test") + + def test_assertion_fails_if_other_cache_is_invalidated(self): + CacheVersion.objects.create(cache="test") + CacheVersion.objects.create(cache="changed_test") + with self.assertRaises(AssertionError): + with assert_invalidates_cache("test"): + invalidate_cache("changed_test") diff --git a/misago/cache/tests/test_cache_versions_middleware.py b/misago/cache/tests/test_cache_versions_middleware.py new file mode 100644 index 0000000000..7115e120a0 --- /dev/null +++ b/misago/cache/tests/test_cache_versions_middleware.py @@ -0,0 +1,22 @@ +from unittest.mock import Mock + +from django.test import TestCase + +from misago.cache.versions import CACHE_NAME +from misago.cache.middleware import cache_versions_middleware + + +class MiddlewareTests(TestCase): + def test_middleware_sets_attr_on_request(self): + get_response = Mock() + request = Mock() + middleware = cache_versions_middleware(get_response) + middleware(request) + assert request.cache_versions + + def test_middleware_calls_get_response(self): + get_response = Mock() + request = Mock() + middleware = cache_versions_middleware(get_response) + middleware(request) + get_response.assert_called_once() diff --git a/misago/cache/tests/test_getting_cache_versions.py b/misago/cache/tests/test_getting_cache_versions.py new file mode 100644 index 0000000000..6c9c9c236a --- /dev/null +++ b/misago/cache/tests/test_getting_cache_versions.py @@ -0,0 +1,45 @@ +from unittest.mock import patch + +from django.test import TestCase + +from misago.cache.versions import ( + CACHE_NAME, get_cache_versions, get_cache_versions_from_cache, get_cache_versions_from_db +) + + +class CacheVersionsTests(TestCase): + def test_db_getter_returns_cache_versions_from_db(self): + with self.assertNumQueries(1): + assert get_cache_versions_from_db() + + @patch('django.core.cache.cache.get', return_value=True) + def test_cache_getter_returns_cache_versions_from_cache(self, cache_get): + assert get_cache_versions_from_cache() is True + cache_get.assert_called_once_with(CACHE_NAME) + + @patch('django.core.cache.cache.get', return_value=True) + def test_getter_reads_from_cache(self, cache_get): + with self.assertNumQueries(0): + assert get_cache_versions() is True + cache_get.assert_called_once_with(CACHE_NAME) + + @patch('django.core.cache.cache.set') + @patch('django.core.cache.cache.get', return_value=None) + def test_getter_reads_from_db_when_cache_is_not_available(self, cache_get, _): + db_caches = get_cache_versions_from_db() + with self.assertNumQueries(1): + assert get_cache_versions() == db_caches + cache_get.assert_called_once_with(CACHE_NAME) + + @patch('django.core.cache.cache.set') + @patch('django.core.cache.cache.get', return_value=None) + def test_getter_sets_new_cache_if_no_cache_is_set(self, _, cache_set): + get_cache_versions() + db_caches = get_cache_versions_from_db() + cache_set.assert_called_once_with(CACHE_NAME, db_caches) + + @patch('django.core.cache.cache.set') + @patch('django.core.cache.cache.get', return_value=True) + def test_getter_is_not_setting_new_cache_if_cache_is_set(self, _, cache_set): + get_cache_versions() + cache_set.assert_not_called() \ No newline at end of file diff --git a/misago/cache/tests/test_invalidate_caches_management_command.py b/misago/cache/tests/test_invalidate_caches_management_command.py new file mode 100644 index 0000000000..d01363165a --- /dev/null +++ b/misago/cache/tests/test_invalidate_caches_management_command.py @@ -0,0 +1,11 @@ +from unittest.mock import Mock, patch + +from django.core.management import call_command +from django.test import TestCase + + +class InvalidateCachesManagementCommandTests(TestCase): + @patch("misago.cache.versions.invalidate_all_caches") + def test_management_command_invalidates_all_caches(self, invalidate_all_caches): + call_command('invalidateversionedcaches', stdout=Mock()) + invalidate_all_caches.assert_called_once() diff --git a/misago/cache/tests/test_invalidating_caches.py b/misago/cache/tests/test_invalidating_caches.py new file mode 100644 index 0000000000..78c60c522e --- /dev/null +++ b/misago/cache/tests/test_invalidating_caches.py @@ -0,0 +1,38 @@ +from unittest.mock import patch + +from django.test import TestCase + +from misago.cache.versions import ( + CACHE_NAME, get_cache_versions_from_db, invalidate_cache, invalidate_all_caches +) +from misago.cache.models import CacheVersion + +from .conftest import cache_version + + +class InvalidatingCacheTests(TestCase): + @patch('django.core.cache.cache.delete') + def test_invalidating_cache_updates_cache_version_in_database(self, _): + test_cache = cache_version() + invalidate_cache(test_cache.cache) + updated_test_cache = CacheVersion.objects.get(cache=test_cache.cache) + assert test_cache.version != updated_test_cache.version + + @patch('django.core.cache.cache.delete') + def test_invalidating_cache_deletes_versions_cache(self, cache_delete): + test_cache = cache_version() + invalidate_cache(test_cache.cache) + cache_delete.assert_called_once_with(CACHE_NAME) + + @patch('django.core.cache.cache.delete') + def test_invalidating_all_caches_updates_cache_version_in_database(self, _): + test_cache = cache_version() + invalidate_all_caches() + updated_test_cache = CacheVersion.objects.get(cache=test_cache.cache) + assert test_cache.version != updated_test_cache.version + + @patch('django.core.cache.cache.delete') + def test_invalidating_all_caches_deletes_versions_cache(self, cache_delete): + cache_version() + invalidate_all_caches() + cache_delete.assert_called_once_with(CACHE_NAME) diff --git a/misago/cache/utils.py b/misago/cache/utils.py new file mode 100644 index 0000000000..4d94dfcc69 --- /dev/null +++ b/misago/cache/utils.py @@ -0,0 +1,5 @@ +from django.utils.crypto import get_random_string + + +def generate_version_string(): + return get_random_string(8) diff --git a/misago/cache/versions.py b/misago/cache/versions.py new file mode 100644 index 0000000000..dfe5e90a92 --- /dev/null +++ b/misago/cache/versions.py @@ -0,0 +1,38 @@ +from django.core.cache import cache + +from .models import CacheVersion +from .utils import generate_version_string + +CACHE_NAME = "cache_versions" + + +def get_cache_versions(): + cache_versions = get_cache_versions_from_cache() + if cache_versions is None: + cache_versions = get_cache_versions_from_db() + cache.set(CACHE_NAME, cache_versions) + return cache_versions + + +def get_cache_versions_from_cache(): + return cache.get(CACHE_NAME) + + +def get_cache_versions_from_db(): + queryset = CacheVersion.objects.all() + return {i.cache: i.version for i in queryset} + + +def invalidate_cache(cache_name): + CacheVersion.objects.filter(cache=cache_name).update( + version=generate_version_string(), + ) + cache.delete(CACHE_NAME) + + +def invalidate_all_caches(): + for cache_name in get_cache_versions_from_db().keys(): + CacheVersion.objects.filter(cache=cache_name).update( + version=generate_version_string(), + ) + cache.delete(CACHE_NAME) diff --git a/misago/categories/api.py b/misago/categories/api.py index a01b3d6aec..b0ddf70c65 100644 --- a/misago/categories/api.py +++ b/misago/categories/api.py @@ -7,5 +7,5 @@ class CategoryViewSet(viewsets.ViewSet): def list(self, request): - categories_tree = get_categories_tree(request.user, join_posters=True) + categories_tree = get_categories_tree(request.user, request.user_acl, join_posters=True) return Response(CategorySerializer(categories_tree, many=True).data) diff --git a/misago/categories/management/commands/fixcategoriestree.py b/misago/categories/management/commands/fixcategoriestree.py index 8d9de51873..01740f7fc4 100644 --- a/misago/categories/management/commands/fixcategoriestree.py +++ b/misago/categories/management/commands/fixcategoriestree.py @@ -1,6 +1,6 @@ from django.core.management.base import BaseCommand -from misago.acl import version as acl_version +from misago.acl.cache import clear_acl_cache from misago.categories.models import Category @@ -19,5 +19,5 @@ def handle(self, *args, **options): self.stdout.write("Categories tree has been rebuild.") Category.objects.clear_cache() - acl_version.invalidate() + clear_acl_cache() self.stdout.write("Caches have been cleared.") diff --git a/misago/categories/models.py b/misago/categories/models.py index 990efda47d..df4822cf05 100644 --- a/misago/categories/models.py +++ b/misago/categories/models.py @@ -3,7 +3,7 @@ from django.db import models -from misago.acl import version as acl_version +from misago.acl.cache import clear_acl_cache from misago.acl.models import BaseRole from misago.conf import settings from misago.core.cache import cache @@ -115,7 +115,7 @@ def thread_type(self): def delete(self, *args, **kwargs): Category.objects.clear_cache() - acl_version.invalidate() + clear_acl_cache() return super().delete(*args, **kwargs) def synchronize(self): diff --git a/misago/categories/permissions.py b/misago/categories/permissions.py index 40ddc4885b..485227d167 100644 --- a/misago/categories/permissions.py +++ b/misago/categories/permissions.py @@ -85,14 +85,14 @@ def build_category_acl(acl, category, categories_roles, key_name): acl['browseable_categories'].append(category.pk) -def add_acl_to_category(user, target): - target.acl['can_see'] = can_see_category(user, target) - target.acl['can_browse'] = can_browse_category(user, target) +def add_acl_to_category(user_acl, target): + target.acl['can_see'] = can_see_category(user_acl, target) + target.acl['can_browse'] = can_browse_category(user_acl, target) -def serialize_categories_acls(serialized_acl): +def serialize_categories_acls(user_acl): categories_acl = [] - for category, acl in serialized_acl.pop('categories').items(): + for category, acl in user_acl.pop('categories').items(): if acl['can_browse']: categories_acl.append({ 'id': category, @@ -102,31 +102,29 @@ def serialize_categories_acls(serialized_acl): 'can_hide_threads': acl.get('can_hide_threads', 0), 'can_close_threads': acl.get('can_close_threads', False), }) - serialized_acl['categories'] = categories_acl + user_acl['categories'] = categories_acl def register_with(registry): registry.acl_annotator(Category, add_acl_to_category) + registry.user_acl_serializer(serialize_categories_acls) - registry.acl_serializer(get_user_model(), serialize_categories_acls) - registry.acl_serializer(AnonymousUser, serialize_categories_acls) - -def allow_see_category(user, target): +def allow_see_category(user_acl, target): try: category_id = target.pk except AttributeError: category_id = int(target) - if not category_id in user.acl_cache['visible_categories']: + if not category_id in user_acl['visible_categories']: raise Http404() can_see_category = return_boolean(allow_see_category) -def allow_browse_category(user, target): - target_acl = user.acl_cache['categories'].get(target.id, {'can_browse': False}) +def allow_browse_category(user_acl, target): + target_acl = user_acl['categories'].get(target.id, {'can_browse': False}) if not target_acl['can_browse']: message = _('You don\'t have permission to browse "%(category)s" contents.') raise PermissionDenied(message % {'category': target.name}) diff --git a/misago/categories/tests/test_categories_admin_views.py b/misago/categories/tests/test_categories_admin_views.py index e334e61107..924413b7a2 100644 --- a/misago/categories/tests/test_categories_admin_views.py +++ b/misago/categories/tests/test_categories_admin_views.py @@ -1,6 +1,8 @@ from django.urls import reverse +from misago.acl import ACL_CACHE from misago.admin.testutils import AdminTestCase +from misago.cache.test import assert_invalidates_cache from misago.categories.models import Category from misago.threads import testutils from misago.threads.models import Thread @@ -140,6 +142,21 @@ def test_new_view(self): response = self.client.get(reverse('misago:admin:categories:nodes:index')) self.assertContains(response, 'Test Subcategory') + def test_creating_new_category_invalidates_acl_cache(self): + root = Category.objects.root_category() + + with assert_invalidates_cache(ACL_CACHE): + self.client.post( + reverse('misago:admin:categories:nodes:new'), + data={ + 'name': 'Test Category', + 'description': 'Lorem ipsum dolor met', + 'new_parent': root.pk, + 'prune_started_after': 0, + 'prune_replied_after': 0, + }, + ) + def test_edit_view(self): """edit category view has no showstoppers""" private_threads = Category.objects.private_threads() @@ -228,6 +245,35 @@ def test_edit_view(self): response = self.client.get(reverse('misago:admin:categories:nodes:index')) self.assertContains(response, 'Test Category Edited') + def test_editing_category_invalidates_acl_cache(self): + root = Category.objects.root_category() + self.client.post( + reverse('misago:admin:categories:nodes:new'), + data={ + 'name': 'Test Category', + 'description': 'Lorem ipsum dolor met', + 'new_parent': root.pk, + 'prune_started_after': 0, + 'prune_replied_after': 0, + }, + ) + + test_category = Category.objects.get(slug='test-category') + + with assert_invalidates_cache(ACL_CACHE): + self.client.post( + reverse('misago:admin:categories:nodes:edit', kwargs={ + 'pk': test_category.pk, + }), + data={ + 'name': 'Test Category Edited', + 'new_parent': root.pk, + 'role': 'category', + 'prune_started_after': 0, + 'prune_replied_after': 0, + }, + ) + def test_move_views(self): """move up/down views have no showstoppers""" root = Category.objects.root_category() @@ -522,3 +568,15 @@ def test_delete_leaf_category_and_contents(self): (self.category_e, 1, 10, 13), (self.category_f, 2, 11, 12), ]) + + def test_deleting_category_invalidates_acl_cache(self): + with assert_invalidates_cache(ACL_CACHE): + self.client.post( + reverse('misago:admin:categories:nodes:delete', kwargs={ + 'pk': self.category_d.pk, + }), + data={ + 'move_children_to': '', + 'move_threads_to': '', + } + ) diff --git a/misago/categories/tests/test_category_model.py b/misago/categories/tests/test_category_model.py index d4c9984511..2ee40dd371 100644 --- a/misago/categories/tests/test_category_model.py +++ b/misago/categories/tests/test_category_model.py @@ -1,11 +1,12 @@ +from django.test import TestCase + from misago.categories import THREADS_ROOT_NAME from misago.categories.models import Category -from misago.core.testutils import MisagoTestCase from misago.threads import testutils from misago.threads.threadtypes import trees_map -class CategoryManagerTests(MisagoTestCase): +class CategoryManagerTests(TestCase): def test_private_threads(self): """private_threads returns private threads category""" category = Category.objects.private_threads() @@ -45,7 +46,7 @@ def test_get_categories_dict_from_db(self): self.assertNotIn(category.id, test_dict) -class CategoryModelTests(MisagoTestCase): +class CategoryModelTests(TestCase): def setUp(self): super().setUp() diff --git a/misago/categories/tests/test_fixcategoriestree.py b/misago/categories/tests/test_fixcategoriestree.py index 53f9bb2676..1217781e4d 100644 --- a/misago/categories/tests/test_fixcategoriestree.py +++ b/misago/categories/tests/test_fixcategoriestree.py @@ -3,6 +3,8 @@ from django.core.management import call_command from django.test import TestCase +from misago.acl import ACL_CACHE +from misago.cache.test import assert_invalidates_cache from misago.categories.management.commands import fixcategoriestree from misago.categories.models import Category @@ -82,3 +84,8 @@ def test_fix_categories_tree_affected(self): (self.test_category, 1, 2, 3), (self.first_category, 1, 4, 5), ]) + + def test_fixing_categories_tree_invalidates_acl_cache(self): + with assert_invalidates_cache(ACL_CACHE): + run_command() + diff --git a/misago/categories/tests/test_permissions_admin_views.py b/misago/categories/tests/test_permissions_admin_views.py index 7968c2523e..c7ef6cf391 100644 --- a/misago/categories/tests/test_permissions_admin_views.py +++ b/misago/categories/tests/test_permissions_admin_views.py @@ -1,8 +1,10 @@ from django.urls import reverse +from misago.acl import ACL_CACHE from misago.acl.models import Role from misago.acl.testutils import fake_post_data from misago.admin.testutils import AdminTestCase +from misago.cache.test import assert_invalidates_cache from misago.categories.models import Category, CategoryRole @@ -72,6 +74,26 @@ def test_edit_view(self): response = self.client.get(reverse('misago:admin:permissions:categories:index')) self.assertContains(response, test_role.name) + def test_editing_role_invalidates_acl_cache(self): + self.client.post( + reverse('misago:admin:permissions:categories:new'), + data=fake_data({ + 'name': 'Test CategoryRole', + }), + ) + + test_role = CategoryRole.objects.get(name='Test CategoryRole') + + with assert_invalidates_cache(ACL_CACHE): + self.client.post( + reverse('misago:admin:permissions:categories:edit', kwargs={ + 'pk': test_role.pk, + }), + data=fake_data({ + 'name': 'Top Lel', + }), + ) + def test_delete_view(self): """delete role view has no showstoppers""" self.client.post( @@ -93,6 +115,23 @@ def test_delete_view(self): response = self.client.get(reverse('misago:admin:permissions:categories:index')) self.assertNotContains(response, test_role.name) + def test_deleting_role_invalidates_acl_cache(self): + self.client.post( + reverse('misago:admin:permissions:categories:new'), + data=fake_data({ + 'name': 'Test CategoryRole', + }), + ) + + test_role = CategoryRole.objects.get(name='Test CategoryRole') + + with assert_invalidates_cache(ACL_CACHE): + self.client.post( + reverse('misago:admin:permissions:categories:delete', kwargs={ + 'pk': test_role.pk, + }) + ) + def test_change_category_roles_view(self): """change category roles perms view works""" root = Category.objects.root_category() @@ -186,6 +225,20 @@ def test_change_category_roles_view(self): self.assertEqual( category_role_set.get(role=test_role_b).category_role_id, role_comments.pk ) + + # Check that ACL was invalidated + with assert_invalidates_cache(ACL_CACHE): + self.client.post( + reverse( + 'misago:admin:categories:nodes:permissions', kwargs={ + 'pk': test_category.pk, + } + ), + data={ + ('%s-category_role' % test_role_a.pk): role_full.pk, + ('%s-category_role' % test_role_b.pk): role_comments.pk, + }, + ) def test_change_role_categories_permissions_view(self): """change role categories perms view works""" @@ -323,3 +376,17 @@ def test_change_role_categories_permissions_view(self): ) self.assertEqual(categories_acls.get(category=category_c).category_role_id, role_full.pk) self.assertEqual(categories_acls.get(category=category_d).category_role_id, role_full.pk) + + # Check that ACL was invalidated + with assert_invalidates_cache(ACL_CACHE): + self.client.post( + reverse('misago:admin:permissions:users:categories', kwargs={ + 'pk': test_role.pk, + }), + data={ + ('%s-role' % category_a.pk): role_comments.pk, + ('%s-role' % category_b.pk): role_comments.pk, + ('%s-role' % category_c.pk): role_full.pk, + ('%s-role' % category_d.pk): role_full.pk, + }, + ) \ No newline at end of file diff --git a/misago/categories/tests/test_utils.py b/misago/categories/tests/test_utils.py index e0f4b98c22..6dc103be4b 100644 --- a/misago/categories/tests/test_utils.py +++ b/misago/categories/tests/test_utils.py @@ -1,9 +1,21 @@ -from misago.acl.testutils import override_acl +from misago.acl.useracl import get_user_acl from misago.categories.models import Category from misago.categories.utils import get_categories_tree, get_category_path -from misago.core import threadstore +from misago.conftest import get_cache_versions from misago.users.testutils import AuthenticatedUserTestCase +cache_versions = get_cache_versions() + + +def get_patched_user_acl(user): + user_acl = get_user_acl(user, cache_versions) + categories_acl = {'categories': {}, 'visible_categories': []} + for category in Category.objects.all_categories(): + categories_acl['visible_categories'].append(category.id) + categories_acl['categories'][category.id] = {'can_see': 1, 'can_browse': 1} + user_acl.update(categories_acl) + return user_acl + class CategoriesUtilsTests(AuthenticatedUserTestCase): def setUp(self): @@ -20,9 +32,7 @@ def setUp(self): Category E + Subcategory F """ - super().setUp() - threadstore.clear() self.root = Category.objects.root_category() self.first_category = Category.objects.get(slug='first-category') @@ -84,15 +94,11 @@ def setUp(self): save=True, ) - categories_acl = {'categories': {}, 'visible_categories': []} - for category in Category.objects.all_categories(): - categories_acl['visible_categories'].append(category.pk) - categories_acl['categories'][category.pk] = {'can_see': 1, 'can_browse': 1} - override_acl(self.user, categories_acl) + self.user_acl = get_patched_user_acl(self.user) def test_root_categories_tree_no_parent(self): """get_categories_tree returns all children of root nodes""" - categories_tree = get_categories_tree(self.user) + categories_tree = get_categories_tree(self.user, self.user_acl) self.assertEqual(len(categories_tree), 3) self.assertEqual(categories_tree[0], Category.objects.get(slug='first-category')) @@ -101,19 +107,19 @@ def test_root_categories_tree_no_parent(self): def test_root_categories_tree_with_parent(self): """get_categories_tree returns all children of given node""" - categories_tree = get_categories_tree(self.user, self.category_a) + categories_tree = get_categories_tree(self.user, self.user_acl, self.category_a) self.assertEqual(len(categories_tree), 1) self.assertEqual(categories_tree[0], Category.objects.get(slug='category-b')) def test_root_categories_tree_with_leaf(self): """get_categories_tree returns all children of given node""" categories_tree = get_categories_tree( - self.user, Category.objects.get(slug='subcategory-f') + self.user, self.user_acl, Category.objects.get(slug='subcategory-f') ) self.assertEqual(len(categories_tree), 0) def test_get_category_path(self): """get_categories_tree returns all children of root nodes""" - for node in get_categories_tree(self.user): + for node in get_categories_tree(self.user, self.user_acl): parent_nodes = len(get_category_path(node)) self.assertEqual(parent_nodes, node.level) diff --git a/misago/categories/tests/test_views.py b/misago/categories/tests/test_views.py index 5d920df764..60c279f761 100644 --- a/misago/categories/tests/test_views.py +++ b/misago/categories/tests/test_views.py @@ -1,53 +1,47 @@ +import json + from django.urls import reverse -from misago.acl.testutils import override_acl +from misago.acl.test import patch_user_acl from misago.categories.models import Category -from misago.categories.utils import get_categories_tree from misago.users.testutils import AuthenticatedUserTestCase class CategoryViewsTests(AuthenticatedUserTestCase): + def setUp(self): + super().setUp() + + self.category = Category.objects.get(slug='first-category') + def test_index_renders(self): """categories list renders for authenticated""" response = self.client.get(reverse('misago:categories')) - - for node in get_categories_tree(self.user): - self.assertContains(response, node.name) - if node.level > 1: - self.assertContains(response, node.get_absolute_url()) + self.assertContains(response, self.category.name) + self.assertContains(response, self.category.get_absolute_url()) def test_index_renders_for_guest(self): """categories list renders for guest""" self.logout_user() response = self.client.get(reverse('misago:categories')) + self.assertContains(response, self.category.name) + self.assertContains(response, self.category.get_absolute_url()) - for node in get_categories_tree(self.user): - self.assertContains(response, node.name) - if node.level > 1: - self.assertContains(response, node.get_absolute_url()) - + @patch_user_acl({'visible_categories': []}) def test_index_no_perms_renders(self): """categories list renders no visible categories for authenticated""" - override_acl(self.user, {'visible_categories': []}) response = self.client.get(reverse('misago:categories')) + self.assertNotContains(response, self.category.name) + self.assertNotContains(response, self.category.get_absolute_url()) - for node in get_categories_tree(self.user): - self.assertNotIn(node.name, response.content) - if node.level > 1: - self.assertNotContains(response, node.get_absolute_url()) - + @patch_user_acl({'visible_categories': []}) def test_index_no_perms_renders_for_guest(self): """categories list renders no visible categories for guest""" self.logout_user() - override_acl(self.user, {'visible_categories': []}) response = self.client.get(reverse('misago:categories')) - - for node in get_categories_tree(self.user): - self.assertNotIn(node.name, response.content) - if node.level > 1: - self.assertNotContains(response, node.get_absolute_url()) + self.assertNotContains(response, self.category.name) + self.assertNotContains(response, self.category.get_absolute_url()) class CategoryAPIViewsTests(AuthenticatedUserTestCase): @@ -59,41 +53,27 @@ def setUp(self): def test_list_renders(self): """api returns categories for authenticated""" response = self.client.get(reverse('misago:api:category-list')) - - for node in get_categories_tree(self.user): - self.assertContains(response, node.name) - if node.level > 1: - self.assertNotContains(response, node.get_absolute_url()) + self.assertContains(response, self.category.name) + self.assertContains(response, self.category.get_absolute_url()) def test_list_renders_for_guest(self): """api returns categories for guest""" self.logout_user() response = self.client.get(reverse('misago:api:category-list')) + self.assertContains(response, self.category.name) + self.assertContains(response, self.category.get_absolute_url()) - for node in get_categories_tree(self.user): - self.assertContains(response, node.name) - if node.level > 1: - self.assertNotContains(response, node.get_absolute_url()) - + @patch_user_acl({'visible_categories': []}) def test_list_no_perms_renders(self): """api returns no categories for authenticated""" - override_acl(self.user, {'visible_categories': []}) response = self.client.get(reverse('misago:api:category-list')) + assert json.loads(response.content) == [] - for node in get_categories_tree(self.user): - self.assertNotIn(node.name, response.content) - if node.level > 1: - self.assertNotContains(response, node.get_absolute_url()) - + @patch_user_acl({'visible_categories': []}) def test_list_no_perms_renders_for_guest(self): """api returns no categories for guest""" self.logout_user() - override_acl(self.user, {'visible_categories': []}) response = self.client.get(reverse('misago:api:category-list')) - - for node in get_categories_tree(self.user): - self.assertNotContains(response, node.name) - if node.level > 1: - self.assertNotContains(response, node.get_absolute_url()) + assert json.loads(response.content) == [] diff --git a/misago/categories/utils.py b/misago/categories/utils.py index bae3ecfe9b..1ad859237b 100644 --- a/misago/categories/utils.py +++ b/misago/categories/utils.py @@ -1,11 +1,11 @@ -from misago.acl import add_acl +from misago.acl.objectacl import add_acl_to_obj from misago.readtracker import categoriestracker from .models import Category -def get_categories_tree(user, parent=None, join_posters=False): - if not user.acl_cache['visible_categories']: +def get_categories_tree(user, user_acl, parent=None, join_posters=False): + if not user_acl['visible_categories']: return [] if parent: @@ -13,7 +13,7 @@ def get_categories_tree(user, parent=None, join_posters=False): else: queryset = Category.objects.all_categories() - queryset_with_acl = queryset.filter(id__in=user.acl_cache['visible_categories']) + queryset_with_acl = queryset.filter(id__in=user_acl['visible_categories']) if join_posters: queryset_with_acl = queryset_with_acl.select_related('last_poster') @@ -32,8 +32,8 @@ def get_categories_tree(user, parent=None, join_posters=False): if category.parent_id and category.level > parent_level: categories_dict[category.parent_id].subcategories.append(category) - add_acl(user, categories_list) - categoriestracker.make_read_aware(user, categories_list) + add_acl_to_obj(user_acl, categories_list) + categoriestracker.make_read_aware(user, user_acl, categories_list) for category in reversed(visible_categories): if category.acl['can_browse']: diff --git a/misago/categories/views/categoriesadmin.py b/misago/categories/views/categoriesadmin.py index 7b687872f4..568cbeec7f 100644 --- a/misago/categories/views/categoriesadmin.py +++ b/misago/categories/views/categoriesadmin.py @@ -2,7 +2,7 @@ from django.shortcuts import redirect from django.utils.translation import gettext_lazy as _ -from misago.acl import version as acl_version +from misago.acl.cache import clear_acl_cache from misago.admin.views import generic from misago.categories import THREADS_ROOT_NAME from misago.categories.forms import CategoryFormFactory, DeleteFormFactory @@ -88,7 +88,7 @@ def handle_form(self, form, request, target): if copied_acls: RoleCategoryACL.objects.bulk_create(copied_acls) - acl_version.invalidate() + clear_acl_cache() messages.success(request, self.message_submit % {'name': target.name}) diff --git a/misago/categories/views/categorieslist.py b/misago/categories/views/categorieslist.py index 19012111f1..7603f094ca 100644 --- a/misago/categories/views/categorieslist.py +++ b/misago/categories/views/categorieslist.py @@ -6,7 +6,7 @@ def categories(request): - categories_tree = get_categories_tree(request.user, join_posters=True) + categories_tree = get_categories_tree(request.user, request.user_acl, join_posters=True) request.frontend_context.update({ 'CATEGORIES': CategorySerializer(categories_tree, many=True).data, diff --git a/misago/categories/views/permsadmin.py b/misago/categories/views/permsadmin.py index 9aa8b5d8ca..5d825be0f8 100644 --- a/misago/categories/views/permsadmin.py +++ b/misago/categories/views/permsadmin.py @@ -2,7 +2,7 @@ from django.shortcuts import redirect from django.utils.translation import gettext_lazy as _ -from misago.acl import version as acl_version +from misago.acl.cache import clear_acl_cache from misago.acl.forms import get_permissions_forms from misago.acl.models import Role from misago.acl.views import RoleAdmin, RolesList @@ -128,7 +128,7 @@ def real_dispatch(self, request, target): if new_permissions: RoleCategoryACL.objects.bulk_create(new_permissions) - acl_version.invalidate() + clear_acl_cache() message = _("Category %(name)s permissions have been changed.") messages.success(request, message % {'name': target.name}) @@ -196,7 +196,7 @@ def real_dispatch(self, request, target): if new_permissions: RoleCategoryACL.objects.bulk_create(new_permissions) - acl_version.invalidate() + clear_acl_cache() message = _("Category permissions for role %(name)s have been changed.") messages.success(request, message % {'name': target.name}) diff --git a/misago/conf/__init__.py b/misago/conf/__init__.py index 539698c224..df5d5af5a6 100644 --- a/misago/conf/__init__.py +++ b/misago/conf/__init__.py @@ -1,3 +1,7 @@ -from .gateway import settings, db_settings # noqa +from .staticsettings import StaticSettings default_app_config = 'misago.conf.apps.MisagoConfConfig' + +SETTINGS_CACHE = "settings" + +settings = StaticSettings() diff --git a/misago/conf/cache.py b/misago/conf/cache.py new file mode 100644 index 0000000000..ea7f3359ad --- /dev/null +++ b/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) diff --git a/misago/conf/context_processors.py b/misago/conf/context_processors.py index 440aa004cf..a14b795295 100644 --- a/misago/conf/context_processors.py +++ b/misago/conf/context_processors.py @@ -4,46 +4,44 @@ from misago.users.social.utils import get_enabled_social_auth_sites_list -from .gateway import settings as misago_settings # noqa -from .gateway import db_settings +from . import settings +BLANK_AVATAR_URL = static(settings.MISAGO_BLANK_AVATAR) -BLANK_AVATAR_URL = static(misago_settings.MISAGO_BLANK_AVATAR) - -def settings(request): +def conf(request): return { - 'DEBUG': misago_settings.DEBUG, - 'LANGUAGE_CODE_SHORT': get_language()[:2], - 'misago_settings': db_settings, 'BLANK_AVATAR_URL': BLANK_AVATAR_URL, - 'THREADS_ON_INDEX': misago_settings.MISAGO_THREADS_ON_INDEX, - 'LOGIN_REDIRECT_URL': misago_settings.LOGIN_REDIRECT_URL, - 'LOGIN_URL': misago_settings.LOGIN_URL, - 'LOGOUT_URL': misago_settings.LOGOUT_URL, + 'DEBUG': settings.DEBUG, + 'LANGUAGE_CODE_SHORT': get_language()[:2], + 'LOGIN_REDIRECT_URL': settings.LOGIN_REDIRECT_URL, + 'LOGIN_URL': settings.LOGIN_URL, + 'LOGOUT_URL': settings.LOGOUT_URL, + 'THREADS_ON_INDEX': settings.MISAGO_THREADS_ON_INDEX, + 'settings': request.settings, } def preload_settings_json(request): - preloaded_settings = db_settings.get_public_settings() + preloaded_settings = request.settings.get_public_settings() preloaded_settings.update({ - 'LOGIN_API_URL': misago_settings.MISAGO_LOGIN_API_URL, - 'LOGIN_REDIRECT_URL': reverse(misago_settings.LOGIN_REDIRECT_URL), - 'LOGIN_URL': reverse(misago_settings.LOGIN_URL), - 'LOGOUT_URL': reverse(misago_settings.LOGOUT_URL), + 'LOGIN_API_URL': settings.MISAGO_LOGIN_API_URL, + 'LOGIN_REDIRECT_URL': reverse(settings.LOGIN_REDIRECT_URL), + 'LOGIN_URL': reverse(settings.LOGIN_URL), + 'LOGOUT_URL': reverse(settings.LOGOUT_URL), 'SOCIAL_AUTH': get_enabled_social_auth_sites_list(), }) request.frontend_context.update({ - 'SETTINGS': preloaded_settings, - 'MISAGO_PATH': reverse('misago:index'), 'BLANK_AVATAR_URL': BLANK_AVATAR_URL, - 'ENABLE_DOWNLOAD_OWN_DATA': misago_settings.MISAGO_ENABLE_DOWNLOAD_OWN_DATA, - 'ENABLE_DELETE_OWN_ACCOUNT': misago_settings.MISAGO_ENABLE_DELETE_OWN_ACCOUNT, - 'STATIC_URL': misago_settings.STATIC_URL, - 'CSRF_COOKIE_NAME': misago_settings.CSRF_COOKIE_NAME, - 'THREADS_ON_INDEX': misago_settings.MISAGO_THREADS_ON_INDEX, + 'CSRF_COOKIE_NAME': settings.CSRF_COOKIE_NAME, + 'ENABLE_DELETE_OWN_ACCOUNT': settings.MISAGO_ENABLE_DELETE_OWN_ACCOUNT, + 'ENABLE_DOWNLOAD_OWN_DATA': settings.MISAGO_ENABLE_DOWNLOAD_OWN_DATA, + 'MISAGO_PATH': reverse('misago:index'), + 'SETTINGS': preloaded_settings, + 'STATIC_URL': settings.STATIC_URL, + 'THREADS_ON_INDEX': settings.MISAGO_THREADS_ON_INDEX, }) return {} diff --git a/misago/conf/dbsettings.py b/misago/conf/dbsettings.py deleted file mode 100644 index e8c73dbdfe..0000000000 --- a/misago/conf/dbsettings.py +++ /dev/null @@ -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() diff --git a/misago/conf/dynamicsettings.py b/misago/conf/dynamicsettings.py new file mode 100644 index 0000000000..9ba6f5fe31 --- /dev/null +++ b/misago/conf/dynamicsettings.py @@ -0,0 +1,63 @@ +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_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 diff --git a/misago/conf/forms.py b/misago/conf/forms.py index c7caeb5763..f0bf0d0a31 100644 --- a/misago/conf/forms.py +++ b/misago/conf/forms.py @@ -4,8 +4,7 @@ from misago.admin.forms import YesNoSwitch - -__ALL__ = ['ChangeSettingsForm'] +__all__ = ['ChangeSettingsForm'] class ValidateChoicesNum(object): diff --git a/misago/conf/gateway.py b/misago/conf/gateway.py deleted file mode 100644 index 3413f24860..0000000000 --- a/misago/conf/gateway.py +++ /dev/null @@ -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() diff --git a/misago/conf/middleware.py b/misago/conf/middleware.py new file mode 100644 index 0000000000..b5c18ec98c --- /dev/null +++ b/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 diff --git a/misago/conf/migrations/0002_cache_version.py b/misago/conf/migrations/0002_cache_version.py new file mode 100644 index 0000000000..16fcd6a2c4 --- /dev/null +++ b/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) + ] diff --git a/misago/conf/migrationutils.py b/misago/conf/migrationutils.py index bdb00c3019..a3b43af2d7 100644 --- a/misago/conf/migrationutils.py +++ b/misago/conf/migrationutils.py @@ -1,6 +1,3 @@ -from misago.core.cache import cache as default_cache - -from .dbsettings import CACHE_KEY from .hydrators import dehydrate_value from .utils import get_setting_value, has_custom_value @@ -91,7 +88,3 @@ def migrate_setting(Setting, group, setting_fixture, order, old_value): setting.field_extra = field_extra or {} setting.save() - - -def delete_settings_cache(): - default_cache.delete(CACHE_KEY) diff --git a/misago/conf/staticsettings.py b/misago/conf/staticsettings.py new file mode 100644 index 0000000000..05693bef26 --- /dev/null +++ b/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) diff --git a/misago/conf/test.py b/misago/conf/test.py new file mode 100644 index 0000000000..b38ab01f64 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/misago/conf/tests/test_context_processors.py b/misago/conf/tests/test_context_processors.py index 511b579f98..7b0a4f7b5d 100644 --- a/misago/conf/tests/test_context_processors.py +++ b/misago/conf/tests/test_context_processors.py @@ -1,27 +1,21 @@ -from django.test import TestCase +from unittest.mock import Mock -from misago.conf.context_processors import settings -from misago.conf.dbsettings import db_settings -from misago.core import threadstore +from django.test import TestCase +from misago.conftest import get_cache_versions -class MockRequest(object): - pass +from misago.conf.context_processors import conf +from misago.conf.dynamicsettings import DynamicSettings class ContextProcessorsTests(TestCase): - def tearDown(self): - threadstore.clear() - - def test_db_settings(self): - """DBSettings are exposed to templates""" - mock_request = MockRequest() - processor_settings = settings(mock_request)['misago_settings'], - - self.assertEqual(id(processor_settings[0]), id(db_settings)) + def test_request_settings_are_included_in_template_context(self): + cache_versions = get_cache_versions() + mock_request = Mock(settings=DynamicSettings(cache_versions)) + context_settings = conf(mock_request)['settings'] + assert context_settings == mock_request.settings - def test_preload_settings(self): - """site configuration is preloaded by middleware""" + def test_settings_are_included_in_frontend_context(self): response = self.client.get('/') self.assertEqual(response.status_code, 200) self.assertContains(response, '"SETTINGS": {"') diff --git a/misago/conf/tests/test_dynamic_settings_middleware.py b/misago/conf/tests/test_dynamic_settings_middleware.py new file mode 100644 index 0000000000..198aaf0625 --- /dev/null +++ b/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() \ No newline at end of file diff --git a/misago/conf/tests/test_getting_dynamic_settings_values.py b/misago/conf/tests/test_getting_dynamic_settings_values.py new file mode 100644 index 0000000000..fa62939ed4 --- /dev/null +++ b/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 \ No newline at end of file diff --git a/misago/conf/tests/test_getting_static_settings_values.py b/misago/conf/tests/test_getting_static_settings_values.py new file mode 100644 index 0000000000..e1c1fdf35d --- /dev/null +++ b/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 \ No newline at end of file diff --git a/misago/conf/tests/test_migrationutils.py b/misago/conf/tests/test_migrationutils.py index 88466796df..104d93008a 100644 --- a/misago/conf/tests/test_migrationutils.py +++ b/misago/conf/tests/test_migrationutils.py @@ -3,7 +3,6 @@ from misago.conf import migrationutils from misago.conf.models import SettingsGroup -from misago.core import threadstore class DBConfMigrationUtilsTests(TestCase): @@ -36,9 +35,6 @@ def setUp(self): migrationutils.migrate_settings_group(apps, self.test_group) self.groups_count = SettingsGroup.objects.count() - def tearDown(self): - threadstore.clear() - def test_get_custom_group_and_settings(self): """tests setup created settings group""" custom_group = migrationutils.get_group( diff --git a/misago/conf/tests/test_overridding_dynamic_settings.py b/misago/conf/tests/test_overridding_dynamic_settings.py new file mode 100644 index 0000000000..d618d7bc96 --- /dev/null +++ b/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" diff --git a/misago/conf/tests/test_settings.py b/misago/conf/tests/test_settings.py deleted file mode 100644 index 944d5d3958..0000000000 --- a/misago/conf/tests/test_settings.py +++ /dev/null @@ -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') diff --git a/misago/conf/views.py b/misago/conf/views.py index ca43ee46c9..ba41753436 100644 --- a/misago/conf/views.py +++ b/misago/conf/views.py @@ -4,7 +4,7 @@ from misago.admin.views import render as mi_render -from . import db_settings +from .cache import clear_settings_cache from .forms import ChangeSettingsForm from .models import SettingsGroup @@ -44,7 +44,7 @@ def group(request, key): setting.value = new_values[setting.setting] setting.save(update_fields=['dry_value']) - db_settings.flush_cache() + clear_settings_cache() messages.success(request, _("Changes in settings have been saved!")) return redirect('misago:admin:system:settings:group', key=key) diff --git a/misago/conftest.py b/misago/conftest.py new file mode 100644 index 0000000000..b50a8fe1c6 --- /dev/null +++ b/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", + } \ No newline at end of file diff --git a/misago/core/cachebuster.py b/misago/core/cachebuster.py deleted file mode 100644 index 1148895f6b..0000000000 --- a/misago/core/cachebuster.py +++ /dev/null @@ -1,104 +0,0 @@ -from django.db.models import F - -from . import threadstore - - -CACHE_KEY = 'misago_cachebuster' - - -class CacheBusterController(object): - def register_cache(self, cache): - from .models import CacheVersion - CacheVersion.objects.create(cache=cache) - - def unregister_cache(self, cache): - from .models import CacheVersion - - try: - cache = CacheVersion.objects.get(cache=cache) - cache.delete() - except CacheVersion.DoesNotExist: - raise ValueError('Cache "%s" is not registered' % cache) - - @property - def cache(self): - return self.read_threadstore() - - def read_threadstore(self): - data = threadstore.get(CACHE_KEY, 'nada') - if data == 'nada': - data = self.read_cache() - threadstore.set(CACHE_KEY, data) - return data - - def read_cache(self): - from .cache import cache as default_cache - - data = default_cache.get(CACHE_KEY, 'nada') - if data == 'nada': - data = self.read_db() - default_cache.set(CACHE_KEY, data) - return data - - def read_db(self): - from .models import CacheVersion - - data = {} - for cache_version in CacheVersion.objects.iterator(): - data[cache_version.cache] = cache_version.version - return data - - def get_cache_version(self, cache): - try: - return self.cache[cache] - except KeyError: - raise ValueError('Cache "%s" is not registered' % cache) - - def is_cache_valid(self, cache, version): - try: - return self.cache[cache] == version - except KeyError: - raise ValueError('Cache "%s" is not registered' % cache) - - def invalidate_cache(self, cache): - from .cache import cache as default_cache - from .models import CacheVersion - - self.cache[cache] += 1 - CacheVersion.objects.filter(cache=cache).update(version=F('version') + 1) - default_cache.delete(CACHE_KEY) - - def invalidate_all(self): - from .cache import cache as default_cache - from .models import CacheVersion - - CacheVersion.objects.update(version=F('version') + 1) - default_cache.delete(CACHE_KEY) - - -_controller = CacheBusterController() - - -# Expose controller API -def register(cache_name): - _controller.register_cache(cache_name) - - -def unregister(cache_name): - _controller.unregister_cache(cache_name) - - -def get_version(cache_name): - return _controller.get_cache_version(cache_name) - - -def is_valid(cache_name, version): - return _controller.is_cache_valid(cache_name, version) - - -def invalidate(cache_name): - _controller.invalidate_cache(cache_name) - - -def invalidate_all(): - _controller.invalidate_all() diff --git a/misago/core/mail.py b/misago/core/mail.py index 5b72f021a2..cddebb9073 100644 --- a/misago/core/mail.py +++ b/misago/core/mail.py @@ -2,7 +2,7 @@ from django.template.loader import render_to_string from django.utils.translation import get_language -from misago.conf import db_settings, settings +from misago.conf import settings from .utils import get_host_from_address @@ -15,13 +15,14 @@ def build_mail(recipient, subject, template, sender=None, context=None): 'LANGUAGE_CODE': get_language()[:2], 'LOGIN_URL': settings.LOGIN_URL, - 'misago_settings': db_settings, - 'user': recipient, 'sender': sender, 'subject': subject, }) + if not context.get("settings"): + raise ValueError("settings key is missing from context") + message_plain = render_to_string('%s.txt' % template, context) message_html = render_to_string('%s.html' % template, context) diff --git a/misago/core/middleware.py b/misago/core/middleware.py index acac674b1d..177c773266 100644 --- a/misago/core/middleware.py +++ b/misago/core/middleware.py @@ -1,6 +1,6 @@ from django.utils.deprecation import MiddlewareMixin -from misago.core import exceptionhandler, threadstore +from misago.core import exceptionhandler from misago.core.utils import is_request_to_misago @@ -19,9 +19,3 @@ class FrontendContextMiddleware(MiddlewareMixin): def process_request(self, request): request.include_frontend_context = True request.frontend_context = {} - - -class ThreadStoreMiddleware(MiddlewareMixin): - def process_response(self, request, response): - threadstore.clear() - return response diff --git a/misago/core/migrationutils.py b/misago/core/migrationutils.py deleted file mode 100644 index 2df62e0a12..0000000000 --- a/misago/core/migrationutils.py +++ /dev/null @@ -1,24 +0,0 @@ -from .cache import cache as default_cache -from .cachebuster import CACHE_KEY - - -def _CacheVersion(apps): - return apps.get_model('misago_core', 'CacheVersion') - - -def cachebuster_register_cache(apps, cache): - _CacheVersion(apps).objects.create(cache=cache) - - -def cachebuster_unregister_cache(apps, cache): - CacheVersion = _CacheVersion(apps) - - try: - cache = CacheVersion.objects.get(cache=cache) - cache.delete() - except CacheVersion.DoesNotExist: - raise ValueError('Cache "%s" is not registered' % cache) - - -def delete_cachebuster_cache(): - default_cache.delete(CACHE_KEY) diff --git a/misago/core/tests/test_cachebuster.py b/misago/core/tests/test_cachebuster.py deleted file mode 100644 index 289ae20429..0000000000 --- a/misago/core/tests/test_cachebuster.py +++ /dev/null @@ -1,73 +0,0 @@ -from misago.core import cachebuster -from misago.core.models import CacheVersion -from misago.core.testutils import MisagoTestCase - - -class CacheBusterTests(MisagoTestCase): - def test_register_unregister_cache(self): - """register and unregister adds/removes cache""" - test_cache_name = 'eric_the_fish' - with self.assertRaises(CacheVersion.DoesNotExist): - CacheVersion.objects.get(cache=test_cache_name) - - cachebuster.register(test_cache_name) - CacheVersion.objects.get(cache=test_cache_name) - - cachebuster.unregister(test_cache_name) - with self.assertRaises(CacheVersion.DoesNotExist): - CacheVersion.objects.get(cache=test_cache_name) - - -class CacheBusterCacheTests(MisagoTestCase): - def setUp(self): - super().setUp() - - self.cache_name = 'eric_the_fish' - cachebuster.register(self.cache_name) - - def test_cache_validation(self): - """cache correctly validates""" - version = cachebuster.get_version(self.cache_name) - self.assertEqual(version, 0) - - db_version = CacheVersion.objects.get(cache=self.cache_name).version - self.assertEqual(db_version, 0) - - self.assertEqual(db_version, version) - self.assertTrue(cachebuster.is_valid(self.cache_name, version)) - self.assertTrue(cachebuster.is_valid(self.cache_name, db_version)) - - def test_cache_invalidation(self): - """invalidate has increased valid version number""" - db_version = CacheVersion.objects.get(cache=self.cache_name).version - cachebuster.invalidate(self.cache_name) - - new_version = cachebuster.get_version(self.cache_name) - new_db_version = CacheVersion.objects.get(cache=self.cache_name) - new_db_version = new_db_version.version - - self.assertEqual(new_version, 1) - self.assertEqual(new_db_version, 1) - self.assertEqual(new_version, new_db_version) - self.assertFalse(cachebuster.is_valid(self.cache_name, db_version)) - self.assertTrue(cachebuster.is_valid(self.cache_name, new_db_version)) - - def test_cache_invalidation_all(self): - """invalidate_all has increased valid version number""" - cache_a = "eric_the_halibut" - cache_b = "eric_the_crab" - cache_c = "eric_the_lion" - - cachebuster.register(cache_a) - cachebuster.register(cache_b) - cachebuster.register(cache_c) - - cachebuster.invalidate_all() - - new_version_a = CacheVersion.objects.get(cache=cache_a).version - new_version_b = CacheVersion.objects.get(cache=cache_b).version - new_version_c = CacheVersion.objects.get(cache=cache_c).version - - self.assertEqual(new_version_a, 1) - self.assertEqual(new_version_b, 1) - self.assertEqual(new_version_c, 1) diff --git a/misago/core/tests/test_errorpages.py b/misago/core/tests/test_errorpages.py index a6a97960aa..a1d64bbb24 100644 --- a/misago/core/tests/test_errorpages.py +++ b/misago/core/tests/test_errorpages.py @@ -4,9 +4,12 @@ from django.test.client import RequestFactory from django.urls import reverse +from misago.acl.useracl import get_user_acl +from misago.users.models import AnonymousUser +from misago.conf.dynamicsettings import DynamicSettings +from misago.conftest import get_cache_versions from misago.core.testproject.views import mock_custom_403_error_page, mock_custom_404_error_page from misago.core.utils import encode_json_html -from misago.users.models import AnonymousUser class CSRFErrorViewTests(TestCase): @@ -73,20 +76,22 @@ def test_social_auth_banned(self): self.assertContains(response, "Banned in auth!", status_code=403) +def test_request(url): + request = RequestFactory().get(url) + request.cache_versions = get_cache_versions() + request.settings = DynamicSettings(request.cache_versions) + request.user = AnonymousUser() + request.user_acl = get_user_acl(request.user, request.cache_versions) + request.include_frontend_context = True + request.frontend_context = {} + return request + + @override_settings(ROOT_URLCONF='misago.core.testproject.urlswitherrorhandlers') class CustomErrorPagesTests(TestCase): def setUp(self): - self.misago_request = RequestFactory().get(reverse('misago:index')) - self.site_request = RequestFactory().get(reverse('raise-403')) - - self.misago_request.user = AnonymousUser() - self.site_request.user = AnonymousUser() - - self.misago_request.include_frontend_context = True - self.site_request.include_frontend_context = True - - self.misago_request.frontend_context = {} - self.site_request.frontend_context = {} + self.misago_request = test_request(reverse('misago:index')) + self.site_request = test_request(reverse('raise-403')) def test_shared_403_decorator(self): """shared_403_decorator calls correct error handler""" diff --git a/misago/core/tests/test_exceptionhandler_middleware.py b/misago/core/tests/test_exceptionhandler_middleware.py index b1250ff30b..675f95e296 100644 --- a/misago/core/tests/test_exceptionhandler_middleware.py +++ b/misago/core/tests/test_exceptionhandler_middleware.py @@ -3,27 +3,35 @@ from django.test.client import RequestFactory from django.urls import reverse +from misago.acl.useracl import get_user_acl +from misago.conf.dynamicsettings import DynamicSettings from misago.core.middleware import ExceptionHandlerMiddleware +from misago.conftest import get_cache_versions from misago.users.models import AnonymousUser +from misago.core.middleware import ExceptionHandlerMiddleware -class ExceptionHandlerMiddlewareTests(TestCase): - def setUp(self): - self.request = RequestFactory().get(reverse('misago:index')) - self.request.user = AnonymousUser() - self.request.include_frontend_context = True - self.request.frontend_context = {} +def test_request(): + request = RequestFactory().get(reverse('misago:index')) + request.cache_versions = get_cache_versions() + request.settings = DynamicSettings(request.cache_versions) + request.user = AnonymousUser() + request.user_acl = get_user_acl(request.user, request.cache_versions) + request.include_frontend_context = True + request.frontend_context = {} + return request + + +class ExceptionHandlerMiddlewareTests(TestCase): def test_middleware_returns_response_for_supported_exception(self): """Middleware returns HttpResponse for supported exception""" - exception = Http404() middleware = ExceptionHandlerMiddleware() - - self.assertTrue(middleware.process_exception(self.request, exception)) + exception = Http404() + assert middleware.process_exception(test_request(), exception) def test_middleware_returns_none_for_non_supported_exception(self): """Middleware returns None for non-supported exception""" - exception = TypeError() middleware = ExceptionHandlerMiddleware() - - self.assertFalse(middleware.process_exception(self.request, exception)) + exception = TypeError() + assert middleware.process_exception(test_request(), exception) is None diff --git a/misago/core/tests/test_mail.py b/misago/core/tests/test_mail.py index d5e0271e58..92a8b8c3d2 100644 --- a/misago/core/tests/test_mail.py +++ b/misago/core/tests/test_mail.py @@ -3,18 +3,39 @@ from django.test import TestCase from django.urls import reverse -from misago.core.mail import mail_user, mail_users +from misago.cache.versions import get_cache_versions +from misago.conf.dynamicsettings import DynamicSettings + +from misago.core.mail import build_mail, mail_user, mail_users UserModel = get_user_model() class MailTests(TestCase): + def test_building_mail_without_context_raises_value_error(self): + user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123') + with self.assertRaises(ValueError): + build_mail(user, "Misago Test Mail", "misago/emails/base") + + def test_building_mail_without_settings_in_context_raises_value_error(self): + user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123') + with self.assertRaises(ValueError): + build_mail(user, "Misago Test Mail", "misago/emails/base", context={"settings": {}}) + def test_mail_user(self): """mail_user sets message in backend""" user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123') - mail_user(user, "Misago Test Mail", "misago/emails/base") + cache_versions = get_cache_versions() + settings = DynamicSettings(cache_versions) + + mail_user( + user, + "Misago Test Mail", + "misago/emails/base", + context={"settings": settings}, + ) self.assertEqual(mail.outbox[0].subject, "Misago Test Mail") @@ -26,6 +47,9 @@ def test_mail_user(self): def test_mail_users(self): """mail_users sets messages in backend""" + cache_versions = get_cache_versions() + settings = DynamicSettings(cache_versions) + test_users = [ UserModel.objects.create_user('Alpha', 'alpha@test.com', 'pass123'), UserModel.objects.create_user('Beta', 'beta@test.com', 'pass123'), @@ -34,7 +58,12 @@ def test_mail_users(self): UserModel.objects.create_user('Uniform', 'uniform@test.com', 'pass123'), ] - mail_users(test_users, "Misago Test Spam", "misago/emails/base") + mail_users( + test_users, + "Misago Test Spam", + "misago/emails/base", + context={"settings": settings}, + ) spams_sent = 0 for message in mail.outbox: diff --git a/misago/core/tests/test_migrationutils.py b/misago/core/tests/test_migrationutils.py deleted file mode 100644 index ce3f074708..0000000000 --- a/misago/core/tests/test_migrationutils.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.apps import apps -from django.test import TestCase - -from misago.core import migrationutils -from misago.core.models import CacheVersion - - -class CacheBusterUtilsTests(TestCase): - def test_cachebuster_register_cache(self): - """cachebuster_register_cache registers cache on migration successfully""" - cache_name = 'eric_licenses' - migrationutils.cachebuster_register_cache(apps, cache_name) - CacheVersion.objects.get(cache=cache_name) - - def test_cachebuster_unregister_cache(self): - """cachebuster_unregister_cache removes cache on migration successfully""" - cache_name = 'eric_licenses' - migrationutils.cachebuster_register_cache(apps, cache_name) - CacheVersion.objects.get(cache=cache_name) - - migrationutils.cachebuster_unregister_cache(apps, cache_name) - with self.assertRaises(CacheVersion.DoesNotExist): - CacheVersion.objects.get(cache=cache_name) - - with self.assertRaises(ValueError): - migrationutils.cachebuster_unregister_cache(apps, cache_name) diff --git a/misago/core/tests/test_threadstore.py b/misago/core/tests/test_threadstore.py deleted file mode 100644 index 9c64d80a80..0000000000 --- a/misago/core/tests/test_threadstore.py +++ /dev/null @@ -1,41 +0,0 @@ -from django.test import TestCase -from django.test.client import RequestFactory -from django.urls import reverse - -from misago.core import threadstore -from misago.core.middleware import ThreadStoreMiddleware - - -class ThreadStoreTests(TestCase): - def setUp(self): - threadstore.clear() - - def test_set_get_value(self): - """It's possible to set and get value from threadstore""" - self.assertEqual(threadstore.get('knights_say'), None) - - returned_value = threadstore.set('knights_say', 'Ni!') - self.assertEqual(returned_value, 'Ni!') - self.assertEqual(threadstore.get('knights_say'), 'Ni!') - - def test_clear_store(self): - """clear cleared threadstore""" - self.assertEqual(threadstore.get('the_fish'), None) - threadstore.set('the_fish', 'Eric') - self.assertEqual(threadstore.get('the_fish'), 'Eric') - threadstore.clear() - self.assertEqual(threadstore.get('the_fish'), None) - - -class ThreadStoreMiddlewareTests(TestCase): - def setUp(self): - self.request = RequestFactory().get(reverse('misago:index')) - - def test_middleware_clears_store_on_response_exception(self): - """Middleware cleared store on response""" - - threadstore.set('any_chesse', 'Nope') - middleware = ThreadStoreMiddleware() - response = middleware.process_response(self.request, 'FakeResponse') - self.assertEqual(response, 'FakeResponse') - self.assertEqual(threadstore.get('any_chesse'), None) diff --git a/misago/core/testutils.py b/misago/core/testutils.py deleted file mode 100644 index 90142a253d..0000000000 --- a/misago/core/testutils.py +++ /dev/null @@ -1,20 +0,0 @@ -from django.test import TestCase - -from . import threadstore -from .cache import cache - - -class MisagoTestCase(TestCase): - """TestCase class that empties global state before and after each test""" - - def clear_state(self): - cache.clear() - threadstore.clear() - - def setUp(self): - super().setUp() - self.clear_state() - - def tearDown(self): - self.clear_state() - super().tearDown() diff --git a/misago/core/threadstore.py b/misago/core/threadstore.py deleted file mode 100644 index c60a4a0163..0000000000 --- a/misago/core/threadstore.py +++ /dev/null @@ -1,17 +0,0 @@ -from threading import local - - -_thread_local = local() - - -def get(key, default=None): - return _thread_local.__dict__.get(key, default) - - -def set(key, value): - _thread_local.__dict__[key] = value - return _thread_local.__dict__[key] - - -def clear(): - _thread_local.__dict__.clear() diff --git a/misago/faker/management/commands/createfakecategories.py b/misago/faker/management/commands/createfakecategories.py index 2f92738efd..2b8757a6e4 100644 --- a/misago/faker/management/commands/createfakecategories.py +++ b/misago/faker/management/commands/createfakecategories.py @@ -5,7 +5,7 @@ from django.core.management.base import BaseCommand -from misago.acl import version as acl_version +from misago.acl.cache import clear_acl_cache from misago.categories.models import Category, RoleCategoryACL from misago.core.management.progressbar import show_progress @@ -85,7 +85,7 @@ def handle(self, *args, **options): created_count += 1 show_progress(self, created_count, items_to_create, start_time) - acl_version.invalidate() + clear_acl_cache() total_time = time.time() - start_time total_humanized = time.strftime('%H:%M:%S', time.gmtime(total_time)) diff --git a/misago/markup/api.py b/misago/markup/api.py index 30624f5860..e2dc75a51e 100644 --- a/misago/markup/api.py +++ b/misago/markup/api.py @@ -8,7 +8,9 @@ @api_view(['POST']) def parse_markup(request): - serializer = MarkupSerializer(data=request.data) + serializer = MarkupSerializer( + data=request.data, context={"settings": request.settings} + ) if not serializer.is_valid(): errors_list = list(serializer.errors.values())[0] return Response( diff --git a/misago/markup/flavours.py b/misago/markup/flavours.py index 6f1cf8364c..ef71a4110f 100644 --- a/misago/markup/flavours.py +++ b/misago/markup/flavours.py @@ -43,15 +43,15 @@ def limited(request, text): return result['parsed_text'] -def signature(request, owner, text): +def signature(request, owner, user_acl, text): result = parse( text, request, owner, allow_mentions=False, - allow_blocks=owner.acl_cache['allow_signature_blocks'], - allow_links=owner.acl_cache['allow_signature_links'], - allow_images=owner.acl_cache['allow_signature_images'], + allow_blocks=user_acl['allow_signature_blocks'], + allow_links=user_acl['allow_signature_links'], + allow_images=user_acl['allow_signature_images'], ) return result['parsed_text'] diff --git a/misago/markup/serializers.py b/misago/markup/serializers.py index 502c0ef09e..ba51ce8e5d 100644 --- a/misago/markup/serializers.py +++ b/misago/markup/serializers.py @@ -7,5 +7,6 @@ class MarkupSerializer(serializers.Serializer): post = serializers.CharField(required=False, allow_blank=True) def validate(self, data): - validate_post_length(data.get('post', '')) + settings = self.context["settings"] + validate_post_length(settings, data.get("post", "")) return data diff --git a/misago/readtracker/categoriestracker.py b/misago/readtracker/categoriestracker.py index 7c2172b590..4664d2c4c4 100644 --- a/misago/readtracker/categoriestracker.py +++ b/misago/readtracker/categoriestracker.py @@ -4,7 +4,7 @@ from .dates import get_cutoff_date -def make_read_aware(user, categories): +def make_read_aware(user, user_acl, categories): if not categories: return @@ -17,7 +17,7 @@ def make_read_aware(user, categories): return threads = Thread.objects.filter(category__in=categories) - threads = exclude_invisible_threads(user, categories, threads) + threads = exclude_invisible_threads(user_acl, categories, threads) queryset = Post.objects.filter( category__in=categories, @@ -26,7 +26,7 @@ def make_read_aware(user, categories): ).values_list('category', flat=True).distinct() queryset = queryset.exclude(id__in=user.postread_set.values('post')) - queryset = exclude_invisible_posts(user, categories, queryset) + queryset = exclude_invisible_posts(user_acl, categories, queryset) unread_categories = list(queryset) diff --git a/misago/readtracker/tests/test_categoriestracker.py b/misago/readtracker/tests/test_categoriestracker.py index 1538af6ce5..8415d37cb7 100644 --- a/misago/readtracker/tests/test_categoriestracker.py +++ b/misago/readtracker/tests/test_categoriestracker.py @@ -4,15 +4,17 @@ from django.test import TestCase from django.utils import timezone +from misago.acl.useracl import get_user_acl from misago.categories.models import Category from misago.conf import settings -from misago.core import cache, threadstore +from misago.conftest import get_cache_versions from misago.readtracker import poststracker, categoriestracker from misago.readtracker.models import PostRead from misago.threads import testutils +User = get_user_model() -UserModel = get_user_model() +cache_versions = get_cache_versions() class AnonymousUser(object): @@ -22,24 +24,22 @@ class AnonymousUser(object): class CategoriesTrackerTests(TestCase): def setUp(self): - cache.cache.clear() - threadstore.clear() - - self.user = UserModel.objects.create_user("UserA", "testa@user.com", 'Pass.123') + self.user = User.objects.create_user("UserA", "testa@user.com", 'Pass.123') + self.user_acl = get_user_acl(self.user, cache_versions) self.category = Category.objects.get(slug='first-category') def test_falsy_value(self): """passing falsy value to readtracker causes no errors""" - categoriestracker.make_read_aware(self.user, None) - categoriestracker.make_read_aware(self.user, False) - categoriestracker.make_read_aware(self.user, []) + categoriestracker.make_read_aware(self.user, self.user_acl, None) + categoriestracker.make_read_aware(self.user, self.user_acl, False) + categoriestracker.make_read_aware(self.user, self.user_acl, []) def test_anon_thread_before_cutoff(self): """non-tracked thread is marked as read for anonymous users""" started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF) testutils.post_thread(self.category, started_on=started_on) - categoriestracker.make_read_aware(AnonymousUser(), self.category) + categoriestracker.make_read_aware(AnonymousUser(), None, self.category) self.assertTrue(self.category.is_read) self.assertFalse(self.category.is_new) @@ -47,7 +47,7 @@ def test_anon_thread_after_cutoff(self): """tracked thread is marked as read for anonymous users""" testutils.post_thread(self.category, started_on=timezone.now()) - categoriestracker.make_read_aware(AnonymousUser(), self.category) + categoriestracker.make_read_aware(AnonymousUser(), None, self.category) self.assertTrue(self.category.is_read) self.assertFalse(self.category.is_new) @@ -56,7 +56,7 @@ def test_user_thread_before_cutoff(self): started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF) testutils.post_thread(self.category, started_on=started_on) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertTrue(self.category.is_read) self.assertFalse(self.category.is_new) @@ -64,7 +64,7 @@ def test_user_unread_thread(self): """tracked thread is marked as unread for authenticated users""" testutils.post_thread(self.category, started_on=timezone.now()) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertFalse(self.category.is_read) self.assertTrue(self.category.is_new) @@ -73,7 +73,7 @@ def test_user_created_after_thread(self): started_on = timezone.now() - timedelta(days=1) testutils.post_thread(self.category, started_on=started_on) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertTrue(self.category.is_read) self.assertFalse(self.category.is_new) @@ -83,7 +83,7 @@ def test_user_read_post(self): poststracker.save_read(self.user, thread.first_post) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertTrue(self.category.is_read) self.assertFalse(self.category.is_new) @@ -94,7 +94,7 @@ def test_user_first_unread_last_read_post(self): post = testutils.reply_thread(thread, posted_on=timezone.now()) poststracker.save_read(self.user, post) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertFalse(self.category.is_read) self.assertTrue(self.category.is_new) @@ -105,7 +105,7 @@ def test_user_first_read_post_unread_event(self): testutils.reply_thread(thread, posted_on=timezone.now(), is_event=True) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertFalse(self.category.is_read) self.assertTrue(self.category.is_new) @@ -120,7 +120,7 @@ def test_user_hidden_event(self): is_hidden=True, ) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertFalse(self.category.is_read) self.assertTrue(self.category.is_new) @@ -136,7 +136,7 @@ def test_user_first_read_post_hidden_event(self): is_hidden=True, ) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertTrue(self.category.is_read) self.assertFalse(self.category.is_new) @@ -145,7 +145,7 @@ def test_user_thread_before_cutoff_unread_post(self): started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF) testutils.post_thread(self.category, started_on=started_on) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertTrue(self.category.is_read) self.assertFalse(self.category.is_new) @@ -160,7 +160,7 @@ def test_user_first_read_post_unapproved_post(self): is_unapproved=True, ) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertTrue(self.category.is_read) self.assertFalse(self.category.is_new) @@ -176,7 +176,7 @@ def test_user_first_read_post_unapproved_own_post(self): is_unapproved=True, ) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertFalse(self.category.is_read) self.assertTrue(self.category.is_new) @@ -192,7 +192,7 @@ def test_user_first_read_post_unapproved_own_post(self): is_unapproved=True, ) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertFalse(self.category.is_read) self.assertTrue(self.category.is_new) @@ -204,7 +204,7 @@ def test_user_unapproved_thread_unread_post(self): is_unapproved=True, ) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertTrue(self.category.is_read) self.assertFalse(self.category.is_new) @@ -217,7 +217,7 @@ def test_user_unapproved_own_thread_unread_post(self): is_unapproved=True, ) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertFalse(self.category.is_read) self.assertTrue(self.category.is_new) @@ -229,6 +229,6 @@ def test_user_hidden_thread_unread_post(self): is_hidden=True, ) - categoriestracker.make_read_aware(self.user, self.category) + categoriestracker.make_read_aware(self.user, self.user_acl, self.category) self.assertTrue(self.category.is_read) self.assertFalse(self.category.is_new) diff --git a/misago/readtracker/tests/test_threadstracker.py b/misago/readtracker/tests/test_threadstracker.py index 2a33d72c04..84a4fd3490 100644 --- a/misago/readtracker/tests/test_threadstracker.py +++ b/misago/readtracker/tests/test_threadstracker.py @@ -4,16 +4,18 @@ from django.test import TestCase from django.utils import timezone -from misago.acl import add_acl +from misago.acl.objectacl import add_acl_to_obj +from misago.acl.useracl import get_user_acl from misago.categories.models import Category from misago.conf import settings -from misago.core import cache, threadstore +from misago.conftest import get_cache_versions from misago.readtracker import poststracker, threadstracker from misago.readtracker.models import PostRead from misago.threads import testutils +User = get_user_model() -UserModel = get_user_model() +cache_versions = get_cache_versions() class AnonymousUser(object): @@ -23,26 +25,24 @@ class AnonymousUser(object): class ThreadsTrackerTests(TestCase): def setUp(self): - cache.cache.clear() - threadstore.clear() - - self.user = UserModel.objects.create_user("UserA", "testa@user.com", 'Pass.123') + self.user = User.objects.create_user("UserA", "testa@user.com", 'Pass.123') + self.user_acl = get_user_acl(self.user, cache_versions) self.category = Category.objects.get(slug='first-category') - add_acl(self.user, self.category) + add_acl_to_obj(self.user_acl, self.category) def test_falsy_value(self): """passing falsy value to readtracker causes no errors""" - threadstracker.make_read_aware(self.user, None) - threadstracker.make_read_aware(self.user, False) - threadstracker.make_read_aware(self.user, []) + threadstracker.make_read_aware(self.user, self.user_acl, None) + threadstracker.make_read_aware(self.user, self.user_acl, False) + threadstracker.make_read_aware(self.user, self.user_acl, []) def test_anon_thread_before_cutoff(self): """non-tracked thread is marked as read for anonymous users""" started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF) thread = testutils.post_thread(self.category, started_on=started_on) - threadstracker.make_read_aware(AnonymousUser(), thread) + threadstracker.make_read_aware(AnonymousUser(), None, thread) self.assertTrue(thread.is_read) self.assertFalse(thread.is_new) @@ -50,7 +50,7 @@ def test_anon_thread_after_cutoff(self): """tracked thread is marked as read for anonymous users""" thread = testutils.post_thread(self.category, started_on=timezone.now()) - threadstracker.make_read_aware(AnonymousUser(), thread) + threadstracker.make_read_aware(AnonymousUser(), None, thread) self.assertTrue(thread.is_read) self.assertFalse(thread.is_new) @@ -59,7 +59,7 @@ def test_user_thread_before_cutoff(self): started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF) thread = testutils.post_thread(self.category, started_on=started_on) - threadstracker.make_read_aware(self.user, thread) + threadstracker.make_read_aware(self.user, self.user_acl, thread) self.assertTrue(thread.is_read) self.assertFalse(thread.is_new) @@ -67,7 +67,7 @@ def test_user_unread_thread(self): """tracked thread is marked as unread for authenticated users""" thread = testutils.post_thread(self.category, started_on=timezone.now()) - threadstracker.make_read_aware(self.user, thread) + threadstracker.make_read_aware(self.user, self.user_acl, thread) self.assertFalse(thread.is_read) self.assertTrue(thread.is_new) @@ -76,7 +76,7 @@ def test_user_created_after_thread(self): started_on = timezone.now() - timedelta(days=1) thread = testutils.post_thread(self.category, started_on=started_on) - threadstracker.make_read_aware(self.user, thread) + threadstracker.make_read_aware(self.user, self.user_acl, thread) self.assertTrue(thread.is_read) self.assertFalse(thread.is_new) @@ -86,7 +86,7 @@ def test_user_read_post(self): poststracker.save_read(self.user, thread.first_post) - threadstracker.make_read_aware(self.user, thread) + threadstracker.make_read_aware(self.user, self.user_acl, thread) self.assertTrue(thread.is_read) self.assertFalse(thread.is_new) @@ -97,7 +97,7 @@ def test_user_first_unread_last_read_post(self): post = testutils.reply_thread(thread, posted_on=timezone.now()) poststracker.save_read(self.user, post) - threadstracker.make_read_aware(self.user, thread) + threadstracker.make_read_aware(self.user, self.user_acl, thread) self.assertFalse(thread.is_read) self.assertTrue(thread.is_new) @@ -108,7 +108,7 @@ def test_user_first_read_post_unread_event(self): testutils.reply_thread(thread, posted_on=timezone.now(), is_event=True) - threadstracker.make_read_aware(self.user, thread) + threadstracker.make_read_aware(self.user, self.user_acl, thread) self.assertFalse(thread.is_read) self.assertTrue(thread.is_new) @@ -123,7 +123,7 @@ def test_user_hidden_event(self): is_hidden=True, ) - threadstracker.make_read_aware(self.user, thread) + threadstracker.make_read_aware(self.user, self.user_acl, thread) self.assertFalse(thread.is_read) self.assertTrue(thread.is_new) @@ -139,7 +139,7 @@ def test_user_first_read_post_hidden_event(self): is_hidden=True, ) - threadstracker.make_read_aware(self.user, thread) + threadstracker.make_read_aware(self.user, self.user_acl, thread) self.assertTrue(thread.is_read) self.assertFalse(thread.is_new) @@ -148,7 +148,7 @@ def test_user_thread_before_cutoff_unread_post(self): started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF) thread = testutils.post_thread(self.category, started_on=started_on) - threadstracker.make_read_aware(self.user, thread) + threadstracker.make_read_aware(self.user, self.user_acl, thread) self.assertTrue(thread.is_read) self.assertFalse(thread.is_new) @@ -163,7 +163,7 @@ def test_user_first_read_post_unapproved_post(self): is_unapproved=True, ) - threadstracker.make_read_aware(self.user, thread) + threadstracker.make_read_aware(self.user, self.user_acl, thread) self.assertTrue(thread.is_read) self.assertFalse(thread.is_new) @@ -179,6 +179,6 @@ def test_user_first_read_post_unapproved_own_post(self): is_unapproved=True, ) - threadstracker.make_read_aware(self.user, thread) + threadstracker.make_read_aware(self.user, self.user_acl, thread) self.assertFalse(thread.is_read) self.assertTrue(thread.is_new) diff --git a/misago/readtracker/threadstracker.py b/misago/readtracker/threadstracker.py index 05595562cf..31b7338031 100644 --- a/misago/readtracker/threadstracker.py +++ b/misago/readtracker/threadstracker.py @@ -4,7 +4,7 @@ from .dates import get_cutoff_date -def make_read_aware(user, threads): +def make_read_aware(user, user_acl, threads): if not threads: return @@ -24,7 +24,7 @@ def make_read_aware(user, threads): ).values_list('thread', flat=True).distinct() queryset = queryset.exclude(id__in=user.postread_set.values('post')) - queryset = exclude_invisible_posts(user, categories, queryset) + queryset = exclude_invisible_posts(user_acl, categories, queryset) unread_threads = list(queryset) diff --git a/misago/search/api.py b/misago/search/api.py index 763ec8d65e..5109e33eba 100644 --- a/misago/search/api.py +++ b/misago/search/api.py @@ -15,7 +15,7 @@ @api_view() def search(request, search_provider=None): allowed_providers = searchproviders.get_allowed_providers(request) - if not request.user.acl_cache['can_search'] or not allowed_providers: + if not request.user_acl['can_search'] or not allowed_providers: raise PermissionDenied(_("You don't have permission to search site.")) search_query = get_search_query(request) diff --git a/misago/search/context_processors.py b/misago/search/context_processors.py index 4a30f2307a..b46e056063 100644 --- a/misago/search/context_processors.py +++ b/misago/search/context_processors.py @@ -8,7 +8,7 @@ def search_providers(request): allowed_providers = [] try: - if request.user.acl_cache['can_search']: + if request.user_acl['can_search']: allowed_providers = searchproviders.get_allowed_providers(request) except AttributeError: # is user has no acl_cache attribute, cease entire middleware diff --git a/misago/search/tests/test_api.py b/misago/search/tests/test_api.py index 4c5cd94c0a..b538b51734 100644 --- a/misago/search/tests/test_api.py +++ b/misago/search/tests/test_api.py @@ -1,6 +1,6 @@ from django.urls import reverse -from misago.acl.testutils import override_acl +from misago.acl.test import patch_user_acl from misago.search.searchproviders import searchproviders from misago.users.testutils import AuthenticatedUserTestCase @@ -11,10 +11,9 @@ def setUp(self): self.test_link = reverse('misago:api:search') + @patch_user_acl({"can_search": False}) def test_no_permission(self): """api validates permission to search""" - override_acl(self.user, {'can_search': 0}) - response = self.client.get(self.test_link) self.assertEqual(response.status_code, 403) self.assertEqual(response.json(), { diff --git a/misago/search/tests/test_views.py b/misago/search/tests/test_views.py index f7f65c1625..c0c52ea9bc 100644 --- a/misago/search/tests/test_views.py +++ b/misago/search/tests/test_views.py @@ -1,6 +1,6 @@ from django.urls import reverse -from misago.acl.testutils import override_acl +from misago.acl.test import patch_user_acl from misago.threads.search import SearchThreads from misago.users.testutils import AuthenticatedUserTestCase @@ -11,27 +11,24 @@ def setUp(self): self.test_link = reverse('misago:search') + @patch_user_acl({'can_search': False}) def test_no_permission(self): """view validates permission to search forum""" - override_acl(self.user, {'can_search': 0}) - response = self.client.get(self.test_link) - self.assertContains(response, "have permission to search site", status_code=403) + @patch_user_acl({'can_search': True}) def test_redirect_to_provider(self): """view validates permission to search forum""" response = self.client.get(self.test_link) - self.assertEqual(response.status_code, 302) self.assertIn(SearchThreads.url, response['location']) class SearchTests(AuthenticatedUserTestCase): + @patch_user_acl({'can_search': False}) def test_no_permission(self): """view validates permission to search forum""" - override_acl(self.user, {'can_search': 0}) - response = self.client.get( reverse('misago:search', kwargs={ 'search_provider': 'users', @@ -48,10 +45,9 @@ def test_not_found(self): self.assertEqual(response.status_code, 404) + @patch_user_acl({'can_search': True, 'can_search_users': False}) def test_provider_no_permission(self): """provider raises 403 without permission""" - override_acl(self.user, {'can_search_users': 0}) - response = self.client.get( reverse('misago:search', kwargs={ 'search_provider': 'users', @@ -64,7 +60,7 @@ def test_provider(self): """provider displays no script page""" response = self.client.get( reverse('misago:search', kwargs={ - 'search_provider': 'threads', + 'search_provider': 'users', }) ) diff --git a/misago/search/views.py b/misago/search/views.py index 12e56ab6b5..3b9759f797 100644 --- a/misago/search/views.py +++ b/misago/search/views.py @@ -9,7 +9,7 @@ def landing(request): allowed_providers = searchproviders.get_allowed_providers(request) - if not request.user.acl_cache['can_search'] or not allowed_providers: + if not request.user_acl['can_search'] or not allowed_providers: raise PermissionDenied(_("You don't have permission to search site.")) default_provider = allowed_providers[0] @@ -18,7 +18,7 @@ def landing(request): def search(request, search_provider): all_providers = searchproviders.get_providers(request) - if not request.user.acl_cache['can_search'] or not all_providers: + if not request.user_acl['can_search'] or not all_providers: raise PermissionDenied(_("You don't have permission to search site.")) for provider in all_providers: diff --git a/misago/templates/misago/base.html b/misago/templates/misago/base.html index 0279063a97..ae0d044ff5 100644 --- a/misago/templates/misago/base.html +++ b/misago/templates/misago/base.html @@ -5,14 +5,14 @@ - {% spaceless %}{% block title %}{{ misago_settings.forum_name }}{% endblock %}{% endspaceless %} + {% spaceless %}{% block title %}{{ settings.forum_name }}{% endblock %}{% endspaceless %} {% spaceless %} {% block meta-extra %}{% endblock meta-extra %} {% block og-tags %} - + - + diff --git a/misago/templates/misago/categories/base.html b/misago/templates/misago/categories/base.html index af5c02f639..48ff4d1083 100644 --- a/misago/templates/misago/categories/base.html +++ b/misago/templates/misago/categories/base.html @@ -6,20 +6,20 @@ {% if THREADS_ON_INDEX %} {% trans "Categories" %} | {{ block.super }} {% else %} - {% if misago_settings.forum_index_title %} - {{ misago_settings.forum_index_title }} + {% if settings.forum_index_title %} + {{ settings.forum_index_title }} {% else %} - {{ misago_settings.forum_name }} + {{ settings.forum_name }} {% endif %} {% endif %} {% endblock title %} {% block meta-description %} - {% if not THREADS_ON_INDEX and misago_settings.forum_index_meta_description %} - {{ misago_settings.forum_index_meta_description }} + {% if not THREADS_ON_INDEX and settings.forum_index_meta_description %} + {{ settings.forum_index_meta_description }} {% else %} - {% blocktrans trimmed count categories=categories|length with forum_name=misago_settings.forum_name %} + {% blocktrans trimmed count categories=categories|length with forum_name=settings.forum_name %} There is {{ categories }} main category currenty available on the {{ forum_name }}. {% plural %} There are {{ categories }} main categories currenty available on the {{ forum_name }}. @@ -32,20 +32,20 @@ {% if THREADS_ON_INDEX %} {% trans "Categories" %} {% else %} - {% if misago_settings.forum_index_title %} - {{ misago_settings.forum_index_title }} + {% if settings.forum_index_title %} + {{ settings.forum_index_title }} {% else %} - {{ misago_settings.forum_name }} + {{ settings.forum_name }} {% endif %} {% endif %} {% endblock og-title %} {% block og-description %} - {% if not THREADS_ON_INDEX and misago_settings.forum_index_meta_description %} - {{ misago_settings.forum_index_meta_description }} + {% if not THREADS_ON_INDEX and settings.forum_index_meta_description %} + {{ settings.forum_index_meta_description }} {% else %} - {% blocktrans trimmed count categories=categories|length with forum_name=misago_settings.forum_name %} + {% blocktrans trimmed count categories=categories|length with forum_name=settings.forum_name %} There is {{ categories }} main category currenty available on the {{ forum_name }}. {% plural %} There are {{ categories }} main categories currenty available on the {{ forum_name }}. diff --git a/misago/templates/misago/categories/header.html b/misago/templates/misago/categories/header.html index 254702c391..8df91b10b6 100644 --- a/misago/templates/misago/categories/header.html +++ b/misago/templates/misago/categories/header.html @@ -3,10 +3,10 @@