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

Merge pull request #1145 from rafalp/remove-global-state-from-acl

Remove global state from ACL
Rafał Pitoń 6 лет назад
Родитель
Сommit
7f1ee27cee
186 измененных файлов с 3777 добавлено и 4331 удалено
  1. 4 2
      devproject/settings.py
  2. 2 2
      misago/acl/__init__.py
  3. 0 65
      misago/acl/api.py
  4. 1 0
      misago/acl/apps.py
  5. 0 0
      misago/acl/buildacl.py
  6. 23 0
      misago/acl/cache.py
  7. 0 1
      misago/acl/constants.py
  8. 2 0
      misago/acl/context_processors.py
  9. 12 0
      misago/acl/middleware.py
  10. 2 1
      misago/acl/migrations/0004_cache_version.py
  11. 3 3
      misago/acl/models.py
  12. 18 0
      misago/acl/objectacl.py
  13. 1 1
      misago/acl/panels.py
  14. 15 13
      misago/acl/providers.py
  15. 56 0
      misago/acl/test.py
  16. 0 26
      misago/acl/tests/test_api.py
  17. 93 0
      misago/acl/tests/test_getting_user_acl.py
  18. 78 0
      misago/acl/tests/test_patching_user_acl.py
  19. 46 73
      misago/acl/tests/test_providers.py
  20. 37 0
      misago/acl/tests/test_roleadmin_views.py
  21. 23 0
      misago/acl/tests/test_serializing_user_acl.py
  22. 0 1
      misago/acl/tests/test_testutils.py
  23. 12 0
      misago/acl/tests/test_user_acl_context_processor.py
  24. 29 0
      misago/acl/tests/test_user_acl_middleware.py
  25. 0 19
      misago/acl/testutils.py
  26. 30 0
      misago/acl/useracl.py
  27. 0 15
      misago/acl/version.py
  28. 1 3
      misago/cache/middleware.py
  29. 21 0
      misago/cache/test.py
  30. 25 0
      misago/cache/tests/test_assert_invalidates_cache.py
  31. 2 30
      misago/cache/tests/test_cache_versions_middleware.py
  32. 2 3
      misago/cache/tests/test_getting_cache_versions.py
  33. 1 1
      misago/categories/api.py
  34. 2 2
      misago/categories/management/commands/fixcategoriestree.py
  35. 2 2
      misago/categories/models.py
  36. 11 13
      misago/categories/permissions.py
  37. 58 0
      misago/categories/tests/test_categories_admin_views.py
  38. 7 0
      misago/categories/tests/test_fixcategoriestree.py
  39. 67 0
      misago/categories/tests/test_permissions_admin_views.py
  40. 18 10
      misago/categories/tests/test_utils.py
  41. 26 46
      misago/categories/tests/test_views.py
  42. 6 6
      misago/categories/utils.py
  43. 2 2
      misago/categories/views/categoriesadmin.py
  44. 1 1
      misago/categories/views/categorieslist.py
  45. 3 3
      misago/categories/views/permsadmin.py
  46. 14 12
      misago/core/tests/test_errorpages.py
  47. 17 13
      misago/core/tests/test_exceptionhandler_middleware.py
  48. 2 2
      misago/faker/management/commands/createfakecategories.py
  49. 4 4
      misago/markup/flavours.py
  50. 3 3
      misago/readtracker/categoriestracker.py
  51. 25 26
      misago/readtracker/tests/test_categoriestracker.py
  52. 23 24
      misago/readtracker/tests/test_threadstracker.py
  53. 2 2
      misago/readtracker/threadstracker.py
  54. 1 1
      misago/search/api.py
  55. 1 1
      misago/search/context_processors.py
  56. 2 3
      misago/search/tests/test_api.py
  57. 6 10
      misago/search/tests/test_views.py
  58. 2 2
      misago/search/views.py
  59. 1 1
      misago/templates/misago/navbar.html
  60. 1 1
      misago/templates/misago/threadslist/tabs.html
  61. 4 4
      misago/threads/api/attachments.py
  62. 3 3
      misago/threads/api/pollvotecreateendpoint.py
  63. 4 4
      misago/threads/api/postendpoints/delete.py
  64. 2 2
      misago/threads/api/postendpoints/edits.py
  65. 3 3
      misago/threads/api/postendpoints/merge.py
  66. 4 4
      misago/threads/api/postendpoints/patch_event.py
  67. 10 8
      misago/threads/api/postendpoints/patch_post.py
  68. 1 1
      misago/threads/api/postendpoints/read.py
  69. 1 1
      misago/threads/api/postendpoints/split.py
  70. 6 1
      misago/threads/api/postingendpoint/__init__.py
  71. 6 5
      misago/threads/api/postingendpoint/attachments.py
  72. 10 10
      misago/threads/api/postingendpoint/category.py
  73. 6 4
      misago/threads/api/postingendpoint/emailnotification.py
  74. 4 2
      misago/threads/api/postingendpoint/floodprotection.py
  75. 12 3
      misago/threads/api/postingendpoint/participants.py
  76. 2 2
      misago/threads/api/postingendpoint/privatethread.py
  77. 1 1
      misago/threads/api/threadendpoints/delete.py
  78. 4 4
      misago/threads/api/threadendpoints/editor.py
  79. 5 7
      misago/threads/api/threadendpoints/merge.py
  80. 23 21
      misago/threads/api/threadendpoints/patch.py
  81. 8 8
      misago/threads/api/threadpoll.py
  82. 7 7
      misago/threads/api/threadposts.py
  83. 2 2
      misago/threads/api/threads.py
  84. 3 3
      misago/threads/middleware.py
  85. 4 4
      misago/threads/permissions/attachments.py
  86. 29 29
      misago/threads/permissions/bestanswers.py
  87. 37 37
      misago/threads/permissions/polls.py
  88. 25 25
      misago/threads/permissions/privatethreads.py
  89. 153 152
      misago/threads/permissions/threads.py
  90. 1 1
      misago/threads/search.py
  91. 26 25
      misago/threads/serializers/moderation.py
  92. 109 0
      misago/threads/test.py
  93. 4 14
      misago/threads/tests/test_attachments_api.py
  94. 58 31
      misago/threads/tests/test_attachments_middleware.py
  95. 27 21
      misago/threads/tests/test_attachmentview.py
  96. 34 36
      misago/threads/tests/test_emailnotification_middleware.py
  97. 12 14
      misago/threads/tests/test_events.py
  98. 18 14
      misago/threads/tests/test_floodprotection.py
  99. 9 7
      misago/threads/tests/test_floodprotection_middleware.py
  100. 6 13
      misago/threads/tests/test_gotoviews.py
  101. 0 18
      misago/threads/tests/test_post_mentions.py
  102. 13 19
      misago/threads/tests/test_privatethread_patch_api.py
  103. 41 45
      misago/threads/tests/test_privatethread_start_api.py
  104. 4 7
      misago/threads/tests/test_privatethread_view.py
  105. 0 32
      misago/threads/tests/test_privatethreads.py
  106. 35 25
      misago/threads/tests/test_privatethreads_api.py
  107. 7 9
      misago/threads/tests/test_privatethreads_lists.py
  108. 11 14
      misago/threads/tests/test_subscription_middleware.py
  109. 18 49
      misago/threads/tests/test_thread_bulkpatch_api.py
  110. 70 95
      misago/threads/tests/test_thread_editreply_api.py
  111. 98 186
      misago/threads/tests/test_thread_merge_api.py
  112. 226 368
      misago/threads/tests/test_thread_patch_api.py
  113. 0 25
      misago/threads/tests/test_thread_poll_api.py
  114. 28 14
      misago/threads/tests/test_thread_pollcreate_api.py
  115. 24 16
      misago/threads/tests/test_thread_polldelete_api.py
  116. 23 16
      misago/threads/tests/test_thread_polledit_api.py
  117. 22 18
      misago/threads/tests/test_thread_pollvotes_api.py
  118. 45 82
      misago/threads/tests/test_thread_postbulkdelete_api.py
  119. 4 25
      misago/threads/tests/test_thread_postbulkpatch_api.py
  120. 54 64
      misago/threads/tests/test_thread_postdelete_api.py
  121. 6 6
      misago/threads/tests/test_thread_postedits_api.py
  122. 5 4
      misago/threads/tests/test_thread_postlikes_api.py
  123. 59 49
      misago/threads/tests/test_thread_postmerge_api.py
  124. 50 94
      misago/threads/tests/test_thread_postmove_api.py
  125. 90 173
      misago/threads/tests/test_thread_postpatch_api.py
  126. 52 94
      misago/threads/tests/test_thread_postsplit_api.py
  127. 42 59
      misago/threads/tests/test_thread_reply_api.py
  128. 32 81
      misago/threads/tests/test_thread_start_api.py
  129. 71 140
      misago/threads/tests/test_threads_api.py
  130. 17 45
      misago/threads/tests/test_threads_bulkdelete_api.py
  131. 138 221
      misago/threads/tests/test_threads_editor_api.py
  132. 58 198
      misago/threads/tests/test_threads_merge_api.py
  133. 124 288
      misago/threads/tests/test_threadslists.py
  134. 143 160
      misago/threads/tests/test_threadview.py
  135. 3 3
      misago/threads/validators.py
  136. 6 6
      misago/threads/viewmodels/category.py
  137. 3 3
      misago/threads/viewmodels/post.py
  138. 4 4
      misago/threads/viewmodels/posts.py
  139. 8 8
      misago/threads/viewmodels/thread.py
  140. 17 16
      misago/threads/viewmodels/threads.py
  141. 1 1
      misago/threads/views/attachment.py
  142. 6 3
      misago/threads/views/goto.py
  143. 4 3
      misago/users/api/auth.py
  144. 5 4
      misago/users/api/userendpoints/signature.py
  145. 47 34
      misago/users/api/userendpoints/username.py
  146. 1 1
      misago/users/api/usernamechanges.py
  147. 10 10
      misago/users/api/users.py
  148. 2 2
      misago/users/apps.py
  149. 5 2
      misago/users/context_processors.py
  150. 3 3
      misago/users/models/rank.py
  151. 21 28
      misago/users/models/user.py
  152. 41 29
      misago/users/namechanges.py
  153. 2 2
      misago/users/online/utils.py
  154. 13 11
      misago/users/permissions/decorators.py
  155. 7 7
      misago/users/permissions/delete.py
  156. 28 28
      misago/users/permissions/moderation.py
  157. 13 15
      misago/users/permissions/profiles.py
  158. 1 1
      misago/users/profilefields/default.py
  159. 1 1
      misago/users/profilefields/serializers.py
  160. 1 1
      misago/users/search.py
  161. 9 7
      misago/users/serializers/auth.py
  162. 6 3
      misago/users/serializers/user.py
  163. 2 2
      misago/users/signatures.py
  164. 8 9
      misago/users/tests/test_bans.py
  165. 45 0
      misago/users/tests/test_getting_user_status.py
  166. 3 9
      misago/users/tests/test_joinip_profilefield.py
  167. 3 10
      misago/users/tests/test_lists_views.py
  168. 68 16
      misago/users/tests/test_namechanges.py
  169. 0 41
      misago/users/tests/test_online_utils.py
  170. 23 32
      misago/users/tests/test_profile_views.py
  171. 55 0
      misago/users/tests/test_rankadmin_views.py
  172. 26 27
      misago/users/tests/test_search.py
  173. 42 16
      misago/users/tests/test_signatures.py
  174. 4 1
      misago/users/tests/test_social_pipeline.py
  175. 4 29
      misago/users/tests/test_user_avatar_api.py
  176. 9 16
      misago/users/tests/test_user_details_api.py
  177. 7 14
      misago/users/tests/test_user_editdetails_api.py
  178. 7 25
      misago/users/tests/test_user_signature_api.py
  179. 5 34
      misago/users/tests/test_user_username_api.py
  180. 6 14
      misago/users/tests/test_usernamechanges_api.py
  181. 37 67
      misago/users/tests/test_users_api.py
  182. 1 1
      misago/users/viewmodels/posts.py
  183. 4 4
      misago/users/viewmodels/threads.py
  184. 5 1
      misago/users/views/admin/users.py
  185. 2 2
      misago/users/views/lists.py
  186. 4 4
      misago/users/views/profile.py

+ 4 - 2
devproject/settings.py

@@ -226,6 +226,7 @@ MIDDLEWARE = [
 
     'misago.cache.middleware.cache_versions_middleware',
     'misago.users.middleware.UserMiddleware',
+    'misago.acl.middleware.user_acl_middleware',
     'misago.core.middleware.ExceptionHandlerMiddleware',
     'misago.users.middleware.OnlineTrackerMiddleware',
     'misago.admin.middleware.AdminAuthMiddleware',
@@ -285,12 +286,13 @@ TEMPLATES = [
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.messages.context_processors.messages',
 
+                'misago.acl.context_processors.user_acl',
+                'misago.conf.context_processors.settings',
                 'misago.core.context_processors.site_address',
                 'misago.core.context_processors.momentjs_locale',
-                'misago.conf.context_processors.settings',
+                'misago.legal.context_processors.legal_links',
                 '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',

+ 2 - 2
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"

+ 0 - 65
misago/acl/api.py

@@ -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

+ 1 - 0
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'

+ 0 - 0
misago/acl/builder.py → misago/acl/buildacl.py


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

+ 0 - 1
misago/acl/constants.py

@@ -1 +0,0 @@
-ACL_CACHEBUSTER = 'misago_acl'

+ 2 - 0
misago/acl/context_processors.py

@@ -0,0 +1,2 @@
+def user_acl(request):
+    return {"user_acl": request.user_acl}

+ 12 - 0
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

+ 2 - 1
misago/acl/migrations/0004_cache_version.py

@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 from django.db import migrations
 
+from misago.acl import ACL_CACHE
 from misago.cache.operations import StartCacheVersioning
 
 
@@ -12,5 +13,5 @@ class Migration(migrations.Migration):
     ]
 
     operations = [
-        StartCacheVersioning("acl")
+        StartCacheVersioning(ACL_CACHE)
     ]

+ 3 - 3
misago/acl/models.py

@@ -2,7 +2,7 @@ from django.contrib.postgres.fields import JSONField
 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 @@ class BaseRole(models.Model):
 
     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)
 
 

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

+ 1 - 1
misago/acl/panels.py

@@ -24,7 +24,7 @@ class MisagoACLPanel(Panel):
             misago_user = None
 
         try:
-            misago_acl = misago_user.acl_cache
+            misago_acl = request.user_acl
         except AttributeError:
             misago_acl = {}
 

+ 15 - 13
misago/acl/providers.py

@@ -5,13 +5,13 @@ from misago.conf import settings
 
 _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 @@ class PermissionProviders(object):
         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 @@ class PermissionProviders(object):
             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 @@ class PermissionProviders(object):
         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

+ 56 - 0
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,
+        )

+ 0 - 26
misago/acl/tests/test_api.py

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

+ 93 - 0
misago/acl/tests/test_getting_user_acl.py

@@ -0,0 +1,93 @@
+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.users.models import AnonymousUser
+
+User = get_user_model()
+
+cache_versions = {"acl": "abcdefgh"}
+
+
+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()

+ 78 - 0
misago/acl/tests/test_patching_user_acl.py

@@ -0,0 +1,78 @@
+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
+
+User = get_user_model()
+
+cache_versions = {"acl": "abcdefgh"}
+
+
+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

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

+ 37 - 0
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 @@ class RoleAdminViewsTests(AdminTestCase):
         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 @@ class RoleAdminViewsTests(AdminTestCase):
         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,
+                })
+            )

+ 23 - 0
misago/acl/tests/test_serializing_user_acl.py

@@ -0,0 +1,23 @@
+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
+
+User = get_user_model()
+
+cache_versions = {"acl": "abcdefgh"}
+
+
+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)

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

+ 12 - 0
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}

+ 29 - 0
misago/acl/tests/test_user_acl_middleware.py

@@ -0,0 +1,29 @@
+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
+
+User = get_user_model()
+
+cache_versions = {"acl": "abcdefgh"}
+
+
+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()

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

+ 30 - 0
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

+ 0 - 15
misago/acl/version.py

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

+ 1 - 3
misago/cache/middleware.py

@@ -1,12 +1,10 @@
-from django.utils.functional import SimpleLazyObject
-
 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 = SimpleLazyObject(get_cache_versions)
+        request.cache_versions = get_cache_versions()
         return get_response(request)
 
     return middleware

+ 21 - 0
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
+        

+ 25 - 0
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")

+ 2 - 30
misago/cache/tests/test_cache_versions_middleware.py

@@ -1,7 +1,6 @@
-from unittest.mock import Mock, PropertyMock, patch
+from unittest.mock import Mock
 
 from django.test import TestCase
-from django.utils.functional import SimpleLazyObject
 
 from misago.cache.versions import CACHE_NAME
 from misago.cache.middleware import cache_versions_middleware
@@ -11,21 +10,9 @@ class MiddlewareTests(TestCase):
     def test_middleware_sets_attr_on_request(self):
         get_response = Mock()
         request = Mock()
-        cache_versions = PropertyMock()
-        type(request).cache_versions = cache_versions
         middleware = cache_versions_middleware(get_response)
         middleware(request)
-        cache_versions.assert_called_once()
-
-    def test_attr_set_by_middleware_on_request_is_lazy_object(self):
-        get_response = Mock()
-        request = Mock()
-        cache_versions = PropertyMock()
-        type(request).cache_versions = cache_versions
-        middleware = cache_versions_middleware(get_response)
-        middleware(request)
-        attr_value = cache_versions.call_args[0][0]
-        assert isinstance(attr_value, SimpleLazyObject)
+        assert request.cache_versions
 
     def test_middleware_calls_get_response(self):
         get_response = Mock()
@@ -33,18 +20,3 @@ class MiddlewareTests(TestCase):
         middleware = cache_versions_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 = cache_versions_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 = cache_versions_middleware(get_response)
-        middleware(request)
-        cache_get.assert_not_called()

+ 2 - 3
misago/cache/tests/test_getting_cache_versions.py

@@ -32,13 +32,12 @@ class CacheVersionsTests(TestCase):
     @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):
-        assert get_cache_versions()
+        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):
-        assert get_cache_versions()
-        db_caches = get_cache_versions_from_db()
+        get_cache_versions()
         cache_set.assert_not_called()

+ 1 - 1
misago/categories/api.py

@@ -7,5 +7,5 @@ from .utils import get_categories_tree
 
 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)

+ 2 - 2
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 @@ class Command(BaseCommand):
         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.")

+ 2 - 2
misago/categories/models.py

@@ -3,7 +3,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 
 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 @@ class Category(MPTTModel):
 
     def delete(self, *args, **kwargs):
         Category.objects.clear_cache()
-        acl_version.invalidate()
+        clear_acl_cache()
         return super().delete(*args, **kwargs)
 
     def synchronize(self):

+ 11 - 13
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})

+ 58 - 0
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 @@ class CategoryAdminViewsTests(CategoryAdminTestCase):
         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 @@ class CategoryAdminViewsTests(CategoryAdminTestCase):
         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 @@ class CategoryAdminDeleteViewTests(CategoryAdminTestCase):
             (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': '',
+                }
+            )

+ 7 - 0
misago/categories/tests/test_fixcategoriestree.py

@@ -3,6 +3,8 @@ from io import StringIO
 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 @@ class FixCategoriesTreeTests(TestCase):
             (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()
+

+ 67 - 0
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 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         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 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         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 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         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 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         )
         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,
+                },
+            )

+ 18 - 10
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.users.testutils import AuthenticatedUserTestCase
 
+cache_versions = {"acl": "abcdefgh"}
+
+
+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):
@@ -84,15 +96,11 @@ class CategoriesUtilsTests(AuthenticatedUserTestCase):
             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 +109,19 @@ class CategoriesUtilsTests(AuthenticatedUserTestCase):
 
     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)

+ 26 - 46
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 @@ class CategoryAPIViewsTests(AuthenticatedUserTestCase):
     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) == []

+ 6 - 6
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']:

+ 2 - 2
misago/categories/views/categoriesadmin.py

@@ -2,7 +2,7 @@ from django.contrib import messages
 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 @@ class CategoryFormMixin(object):
             if copied_acls:
                 RoleCategoryACL.objects.bulk_create(copied_acls)
 
-        acl_version.invalidate()
+        clear_acl_cache()
         messages.success(request, self.message_submit % {'name': target.name})
 
 

+ 1 - 1
misago/categories/views/categorieslist.py

@@ -6,7 +6,7 @@ from misago.categories.utils import get_categories_tree
 
 
 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,

+ 3 - 3
misago/categories/views/permsadmin.py

@@ -2,7 +2,7 @@ from django.contrib import messages
 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 @@ class CategoryPermissions(CategoryAdmin, generic.ModelFormView):
             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 @@ class RoleCategoriesACL(RoleAdmin, generic.ModelFormView):
             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})

+ 14 - 12
misago/core/tests/test_errorpages.py

@@ -4,9 +4,10 @@ from django.test import Client, TestCase, override_settings
 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.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 +74,21 @@ class ErrorPageViewsTests(TestCase):
         self.assertContains(response, "Banned in auth!", status_code=403)
 
 
+def test_request(url):
+    request = RequestFactory().get(url)
+    request.cache_versions = {"acl": "abcdefgh"}
+    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"""

+ 17 - 13
misago/core/tests/test_exceptionhandler_middleware.py

@@ -3,27 +3,31 @@ from django.test import TestCase
 from django.test.client import RequestFactory
 from django.urls import reverse
 
-from misago.core.middleware import ExceptionHandlerMiddleware
+from misago.acl.useracl import get_user_acl
 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 = {"acl": "abcdefgh"}
+    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

+ 2 - 2
misago/faker/management/commands/createfakecategories.py

@@ -5,7 +5,7 @@ from faker import Factory
 
 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 @@ class Command(BaseCommand):
             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))

+ 4 - 4
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']

+ 3 - 3
misago/readtracker/categoriestracker.py

@@ -4,7 +4,7 @@ from misago.threads.permissions import exclude_invisible_posts, exclude_invisibl
 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)
 

+ 25 - 26
misago/readtracker/tests/test_categoriestracker.py

@@ -4,15 +4,16 @@ from django.contrib.auth import get_user_model
 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.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 = {"acl": "abcdefgh"}
 
 
 class AnonymousUser(object):
@@ -22,24 +23,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 +46,7 @@ class CategoriesTrackerTests(TestCase):
         """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 +55,7 @@ class CategoriesTrackerTests(TestCase):
         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 +63,7 @@ class CategoriesTrackerTests(TestCase):
         """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 +72,7 @@ class CategoriesTrackerTests(TestCase):
         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 +82,7 @@ class CategoriesTrackerTests(TestCase):
 
         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 +93,7 @@ class CategoriesTrackerTests(TestCase):
         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 +104,7 @@ class CategoriesTrackerTests(TestCase):
 
         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 +119,7 @@ class CategoriesTrackerTests(TestCase):
             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 +135,7 @@ class CategoriesTrackerTests(TestCase):
             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 +144,7 @@ class CategoriesTrackerTests(TestCase):
         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 +159,7 @@ class CategoriesTrackerTests(TestCase):
             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 +175,7 @@ class CategoriesTrackerTests(TestCase):
             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 +191,7 @@ class CategoriesTrackerTests(TestCase):
             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 +203,7 @@ class CategoriesTrackerTests(TestCase):
             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 +216,7 @@ class CategoriesTrackerTests(TestCase):
             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 +228,6 @@ class CategoriesTrackerTests(TestCase):
             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)

+ 23 - 24
misago/readtracker/tests/test_threadstracker.py

@@ -4,16 +4,17 @@ from django.contrib.auth import get_user_model
 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.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 = {"acl": "abcdefgh"}
 
 
 class AnonymousUser(object):
@@ -23,26 +24,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 +49,7 @@ class ThreadsTrackerTests(TestCase):
         """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 +58,7 @@ class ThreadsTrackerTests(TestCase):
         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 +66,7 @@ class ThreadsTrackerTests(TestCase):
         """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 +75,7 @@ class ThreadsTrackerTests(TestCase):
         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 +85,7 @@ class ThreadsTrackerTests(TestCase):
 
         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 +96,7 @@ class ThreadsTrackerTests(TestCase):
         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 +107,7 @@ class ThreadsTrackerTests(TestCase):
 
         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 +122,7 @@ class ThreadsTrackerTests(TestCase):
             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 +138,7 @@ class ThreadsTrackerTests(TestCase):
             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 +147,7 @@ class ThreadsTrackerTests(TestCase):
         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 +162,7 @@ class ThreadsTrackerTests(TestCase):
             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 +178,6 @@ class ThreadsTrackerTests(TestCase):
             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)

+ 2 - 2
misago/readtracker/threadstracker.py

@@ -4,7 +4,7 @@ from misago.threads.permissions import exclude_invisible_posts
 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)
 

+ 1 - 1
misago/search/api.py

@@ -15,7 +15,7 @@ from .searchproviders import searchproviders
 @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)

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

+ 2 - 3
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 @@ class SearchApiTests(AuthenticatedUserTestCase):
 
         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(), {

+ 6 - 10
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 @@ class LandingTests(AuthenticatedUserTestCase):
 
         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 @@ class SearchTests(AuthenticatedUserTestCase):
 
         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 @@ class SearchTests(AuthenticatedUserTestCase):
         """provider displays no script page"""
         response = self.client.get(
             reverse('misago:search', kwargs={
-                'search_provider': 'threads',
+                'search_provider': 'users',
             })
         )
 

+ 2 - 2
misago/search/views.py

@@ -9,7 +9,7 @@ from .searchproviders import searchproviders
 
 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:

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

@@ -93,7 +93,7 @@
         <i class="material-icon">group</i>
       </a>
     </li>
-    {% if user.acl_cache.can_search %}
+    {% if user_acl.can_search %}
       <li>
         <a href="{% url 'misago:search' %}">
           <i class="material-icon">search</i>

+ 1 - 1
misago/templates/misago/threadslist/tabs.html

@@ -27,7 +27,7 @@
           {% trans "Subscribed" %}
         </a>
       </li>
-      {% if user.acl_cache.can_see_unapproved_content_lists and not hide_unapproved %}
+      {% if user_acl.can_see_unapproved_content_lists and not hide_unapproved %}
         <li{% if list_type == 'unapproved' %} class="active"{% endif %}>
           <a href="{{ category.get_absolute_url }}unapproved/">
             {% trans "Unapproved" %}

+ 4 - 4
misago/threads/api/attachments.py

@@ -5,7 +5,7 @@ from django.core.exceptions import PermissionDenied, ValidationError
 from django.template.defaultfilters import filesizeformat
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.threads.models import Attachment, AttachmentType
 from misago.threads.serializers import AttachmentSerializer
 from misago.users.audittrail import create_audit_trail
@@ -16,7 +16,7 @@ IMAGE_EXTENSIONS = ('jpg', 'jpeg', 'png', 'gif')
 
 class AttachmentViewSet(viewsets.ViewSet):
     def create(self, request):
-        if not request.user.acl_cache['max_attachment_size']:
+        if not request.user_acl['max_attachment_size']:
             raise PermissionDenied(_("You don't have permission to upload new files."))
 
         try:
@@ -31,7 +31,7 @@ class AttachmentViewSet(viewsets.ViewSet):
 
         user_roles = set(r.pk for r in request.user.get_roles())
         filetype = validate_filetype(upload, user_roles)
-        validate_filesize(upload, filetype, request.user.acl_cache['max_attachment_size'])
+        validate_filesize(upload, filetype, request.user_acl['max_attachment_size'])
 
         attachment = Attachment(
             secret=Attachment.generate_new_secret(),
@@ -52,7 +52,7 @@ class AttachmentViewSet(viewsets.ViewSet):
             attachment.set_file(upload)
 
         attachment.save()
-        add_acl(request.user, attachment)
+        add_acl_to_obj(request.user_acl, attachment)
 
         create_audit_trail(request, attachment)
 

+ 3 - 3
misago/threads/api/pollvotecreateendpoint.py

@@ -2,7 +2,7 @@ from copy import deepcopy
 
 from rest_framework.response import Response
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.threads.permissions import allow_vote_poll
 from misago.threads.serializers import PollSerializer, NewVoteSerializer
 
@@ -10,7 +10,7 @@ from misago.threads.serializers import PollSerializer, NewVoteSerializer
 def poll_vote_create(request, thread, poll):
     poll.make_choices_votes_aware(request.user)
 
-    allow_vote_poll(request.user, poll)
+    allow_vote_poll(request.user_acl, poll)
 
     serializer = NewVoteSerializer(
         data={
@@ -33,7 +33,7 @@ def poll_vote_create(request, thread, poll):
     remove_user_votes(request.user, poll, serializer.data['choices'])
     set_new_votes(request, poll, serializer.data['choices'])
 
-    add_acl(request.user, poll)
+    add_acl_to_obj(request.user_acl, poll)
     serialized_poll = PollSerializer(poll).data
 
     poll.choices = list(map(presave_clean_choice, deepcopy(poll.choices)))

+ 4 - 4
misago/threads/api/postendpoints/delete.py

@@ -18,10 +18,10 @@ DELETE_LIMIT = settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL
 
 def delete_post(request, thread, post):
     if post.is_event:
-        allow_delete_event(request.user, post)
+        allow_delete_event(request.user_acl, post)
     else:
-        allow_delete_best_answer(request.user, post)
-        allow_delete_post(request.user, post)
+        allow_delete_best_answer(request.user_acl, post)
+        allow_delete_post(request.user_acl, post)
 
     moderation.delete_post(request.user, post)
 
@@ -34,7 +34,7 @@ def delete_bulk(request, thread):
         data={'posts': request.data},
         context={
             'thread': thread,
-            'user': request.user,
+            'user_acl': request.user_acl,
         },
     )
 

+ 2 - 2
misago/threads/api/postendpoints/edits.py

@@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404
 from django.utils import timezone
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.shortcuts import get_int_or_404
 from misago.markup import common_flavour
 from misago.threads.checksums import update_post_checksum
@@ -71,7 +71,7 @@ def revert_post_endpoint(request, post):
     post.is_new = False
     post.edits = post_edits + 1
 
-    add_acl(request.user, post)
+    add_acl_to_obj(request.user_acl, post)
 
     if post.poster:
         make_users_status_aware(request, [post.poster])

+ 3 - 3
misago/threads/api/postendpoints/merge.py

@@ -3,7 +3,7 @@ from rest_framework.response import Response
 from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.threads.serializers import MergePostsSerializer, PostSerializer
 
 
@@ -15,7 +15,7 @@ def posts_merge_endpoint(request, thread):
         data=request.data,
         context={
             'thread': thread,
-            'user': request.user,
+            'user_acl': request.user_acl,
         },
     )
 
@@ -55,6 +55,6 @@ def posts_merge_endpoint(request, thread):
     first_post.thread = thread
     first_post.category = thread.category
 
-    add_acl(request.user, first_post)
+    add_acl_to_obj(request.user_acl, first_post)
 
     return Response(PostSerializer(first_post, context={'user': request.user}).data)

+ 4 - 4
misago/threads/api/postendpoints/patch_event.py

@@ -1,7 +1,7 @@
 from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.apipatch import ApiPatch
 from misago.threads.moderation import posts as moderation
 from misago.threads.permissions import allow_hide_event, allow_unhide_event
@@ -13,7 +13,7 @@ event_patch_dispatcher = ApiPatch()
 def patch_acl(request, event, value):
     """useful little op that updates event acl to current state"""
     if value:
-        add_acl(request.user, event)
+        add_acl_to_obj(request.user_acl, event)
         return {'acl': event.acl}
     else:
         return {'acl': None}
@@ -24,10 +24,10 @@ event_patch_dispatcher.add('acl', patch_acl)
 
 def patch_is_hidden(request, event, value):
     if value:
-        allow_hide_event(request.user, event)
+        allow_hide_event(request.user_acl, event)
         moderation.hide_post(request.user, event)
     else:
-        allow_unhide_event(request.user, event)
+        allow_unhide_event(request.user_acl, event)
         moderation.unhide_post(request.user, event)
 
     return {'is_hidden': event.is_hidden}

+ 10 - 8
misago/threads/api/postendpoints/patch_post.py

@@ -4,7 +4,7 @@ from rest_framework.response import Response
 from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.conf import settings
 from misago.core.apipatch import ApiPatch
 from misago.threads.models import PostLike
@@ -23,7 +23,7 @@ post_patch_dispatcher = ApiPatch()
 def patch_acl(request, post, value):
     """useful little op that updates post acl to current state"""
     if value:
-        add_acl(request.user, post)
+        add_acl_to_obj(request.user_acl, post)
         return {'acl': post.acl}
     else:
         return {'acl': None}
@@ -89,7 +89,7 @@ post_patch_dispatcher.replace('is-liked', patch_is_liked)
 
 
 def patch_is_protected(request, post, value):
-    allow_protect_post(request.user, post)
+    allow_protect_post(request.user_acl, post)
     if value:
         moderation.protect_post(request.user, post)
     else:
@@ -101,7 +101,7 @@ post_patch_dispatcher.replace('is-protected', patch_is_protected)
 
 
 def patch_is_unapproved(request, post, value):
-    allow_approve_post(request.user, post)
+    allow_approve_post(request.user_acl, post)
 
     if value:
         raise PermissionDenied(_("Content approval can't be reversed."))
@@ -116,11 +116,11 @@ post_patch_dispatcher.replace('is-unapproved', patch_is_unapproved)
 
 def patch_is_hidden(request, post, value):
     if value is True:
-        allow_hide_post(request.user, post)
-        allow_hide_best_answer(request.user, post)
+        allow_hide_post(request.user_acl, post)
+        allow_hide_best_answer(request.user_acl, post)
         moderation.hide_post(request.user, post)
     elif value is False:
-        allow_unhide_post(request.user, post)
+        allow_unhide_post(request.user_acl, post)
         moderation.unhide_post(request.user, post)
 
     return {'is_hidden': post.is_hidden}
@@ -169,7 +169,9 @@ def bulk_patch_endpoint(request, thread):
 
 
 def clean_posts_for_patch(request, thread, posts_ids):
-    posts_queryset = exclude_invisible_posts(request.user, thread.category, thread.post_set)
+    posts_queryset = exclude_invisible_posts(
+        request.user_acl, thread.category, thread.post_set
+    )
     posts_queryset = posts_queryset.filter(
         id__in=posts_ids,
         is_event=False,

+ 1 - 1
misago/threads/api/postendpoints/read.py

@@ -12,7 +12,7 @@ def post_read_endpoint(request, thread, post):
             thread.subscription.last_read_on = post.posted_on
             thread.subscription.save()
 
-    threadstracker.make_read_aware(request.user, thread)
+    threadstracker.make_read_aware(request.user, request.user_acl, thread)
 
     # send signal if post read marked thread as read
     # used in some places, eg. syncing unread thread count

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

@@ -16,7 +16,7 @@ def posts_split_endpoint(request, thread):
         data=request.data,
         context={
             'thread': thread,
-            'user': request.user,
+            'user_acl': request.user_acl,
         },
     )
 

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

@@ -26,7 +26,12 @@ class PostingEndpoint(object):
 
         # build kwargs dict for passing to middlewares
         self.kwargs = kwargs
-        self.kwargs.update({'mode': mode, 'request': request, 'user': request.user})
+        self.kwargs.update({
+            'mode': mode,
+            'request': request,
+            'user': request.user,
+            'user_acl': request.user_acl,
+        })
 
         self.__dict__.update(kwargs)
 

+ 6 - 5
misago/threads/api/postingendpoint/attachments.py

@@ -3,7 +3,7 @@ from rest_framework import serializers
 from django.utils.translation import gettext as _
 from django.utils.translation import ngettext
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.conf import settings
 from misago.threads.serializers import AttachmentSerializer
 
@@ -12,7 +12,7 @@ from . import PostingEndpoint, PostingMiddleware
 
 class AttachmentsMiddleware(PostingMiddleware):
     def use_this_middleware(self):
-        return bool(self.user.acl_cache['max_attachment_size'])
+        return bool(self.user_acl['max_attachment_size'])
 
     def get_serializer(self):
         return AttachmentsSerializer(
@@ -20,6 +20,7 @@ class AttachmentsMiddleware(PostingMiddleware):
             context={
                 'mode': self.mode,
                 'user': self.user,
+                'user_acl': self.user_acl,
                 'post': self.post,
             }
         )
@@ -41,7 +42,7 @@ class AttachmentsSerializer(serializers.Serializer):
         validate_attachments_count(ids)
 
         attachments = self.get_initial_attachments(
-            self.context['mode'], self.context['user'], self.context['post']
+            self.context['mode'], self.context['user_acl'], self.context['post']
         )
         new_attachments = self.get_new_attachments(self.context['user'], ids)
 
@@ -69,12 +70,12 @@ class AttachmentsSerializer(serializers.Serializer):
             self.final_attachments += new_attachments
             self.final_attachments.sort(key=lambda a: a.pk, reverse=True)
 
-    def get_initial_attachments(self, mode, user, post):
+    def get_initial_attachments(self, mode, user_acl, post):
         attachments = []
         if mode == PostingEndpoint.EDIT:
             queryset = post.attachment_set.select_related('filetype')
             attachments = list(queryset)
-            add_acl(user, attachments)
+            add_acl_to_obj(user_acl, attachments)
         return attachments
 
     def get_new_attachments(self, user, ids):

+ 10 - 10
misago/threads/api/postingendpoint/category.py

@@ -4,7 +4,7 @@ from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext_lazy
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.categories.permissions import can_browse_category, can_see_category
@@ -23,12 +23,12 @@ class CategoryMiddleware(PostingMiddleware):
         return False
 
     def get_serializer(self):
-        return CategorySerializer(self.user, data=self.request.data)
+        return CategorySerializer(self.user_acl, data=self.request.data)
 
     def pre_save(self, serializer):
         category = serializer.category_cache
 
-        add_acl(self.user, category)
+        add_acl_to_obj(self.user_acl, category)
 
         # set flags for savechanges middleware
         category.update_all = False
@@ -47,8 +47,8 @@ class CategorySerializer(serializers.Serializer):
         }
     )
 
-    def __init__(self, user, *args, **kwargs):
-        self.user = user
+    def __init__(self, user_acl, *args, **kwargs):
+        self.user_acl = user_acl
         self.category_cache = None
 
         super().__init__(*args, **kwargs)
@@ -59,15 +59,15 @@ class CategorySerializer(serializers.Serializer):
                 pk=value, tree_id=trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
             )
 
-            can_see = can_see_category(self.user, self.category_cache)
-            can_browse = can_browse_category(self.user, self.category_cache)
+            can_see = can_see_category(self.user_acl, self.category_cache)
+            can_browse = can_browse_category(self.user_acl, self.category_cache)
             if not (self.category_cache.level and can_see and can_browse):
                 raise PermissionDenied(_("Selected category is invalid."))
 
-            allow_start_thread(self.user, self.category_cache)
-        except PermissionDenied as e:
-            raise serializers.ValidationError(e.args[0])
+            allow_start_thread(self.user_acl, self.category_cache)
         except Category.DoesNotExist:
             raise serializers.ValidationError(
                 _("Selected category doesn't exist or you don't have permission to browse it.")
             )
+        except PermissionDenied as e:
+            raise serializers.ValidationError(e.args[0])

+ 6 - 4
misago/threads/api/postingendpoint/emailnotification.py

@@ -1,5 +1,6 @@
 from django.utils.translation import gettext as _
 
+from misago.acl import useracl
 from misago.core.mail import build_mail, send_messages
 from misago.threads.permissions import can_see_post, can_see_thread
 
@@ -23,15 +24,16 @@ class EmailNotificationMiddleware(PostingMiddleware):
 
         notifications = []
         for subscription in queryset.iterator():
-            if self.notify_user_of_post(subscription.user):
+            if self.subscriber_can_see_post(subscription.user):
                 notifications.append(self.build_mail(subscription.user))
 
         if notifications:
             send_messages(notifications)
 
-    def notify_user_of_post(self, subscriber):
-        see_thread = can_see_thread(subscriber, self.thread)
-        see_post = can_see_post(subscriber, self.post)
+    def subscriber_can_see_post(self, subscriber):
+        user_acl = useracl.get_user_acl(subscriber, self.request.cache_versions)
+        see_thread = can_see_thread(user_acl, self.thread)
+        see_post = can_see_post(user_acl, self.post)
         return see_thread and see_post
 
     def build_mail(self, subscriber):

+ 4 - 2
misago/threads/api/postingendpoint/floodprotection.py

@@ -13,8 +13,10 @@ MIN_POSTING_PAUSE = 3
 
 class FloodProtectionMiddleware(PostingMiddleware):
     def use_this_middleware(self):
-        return not self.user.acl_cache['can_omit_flood_protection'
-                                       ] and self.mode != PostingEndpoint.EDIT
+        return (
+            not self.user_acl['can_omit_flood_protection'] and
+            self.mode != PostingEndpoint.EDIT
+        )
 
     def interrupt_posting(self, serializer):
         now = timezone.now()

+ 12 - 3
misago/threads/api/postingendpoint/participants.py

@@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
 from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _, ngettext
 
+from misago.acl import useracl
 from misago.categories import PRIVATE_THREADS_ROOT_NAME
 from misago.threads.participants import add_participants, set_owner
 from misago.threads.permissions import allow_message_user
@@ -21,7 +22,14 @@ class ParticipantsMiddleware(PostingMiddleware):
         return False
 
     def get_serializer(self):
-        return ParticipantsSerializer(data=self.request.data, context={'user': self.user})
+        return ParticipantsSerializer(
+            data=self.request.data,
+            context={
+                'request': self.request,
+                'user': self.user,
+                'user_acl': self.user_acl,
+            },
+        )
 
     def save(self, serializer):
         set_owner(self.thread, self.user)
@@ -51,7 +59,7 @@ class ParticipantsSerializer(serializers.Serializer):
         if not clean_usernames:
             raise serializers.ValidationError(_("You have to enter user names."))
 
-        max_participants = self.context['user'].acl_cache['max_private_thread_participants']
+        max_participants = self.context['user_acl']['max_private_thread_participants']
         if max_participants and len(clean_usernames) > max_participants:
             message = ngettext(
                 "You can't add more than %(users)s user to private thread (you've added %(added)s).",
@@ -71,7 +79,8 @@ class ParticipantsSerializer(serializers.Serializer):
         users = []
         for user in UserModel.objects.filter(slug__in=usernames):
             try:
-                allow_message_user(self.context['user'], user)
+                user_acl = useracl.get_user_acl(user, self.context["request"].cache_versions)
+                allow_message_user(self.context['user_acl'], user, user_acl)
             except PermissionDenied as e:
                 raise serializers.ValidationError(str(e))
             users.append(user)

+ 2 - 2
misago/threads/api/postingendpoint/privatethread.py

@@ -1,4 +1,4 @@
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories import PRIVATE_THREADS_ROOT_NAME
 from misago.categories.models import Category
 
@@ -16,7 +16,7 @@ class PrivateThreadMiddleware(PostingMiddleware):
     def pre_save(self, serializer):
         category = Category.objects.private_threads()
 
-        add_acl(self.user, category)
+        add_acl_to_obj(self.user_acl, category)
 
         # set flags for savechanges middleware
         category.update_all = False

+ 1 - 1
misago/threads/api/threadendpoints/delete.py

@@ -10,7 +10,7 @@ from misago.threads.serializers import DeleteThreadsSerializer
 
 @transaction.atomic
 def delete_thread(request, thread):
-    allow_delete_thread(request.user, thread)
+    allow_delete_thread(request.user_acl, thread)
     moderation.delete_thread(request.user, thread)
     return Response({})
 

+ 4 - 4
misago/threads/api/threadendpoints/editor.py

@@ -3,7 +3,7 @@ from rest_framework.response import Response
 from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.threads.permissions import can_start_thread
@@ -19,15 +19,15 @@ def thread_start_editor(request):
     categories = []
 
     queryset = Category.objects.filter(
-        pk__in=request.user.acl_cache['browseable_categories'],
+        pk__in=request.user_acl['browseable_categories'],
         tree_id=trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
     ).order_by('-lft')
 
     for category in queryset:
-        add_acl(request.user, category)
+        add_acl_to_obj(request.user_acl, category)
 
         post = False
-        if can_start_thread(request.user, category):
+        if can_start_thread(request.user_acl, category):
             post = {
                 'close': bool(category.acl['can_close_threads']),
                 'hide': bool(category.acl['can_hide_threads']),

+ 5 - 7
misago/threads/api/threadendpoints/merge.py

@@ -4,7 +4,7 @@ from rest_framework.response import Response
 from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.threads.events import record_event
 from misago.threads.mergeconflict import MergeConflict
 from misago.threads.models import Thread
@@ -15,7 +15,7 @@ from misago.threads.serializers import (
 
 
 def thread_merge_endpoint(request, thread, viewmodel):
-    allow_merge_thread(request.user, thread)
+    allow_merge_thread(request.user_acl, thread)
 
     serializer = MergeThreadSerializer(
         data=request.data,
@@ -88,9 +88,7 @@ def thread_merge_endpoint(request, thread, viewmodel):
 def threads_merge_endpoint(request):
     serializer = MergeThreadsSerializer(
         data=request.data,
-        context={
-            'user': request.user
-        },
+        context={'user_acl': request.user_acl},
     )
 
     if not serializer.is_valid():
@@ -108,7 +106,7 @@ def threads_merge_endpoint(request):
 
     for thread in threads:
         try:
-            allow_merge_thread(request.user, thread)
+            allow_merge_thread(request.user_acl, thread)
         except PermissionDenied as e:
             invalid_threads.append({
                 'id': thread.pk,
@@ -191,5 +189,5 @@ def merge_threads(request, validated_data, threads, merge_conflict):
     new_thread.is_read = False
     new_thread.subscription = None
 
-    add_acl(request.user, new_thread)
+    add_acl_to_obj(request.user_acl, new_thread)
     return new_thread

+ 23 - 21
misago/threads/api/threadendpoints/patch.py

@@ -7,7 +7,8 @@ from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl import useracl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.categories.permissions import allow_browse_category, allow_see_category
 from misago.categories.serializers import CategorySerializer
@@ -37,7 +38,7 @@ thread_patch_dispatcher = ApiPatch()
 def patch_acl(request, thread, value):
     """useful little op that updates thread acl to current state"""
     if value:
-        add_acl(request.user, thread)
+        add_acl_to_obj(request.user_acl, thread)
         return {'acl': thread.acl}
     else:
         return {'acl': None}
@@ -57,7 +58,7 @@ def patch_title(request, thread, value):
     except ValidationError as e:
         raise PermissionDenied(e.args[0])
 
-    allow_edit_thread(request.user, thread)
+    allow_edit_thread(request.user_acl, thread)
 
     moderation.change_thread_title(request, thread, value_cleaned)
     return {'title': thread.title}
@@ -67,7 +68,7 @@ thread_patch_dispatcher.replace('title', patch_title)
 
 
 def patch_weight(request, thread, value):
-    allow_pin_thread(request.user, thread)
+    allow_pin_thread(request.user_acl, thread)
 
     if not thread.acl.get('can_pin_globally') and thread.weight == 2:
         raise PermissionDenied(_("You can't change globally pinned threads weights in this category."))
@@ -89,17 +90,17 @@ thread_patch_dispatcher.replace('weight', patch_weight)
 
 
 def patch_move(request, thread, value):
-    allow_move_thread(request.user, thread)
+    allow_move_thread(request.user_acl, thread)
 
     category_pk = get_int_or_404(value)
     new_category = get_object_or_404(
         Category.objects.all_categories().select_related('parent'), pk=category_pk
     )
 
-    add_acl(request.user, new_category)
-    allow_see_category(request.user, new_category)
-    allow_browse_category(request.user, new_category)
-    allow_start_thread(request.user, new_category)
+    add_acl_to_obj(request.user_acl, new_category)
+    allow_see_category(request.user_acl, new_category)
+    allow_browse_category(request.user_acl, new_category)
+    allow_start_thread(request.user_acl, new_category)
 
     if new_category == thread.category:
         raise PermissionDenied(_("You can't move thread to the category it's already in."))
@@ -123,7 +124,7 @@ thread_patch_dispatcher.replace('flatten-categories', patch_flatten_categories)
 
 
 def patch_is_unapproved(request, thread, value):
-    allow_approve_thread(request.user, thread)
+    allow_approve_thread(request.user_acl, thread)
 
     if value:
         raise PermissionDenied(_("Content approval can't be reversed."))
@@ -159,10 +160,10 @@ thread_patch_dispatcher.replace('is-closed', patch_is_closed)
 
 def patch_is_hidden(request, thread, value):
     if value:
-        allow_hide_thread(request.user, thread)
+        allow_hide_thread(request.user_acl, thread)
         moderation.hide_thread(request, thread)
     else:
-        allow_unhide_thread(request.user, thread)
+        allow_unhide_thread(request.user_acl, thread)
         moderation.unhide_thread(request, thread)
 
     return {'is_hidden': thread.is_hidden}
@@ -205,20 +206,20 @@ def patch_best_answer(request, thread, value):
     except (TypeError, ValueError):
         raise PermissionDenied(_("A valid integer is required."))
 
-    allow_mark_best_answer(request.user, thread)
+    allow_mark_best_answer(request.user_acl, thread)
 
     post = get_object_or_404(thread.post_set, id=post_id)
     post.category = thread.category
     post.thread = thread
 
-    allow_see_post(request.user, post)
-    allow_mark_as_best_answer(request.user, post)
+    allow_see_post(request.user_acl, post)
+    allow_mark_as_best_answer(request.user_acl, post)
 
     if post.is_best_answer:
         raise PermissionDenied(_("This post is already marked as thread's best answer."))
 
     if thread.has_best_answer:
-        allow_change_best_answer(request.user, thread)
+        allow_change_best_answer(request.user_acl, thread)
         
     thread.set_best_answer(request.user, post)
     thread.save()
@@ -250,7 +251,7 @@ def patch_unmark_best_answer(request, thread, value):
         raise PermissionDenied(
             _("This post can't be unmarked because it's not currently marked as best answer."))
 
-    allow_unmark_best_answer(request.user, thread)
+    allow_unmark_best_answer(request.user_acl, thread)
     thread.clear_best_answer()
     thread.save()
 
@@ -268,7 +269,7 @@ thread_patch_dispatcher.remove('best-answer', patch_unmark_best_answer)
 
 
 def patch_add_participant(request, thread, value):
-    allow_add_participants(request.user, thread)
+    allow_add_participants(request.user_acl, thread)
 
     try:
         username = str(value).strip().lower()
@@ -281,7 +282,8 @@ def patch_add_participant(request, thread, value):
     if participant in [p.user for p in thread.participants_list]:
         raise PermissionDenied(_("This user is already thread participant."))
 
-    allow_add_participant(request.user, participant)
+    participant_acl = useracl.get_user_acl(participant, request.cache_versions)
+    allow_add_participant(request.user_acl, participant, participant_acl)
     add_participant(request, thread, participant)
 
     make_participants_aware(request.user, thread)
@@ -305,7 +307,7 @@ def patch_remove_participant(request, thread, value):
     else:
         raise PermissionDenied(_("Participant doesn't exist."))
 
-    allow_remove_participant(request.user, thread, participant.user)
+    allow_remove_participant(request.user_acl, thread, participant.user)
     remove_participant(request, thread, participant.user)
 
     if len(thread.participants_list) == 1:
@@ -338,7 +340,7 @@ def patch_replace_owner(request, thread, value):
     else:
         raise PermissionDenied(_("Participant doesn't exist."))
 
-    allow_change_owner(request.user, thread)
+    allow_change_owner(request.user_acl, thread)
     change_owner(request, thread, participant.user)
 
     make_participants_aware(request.user, thread)

+ 8 - 8
misago/threads/api/threadpoll.py

@@ -7,7 +7,7 @@ from django.db import transaction
 from django.http import Http404
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.shortcuts import get_int_or_404
 from misago.threads.models import Poll
 from misago.threads.permissions import (
@@ -47,7 +47,7 @@ class ViewSet(viewsets.ViewSet):
     @transaction.atomic
     def create(self, request, thread_pk):
         thread = self.get_thread(request, thread_pk)
-        allow_start_poll(request.user, thread)
+        allow_start_poll(request.user_acl, thread)
 
         try:
             if thread.poll and thread.poll.pk:
@@ -68,7 +68,7 @@ class ViewSet(viewsets.ViewSet):
 
         serializer.save()
 
-        add_acl(request.user, instance)
+        add_acl_to_obj(request.user_acl, instance)
         for choice in instance.choices:
             choice['selected'] = False
 
@@ -84,14 +84,14 @@ class ViewSet(viewsets.ViewSet):
         thread = self.get_thread(request, thread_pk)
         instance = self.get_poll(thread, pk)
 
-        allow_edit_poll(request.user, instance)
+        allow_edit_poll(request.user_acl, instance)
 
         serializer = EditPollSerializer(instance, data=request.data)
         serializer.is_valid(raise_exception=True)
 
         serializer.save()
 
-        add_acl(request.user, instance)
+        add_acl_to_obj(request.user_acl, instance)
         instance.make_choices_votes_aware(request.user)
 
         create_audit_trail(request, instance)
@@ -103,7 +103,7 @@ class ViewSet(viewsets.ViewSet):
         thread = self.get_thread(request, thread_pk)
         instance = self.get_poll(thread, pk)
 
-        allow_delete_poll(request.user, instance)
+        allow_delete_poll(request.user_acl, instance)
 
         thread.poll.delete()
 
@@ -111,7 +111,7 @@ class ViewSet(viewsets.ViewSet):
         thread.save()
 
         return Response({
-            'can_start_poll': can_start_poll(request.user, thread),
+            'can_start_poll': can_start_poll(request.user_acl, thread),
         })
 
     @detail_route(methods=['get', 'post'])
@@ -138,7 +138,7 @@ class ViewSet(viewsets.ViewSet):
         except Poll.DoesNotExist:
             raise Http404()
 
-        allow_see_poll_votes(request.user, thread.poll)
+        allow_see_poll_votes(request.user_acl, thread.poll)
 
         choices = []
         voters = {}

+ 7 - 7
misago/threads/api/threadposts.py

@@ -6,7 +6,7 @@ from django.core.exceptions import PermissionDenied
 from django.db import transaction
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.shortcuts import get_int_or_404
 from misago.threads.models import Post
 from misago.threads.permissions import allow_edit_post, allow_reply_thread
@@ -86,7 +86,7 @@ class ViewSet(viewsets.ViewSet):
     @transaction.atomic
     def create(self, request, thread_pk):
         thread = self.get_thread(request, thread_pk).unwrap()
-        allow_reply_thread(request.user, thread)
+        allow_reply_thread(request.user_acl, thread)
 
         post = Post(
             thread=thread,
@@ -122,7 +122,7 @@ class ViewSet(viewsets.ViewSet):
         thread = self.get_thread(request, thread_pk).unwrap()
         post = self.get_post(request, thread, pk).unwrap()
 
-        allow_edit_post(request.user, post)
+        allow_edit_post(request.user_acl, post)
 
         posting = PostingEndpoint(
             request,
@@ -188,11 +188,11 @@ class ViewSet(viewsets.ViewSet):
         thread = self.get_thread(request, thread_pk)
         post = self.get_post(request, thread, pk).unwrap()
 
-        allow_edit_post(request.user, post)
+        allow_edit_post(request.user_acl, post)
 
         attachments = []
         for attachment in post.attachment_set.order_by('-id'):
-            add_acl(request.user, attachment)
+            add_acl_to_obj(request.user_acl, attachment)
             attachments.append(attachment)
         attachments_json = AttachmentSerializer(
             attachments, many=True, context={'user': request.user}
@@ -211,7 +211,7 @@ class ViewSet(viewsets.ViewSet):
     @list_route(methods=['get'], url_path='editor')
     def reply_editor(self, request, thread_pk):
         thread = self.get_thread(request, thread_pk).unwrap()
-        allow_reply_thread(request.user, thread)
+        allow_reply_thread(request.user_acl, thread)
 
         if 'reply' in request.query_params:
             reply_to = self.get_post(request, thread, request.query_params['reply']).unwrap()
@@ -242,7 +242,7 @@ class ViewSet(viewsets.ViewSet):
                 thread = self.get_thread(request, thread_pk)
                 post = self.get_post(request, thread, pk).unwrap()
 
-                allow_edit_post(request.user, post)
+                allow_edit_post(request.user_acl, post)
 
                 return revert_post_endpoint(request, post)
 

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

@@ -118,8 +118,8 @@ class PrivateThreadViewSet(ViewSet):
 
     @transaction.atomic
     def create(self, request):
-        allow_use_private_threads(request.user)
-        if not request.user.acl_cache['can_start_private_threads']:
+        allow_use_private_threads(request.user_acl)
+        if not request.user_acl['can_start_private_threads']:
             raise PermissionDenied(_("You can't start private threads."))
 
         request.user.lock()

+ 3 - 3
misago/threads/middleware.py

@@ -11,7 +11,7 @@ class UnreadThreadsCountMiddleware(MiddlewareMixin):
         if request.user.is_anonymous:
             return
 
-        if not request.user.acl_cache['can_use_private_threads']:
+        if not request.user_acl['can_use_private_threads']:
             return
 
         if not request.user.sync_unread_private_threads:
@@ -22,8 +22,8 @@ class UnreadThreadsCountMiddleware(MiddlewareMixin):
         category = Category.objects.private_threads()
         threads = Thread.objects.filter(category=category, id__in=participated_threads)
 
-        new_threads = filter_read_threads_queryset(request.user, [category], 'new', threads)
-        unread_threads = filter_read_threads_queryset(request.user, [category], 'unread', threads)
+        new_threads = filter_read_threads_queryset(request, [category], 'new', threads)
+        unread_threads = filter_read_threads_queryset(request, [category], 'unread', threads)
 
         request.user.unread_private_threads = new_threads.count() + unread_threads.count()
         request.user.sync_unread_private_threads = False

+ 4 - 4
misago/threads/permissions/attachments.py

@@ -58,15 +58,15 @@ def build_acl(acl, roles, key_name):
     )
 
 
-def add_acl_to_attachment(user, attachment):
-    if user.is_authenticated and user.id == attachment.uploader_id:
+def add_acl_to_attachment(user_acl, attachment):
+    if user_acl["is_authenticated"] and user_acl["user_id"] == attachment.uploader_id:
         attachment.acl.update({
             'can_delete': True,
         })
     else:
-        user_can_delete = user.acl_cache['can_delete_other_users_attachments']
+        user_can_delete = user_acl['can_delete_other_users_attachments']
         attachment.acl.update({
-            'can_delete': user.is_authenticated and user_can_delete,
+            'can_delete': user_acl["is_authenticated"] and user_can_delete,
         })
 
 

+ 29 - 29
misago/threads/permissions/bestanswers.py

@@ -108,19 +108,19 @@ def build_category_acl(acl, category, categories_roles, key_name):
     return final_acl
 
 
-def add_acl_to_thread(user, thread):
+def add_acl_to_thread(user_acl, thread):
     thread.acl.update({
-        'can_mark_best_answer': can_mark_best_answer(user, thread),
-        'can_change_best_answer': can_change_best_answer(user, thread),
-        'can_unmark_best_answer': can_unmark_best_answer(user, thread),
+        'can_mark_best_answer': can_mark_best_answer(user_acl, thread),
+        'can_change_best_answer': can_change_best_answer(user_acl, thread),
+        'can_unmark_best_answer': can_unmark_best_answer(user_acl, thread),
     })
     
 
-def add_acl_to_post(user, post):
+def add_acl_to_post(user_acl, post):
     post.acl.update({
-        'can_mark_as_best_answer': can_mark_as_best_answer(user, post),
-        'can_hide_best_answer': can_hide_best_answer(user, post),
-        'can_delete_best_answer': can_delete_best_answer(user, post),
+        'can_mark_as_best_answer': can_mark_as_best_answer(user_acl, post),
+        'can_hide_best_answer': can_hide_best_answer(user_acl, post),
+        'can_delete_best_answer': can_delete_best_answer(user_acl, post),
     })
 
 
@@ -129,11 +129,11 @@ def register_with(registry):
     registry.acl_annotator(Post, add_acl_to_post)
 
 
-def allow_mark_best_answer(user, target):
-    if user.is_anonymous:
+def allow_mark_best_answer(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to mark best answers."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {})
+    category_acl = user_acl['categories'].get(target.category_id, {})
 
     if not category_acl.get('can_mark_best_answers'):
         raise PermissionDenied(
@@ -144,7 +144,7 @@ def allow_mark_best_answer(user, target):
             }
         )
 
-    if category_acl['can_mark_best_answers'] == 1 and target.starter_id != user.id:
+    if category_acl['can_mark_best_answers'] == 1 and user_acl["user_id"] != target.starter_id:
         raise PermissionDenied(
             _(
                 "You don't have permission to mark best answer in this thread because you didn't "
@@ -174,11 +174,11 @@ def allow_mark_best_answer(user, target):
 can_mark_best_answer = return_boolean(allow_mark_best_answer)
 
 
-def allow_change_best_answer(user, target):
+def allow_change_best_answer(user_acl, target):
     if not target.has_best_answer:
         return # shortcircut permission test
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {})
+    category_acl = user_acl['categories'].get(target.category_id, {})
 
     if not category_acl.get('can_change_marked_answers'):
         raise PermissionDenied(
@@ -191,14 +191,14 @@ def allow_change_best_answer(user, target):
         )
 
     if category_acl['can_change_marked_answers'] == 1:
-        if target.starter_id != user.id:
+        if user_acl["user_id"] != target.starter_id:
             raise PermissionDenied(
                 _(
                     "You don't have permission to change this thread's marked answer because you "
                     "are not a thread starter."
                 )
             )
-        if not has_time_to_change_answer(user, target):
+        if not has_time_to_change_answer(user_acl, target):
             raise PermissionDenied(
                 ngettext(
                     (
@@ -227,14 +227,14 @@ def allow_change_best_answer(user, target):
 can_change_best_answer = return_boolean(allow_change_best_answer)
 
 
-def allow_unmark_best_answer(user, target):
-    if user.is_anonymous:
+def allow_unmark_best_answer(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to unmark best answers."))
 
     if not target.has_best_answer:
         return # shortcircut test
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {})
+    category_acl = user_acl['categories'].get(target.category_id, {})
 
     if not category_acl.get('can_change_marked_answers'):
         raise PermissionDenied(
@@ -247,14 +247,14 @@ def allow_unmark_best_answer(user, target):
         )
 
     if category_acl['can_change_marked_answers'] == 1:
-        if target.starter_id != user.id:
+        if user_acl["user_id"] != target.starter_id:
             raise PermissionDenied(
                 _(
                     "You don't have permission to unmark this best answer because you are not a "
                     "thread starter."
                 )
             )
-        if not has_time_to_change_answer(user, target):
+        if not has_time_to_change_answer(user_acl, target):
             raise PermissionDenied(
                 ngettext(
                     (
@@ -301,14 +301,14 @@ def allow_unmark_best_answer(user, target):
 can_unmark_best_answer = return_boolean(allow_unmark_best_answer)
 
 
-def allow_mark_as_best_answer(user, target):
-    if user.is_anonymous:
+def allow_mark_as_best_answer(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to mark best answers."))
 
     if target.is_event:
         raise PermissionDenied(_("Events can't be marked as best answers."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {})
+    category_acl = user_acl['categories'].get(target.category_id, {})
 
     if not category_acl.get('can_mark_best_answers'):
         raise PermissionDenied(
@@ -319,7 +319,7 @@ def allow_mark_as_best_answer(user, target):
             }
         )
 
-    if category_acl['can_mark_best_answers'] == 1 and target.thread.starter_id != user.id:
+    if category_acl['can_mark_best_answers'] == 1 and user_acl["user_id"] != target.thread.starter_id:
         raise PermissionDenied(
             _(
                 "You don't have permission to mark best answer in this thread because you "
@@ -348,7 +348,7 @@ def allow_mark_as_best_answer(user, target):
 can_mark_as_best_answer = return_boolean(allow_mark_as_best_answer)
 
 
-def allow_hide_best_answer(user, target):
+def allow_hide_best_answer(user_acl, target):
     if target.is_best_answer:
         raise PermissionDenied(
             _("You can't hide this post because its marked as best answer.")
@@ -358,7 +358,7 @@ def allow_hide_best_answer(user, target):
 can_hide_best_answer = return_boolean(allow_hide_best_answer)
 
 
-def allow_delete_best_answer(user, target):
+def allow_delete_best_answer(user_acl, target):
     if target.is_best_answer:
         raise PermissionDenied(
             _("You can't delete this post because its marked as best answer.")
@@ -368,8 +368,8 @@ def allow_delete_best_answer(user, target):
 can_delete_best_answer = return_boolean(allow_delete_best_answer)
 
 
-def has_time_to_change_answer(user, target):
-    category_acl = user.acl_cache['categories'].get(target.category_id, {})
+def has_time_to_change_answer(user_acl, target):
+    category_acl = user_acl['categories'].get(target.category_id, {})
     change_time = category_acl.get('best_answer_change_time', 0)
 
     if change_time:

+ 37 - 37
misago/threads/permissions/polls.py

@@ -98,18 +98,18 @@ def build_acl(acl, roles, key_name):
     )
 
 
-def add_acl_to_poll(user, poll):
+def add_acl_to_poll(user_acl, poll):
     poll.acl.update({
-        'can_vote': can_vote_poll(user, poll),
-        'can_edit': can_edit_poll(user, poll),
-        'can_delete': can_delete_poll(user, poll),
-        'can_see_votes': can_see_poll_votes(user, poll),
+        'can_vote': can_vote_poll(user_acl, poll),
+        'can_edit': can_edit_poll(user_acl, poll),
+        'can_delete': can_delete_poll(user_acl, poll),
+        'can_see_votes': can_see_poll_votes(user_acl, poll),
     })
 
 
-def add_acl_to_thread(user, thread):
+def add_acl_to_thread(user_acl, thread):
     thread.acl.update({
-        'can_start_poll': can_start_poll(user, thread),
+        'can_start_poll': can_start_poll(user_acl, thread),
     })
 
 
@@ -118,19 +118,19 @@ def register_with(registry):
     registry.acl_annotator(Thread, add_acl_to_thread)
 
 
-def allow_start_poll(user, target):
-    if user.is_anonymous:
+def allow_start_poll(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to start polls."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_close_threads': False,
         }
     )
 
-    if not user.acl_cache.get('can_start_polls'):
+    if not user_acl.get('can_start_polls'):
         raise PermissionDenied(_("You can't start polls."))
-    if user.acl_cache.get('can_start_polls') < 2 and user.pk != target.starter_id:
+    if user_acl.get('can_start_polls') < 2 and user_acl["user_id"] != target.starter_id:
         raise PermissionDenied(_("You can't start polls in other users threads."))
 
     if not category_acl.get('can_close_threads'):
@@ -143,29 +143,29 @@ def allow_start_poll(user, target):
 can_start_poll = return_boolean(allow_start_poll)
 
 
-def allow_edit_poll(user, target):
-    if user.is_anonymous:
+def allow_edit_poll(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to edit polls."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_close_threads': False,
         }
     )
 
-    if not user.acl_cache.get('can_edit_polls'):
+    if not user_acl.get('can_edit_polls'):
         raise PermissionDenied(_("You can't edit polls."))
 
-    if user.acl_cache.get('can_edit_polls') < 2:
-        if user.pk != target.poster_id:
+    if user_acl.get('can_edit_polls') < 2:
+        if user_acl["user_id"] != target.poster_id:
             raise PermissionDenied(_("You can't edit other users polls in this category."))
-        if not has_time_to_edit_poll(user, target):
+        if not has_time_to_edit_poll(user_acl, target):
             message = ngettext(
                 "You can't edit polls that are older than %(minutes)s minute.",
                 "You can't edit polls that are older than %(minutes)s minutes.",
-                user.acl_cache['poll_edit_time']
+                user_acl['poll_edit_time']
             )
-            raise PermissionDenied(message % {'minutes': user.acl_cache['poll_edit_time']})
+            raise PermissionDenied(message % {'minutes': user_acl['poll_edit_time']})
 
         if target.is_over:
             raise PermissionDenied(_("This poll is over. You can't edit it."))
@@ -180,29 +180,29 @@ def allow_edit_poll(user, target):
 can_edit_poll = return_boolean(allow_edit_poll)
 
 
-def allow_delete_poll(user, target):
-    if user.is_anonymous:
+def allow_delete_poll(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to delete polls."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_close_threads': False,
         }
     )
 
-    if not user.acl_cache.get('can_delete_polls'):
+    if not user_acl.get('can_delete_polls'):
         raise PermissionDenied(_("You can't delete polls."))
 
-    if user.acl_cache.get('can_delete_polls') < 2:
-        if user.pk != target.poster_id:
+    if user_acl.get('can_delete_polls') < 2:
+        if user_acl["user_id"] != target.poster_id:
             raise PermissionDenied(_("You can't delete other users polls in this category."))
-        if not has_time_to_edit_poll(user, target):
+        if not has_time_to_edit_poll(user_acl, target):
             message = ngettext(
                 "You can't delete polls that are older than %(minutes)s minute.",
                 "You can't delete polls that are older than %(minutes)s minutes.",
-                user.acl_cache['poll_edit_time']
+                user_acl['poll_edit_time']
             )
-            raise PermissionDenied(message % {'minutes': user.acl_cache['poll_edit_time']})
+            raise PermissionDenied(message % {'minutes': user_acl['poll_edit_time']})
         if target.is_over:
             raise PermissionDenied(_("This poll is over. You can't delete it."))
 
@@ -216,8 +216,8 @@ def allow_delete_poll(user, target):
 can_delete_poll = return_boolean(allow_delete_poll)
 
 
-def allow_vote_poll(user, target):
-    if user.is_anonymous:
+def allow_vote_poll(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to vote in polls."))
 
     if target.has_selected_choices and not target.allow_revotes:
@@ -225,7 +225,7 @@ def allow_vote_poll(user, target):
     if target.is_over:
         raise PermissionDenied(_("This poll is over. You can't vote in it."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_close_threads': False,
         }
@@ -241,16 +241,16 @@ def allow_vote_poll(user, target):
 can_vote_poll = return_boolean(allow_vote_poll)
 
 
-def allow_see_poll_votes(user, target):
-    if not target.is_public and not user.acl_cache['can_always_see_poll_voters']:
+def allow_see_poll_votes(user_acl, target):
+    if not target.is_public and not user_acl['can_always_see_poll_voters']:
         raise PermissionDenied(_("You dont have permission to this poll's voters."))
 
 
 can_see_poll_votes = return_boolean(allow_see_poll_votes)
 
 
-def has_time_to_edit_poll(user, target):
-    edit_time = user.acl_cache['poll_edit_time']
+def has_time_to_edit_poll(user_acl, target):
+    edit_time = user_acl['poll_edit_time']
     if edit_time:
         diff = timezone.now() - target.posted_on
         diff_minutes = int(diff.total_seconds() / 60)

+ 25 - 25
misago/threads/permissions/privatethreads.py

@@ -152,7 +152,7 @@ def build_acl(acl, roles, key_name):
     return new_acl
 
 
-def add_acl_to_thread(user, thread):
+def add_acl_to_thread(user_acl, thread):
     if thread.thread_type.root_name != PRIVATE_THREADS_ROOT_NAME:
         return
 
@@ -162,8 +162,8 @@ def add_acl_to_thread(user, thread):
 
     thread.acl.update({
         'can_start_poll': False,
-        'can_change_owner': can_change_owner(user, thread),
-        'can_add_participants': can_add_participants(user, thread),
+        'can_change_owner': can_change_owner(user_acl, thread),
+        'can_add_participants': can_add_participants(user_acl, thread),
     })
 
 
@@ -171,23 +171,23 @@ def register_with(registry):
     registry.acl_annotator(Thread, add_acl_to_thread)
 
 
-def allow_use_private_threads(user):
-    if user.is_anonymous:
+def allow_use_private_threads(user_acl):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to use private threads."))
-    if not user.acl_cache['can_use_private_threads']:
+    if not user_acl['can_use_private_threads']:
         raise PermissionDenied(_("You can't use private threads."))
 
 
 can_use_private_threads = return_boolean(allow_use_private_threads)
 
 
-def allow_see_private_thread(user, target):
-    if user.acl_cache['can_moderate_private_threads']:
+def allow_see_private_thread(user_acl, target):
+    if user_acl['can_moderate_private_threads']:
         can_see_reported = target.has_reported_posts
     else:
         can_see_reported = False
 
-    can_see_participating = user in [p.user for p in target.participants_list]
+    can_see_participating = user_acl["user_id"] in [p.user_id for p in target.participants_list]
 
     if not (can_see_participating or can_see_reported):
         raise Http404()
@@ -196,8 +196,8 @@ def allow_see_private_thread(user, target):
 can_see_private_thread = return_boolean(allow_see_private_thread)
 
 
-def allow_change_owner(user, target):
-    is_moderator = user.acl_cache['can_moderate_private_threads']
+def allow_change_owner(user_acl, target):
+    is_moderator = user_acl['can_moderate_private_threads']
     is_owner = target.participant and target.participant.is_owner
 
     if not (is_owner or is_moderator):
@@ -210,8 +210,8 @@ def allow_change_owner(user, target):
 can_change_owner = return_boolean(allow_change_owner)
 
 
-def allow_add_participants(user, target):
-    is_moderator = user.acl_cache['can_moderate_private_threads']
+def allow_add_participants(user_acl, target):
+    is_moderator = user_acl['can_moderate_private_threads']
 
     if not is_moderator:
         if not target.participant or not target.participant.is_owner:
@@ -220,7 +220,7 @@ def allow_add_participants(user, target):
         if target.is_closed:
             raise PermissionDenied(_("Only moderators can add participants to closed threads."))
 
-    max_participants = user.acl_cache['max_private_thread_participants']
+    max_participants = user_acl['max_private_thread_participants']
     current_participants = len(target.participants_list) - 1
 
     if current_participants >= max_participants:
@@ -230,11 +230,11 @@ def allow_add_participants(user, target):
 can_add_participants = return_boolean(allow_add_participants)
 
 
-def allow_remove_participant(user, thread, target):
-    if user.acl_cache['can_moderate_private_threads']:
+def allow_remove_participant(user_acl, thread, target):
+    if user_acl['can_moderate_private_threads']:
         return
 
-    if user == target:
+    if user_acl["user_id"] == target.id:
         return  # we can always remove ourselves
 
     if thread.is_closed:
@@ -247,18 +247,18 @@ def allow_remove_participant(user, thread, target):
 can_remove_participant = return_boolean(allow_remove_participant)
 
 
-def allow_add_participant(user, target):
+def allow_add_participant(user_acl, target, target_acl):
     message_format = {'user': target.username}
 
-    if not can_use_private_threads(target):
+    if not can_use_private_threads(target_acl):
         raise PermissionDenied(
             _("%(user)s can't participate in private threads.") % message_format
         )
 
-    if user.acl_cache['can_add_everyone_to_private_threads']:
+    if user_acl['can_add_everyone_to_private_threads']:
         return
 
-    if user.acl_cache['can_be_blocked'] and target.is_blocking(user):
+    if user_acl['can_be_blocked'] and target.is_blocking(user_acl["user_id"]):
         raise PermissionDenied(_("%(user)s is blocking you.") % message_format)
 
     if target.can_be_messaged_by_nobody:
@@ -266,7 +266,7 @@ def allow_add_participant(user, target):
             _("%(user)s is not allowing invitations to private threads.") % message_format
         )
 
-    if target.can_be_messaged_by_followed and not target.is_following(user):
+    if target.can_be_messaged_by_followed and not target.is_following(user_acl["user_id"]):
         message = _("%(user)s limits invitations to private threads to followed users.")
         raise PermissionDenied(message % message_format)
 
@@ -274,9 +274,9 @@ def allow_add_participant(user, target):
 can_add_participant = return_boolean(allow_add_participant)
 
 
-def allow_message_user(user, target):
-    allow_use_private_threads(user)
-    allow_add_participant(user, target)
+def allow_message_user(user_acl, target, target_acl):
+    allow_use_private_threads(user_acl)
+    allow_add_participant(user_acl, target, target_acl)
 
 
 can_message_user = return_boolean(allow_message_user)

+ 153 - 152
misago/threads/permissions/threads.py

@@ -5,9 +5,10 @@ from django.http import Http404
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _, ngettext
 
-from misago.acl import add_acl, algebra
+from misago.acl import algebra
 from misago.acl.decorators import return_boolean
 from misago.acl.models import Role
+from misago.acl.objectacl import add_acl_to_obj
 from misago.admin.forms import YesNoSwitch
 from misago.categories.models import Category, CategoryRole
 from misago.categories.permissions import get_categories_roles
@@ -370,8 +371,8 @@ def build_category_acl(acl, category, categories_roles, key_name):
     return final_acl
 
 
-def add_acl_to_category(user, category):
-    category_acl = user.acl_cache['categories'].get(category.pk, {})
+def add_acl_to_category(user_acl, category):
+    category_acl = user_acl['categories'].get(category.pk, {})
 
     category.acl.update({
         'can_see_all_threads': 0,
@@ -411,7 +412,7 @@ def add_acl_to_category(user, category):
         can_see_posts_likes=algebra.greater,
     )
 
-    if user.is_authenticated:
+    if user_acl["is_authenticated"]:
         algebra.sum_acls(
             category.acl,
             acls=[category_acl],
@@ -442,7 +443,7 @@ def add_acl_to_category(user, category):
             can_hide_events=algebra.greater,
         )
 
-    if user.acl_cache['can_approve_content']:
+    if user_acl['can_approve_content']:
         category.acl.update({
             'require_threads_approval': 0,
             'require_replies_approval': 0,
@@ -452,23 +453,23 @@ def add_acl_to_category(user, category):
     category.acl['can_see_own_threads'] = not category.acl['can_see_all_threads']
 
 
-def add_acl_to_thread(user, thread):
-    category_acl = user.acl_cache['categories'].get(thread.category_id, {})
+def add_acl_to_thread(user_acl, thread):
+    category_acl = user_acl['categories'].get(thread.category_id, {})
 
     thread.acl.update({
-        'can_reply': can_reply_thread(user, thread),
-        'can_edit': can_edit_thread(user, thread),
-        'can_pin': can_pin_thread(user, thread),
+        'can_reply': can_reply_thread(user_acl, thread),
+        'can_edit': can_edit_thread(user_acl, thread),
+        'can_pin': can_pin_thread(user_acl, thread),
         'can_pin_globally': False,
-        'can_hide': can_hide_thread(user, thread),
-        'can_unhide': can_unhide_thread(user, thread),
-        'can_delete': can_delete_thread(user, thread),
+        'can_hide': can_hide_thread(user_acl, thread),
+        'can_unhide': can_unhide_thread(user_acl, thread),
+        'can_delete': can_delete_thread(user_acl, thread),
         'can_close': category_acl.get('can_close_threads', False),
-        'can_move': can_move_thread(user, thread),
-        'can_merge': can_merge_thread(user, thread),
+        'can_move': can_move_thread(user_acl, thread),
+        'can_merge': can_merge_thread(user_acl, thread),
         'can_move_posts': category_acl.get('can_move_posts', False),
         'can_merge_posts': category_acl.get('can_merge_posts', False),
-        'can_approve': can_approve_thread(user, thread),
+        'can_approve': can_approve_thread(user_acl, thread),
         'can_see_reports': category_acl.get('can_see_reports', False),
     })
 
@@ -476,18 +477,18 @@ def add_acl_to_thread(user, thread):
         thread.acl['can_pin_globally'] = True
 
 
-def add_acl_to_post(user, post):
+def add_acl_to_post(user_acl, post):
     if post.is_event:
-        add_acl_to_event(user, post)
+        add_acl_to_event(user_acl, post)
     else:
-        add_acl_to_reply(user, post)
+        add_acl_to_reply(user_acl, post)
 
 
-def add_acl_to_event(user, event):
+def add_acl_to_event(user_acl, event):
     can_hide_events = 0
 
-    if user.is_authenticated:
-        category_acl = user.acl_cache['categories'].get(
+    if user_acl["is_authenticated"]:
+        category_acl = user_acl['categories'].get(
             event.category_id, {
                 'can_hide_events': 0,
             }
@@ -497,25 +498,25 @@ def add_acl_to_event(user, event):
 
     event.acl.update({
         'can_see_hidden': can_hide_events > 0,
-        'can_hide': can_hide_event(user, event),
-        'can_delete': can_delete_event(user, event),
+        'can_hide': can_hide_event(user_acl, event),
+        'can_delete': can_delete_event(user_acl, event),
     })
 
 
-def add_acl_to_reply(user, post):
-    category_acl = user.acl_cache['categories'].get(post.category_id, {})
+def add_acl_to_reply(user_acl, post):
+    category_acl = user_acl['categories'].get(post.category_id, {})
 
     post.acl.update({
-        'can_reply': can_reply_thread(user, post.thread),
-        'can_edit': can_edit_post(user, post),
+        'can_reply': can_reply_thread(user_acl, post.thread),
+        'can_edit': can_edit_post(user_acl, post),
         'can_see_hidden': post.is_first_post or category_acl.get('can_hide_posts'),
-        'can_unhide': can_unhide_post(user, post),
-        'can_hide': can_hide_post(user, post),
-        'can_delete': can_delete_post(user, post),
-        'can_protect': can_protect_post(user, post),
-        'can_approve': can_approve_post(user, post),
-        'can_move': can_move_post(user, post),
-        'can_merge': can_merge_post(user, post),
+        'can_unhide': can_unhide_post(user_acl, post),
+        'can_hide': can_hide_post(user_acl, post),
+        'can_delete': can_delete_post(user_acl, post),
+        'can_protect': can_protect_post(user_acl, post),
+        'can_approve': can_approve_post(user_acl, post),
+        'can_move': can_move_post(user_acl, post),
+        'can_merge': can_merge_post(user_acl, post),
         'can_report': category_acl.get('can_report_content', False),
         'can_see_reports': category_acl.get('can_see_reports', False),
         'can_see_likes': category_acl.get('can_see_posts_likes', 0),
@@ -524,7 +525,7 @@ def add_acl_to_reply(user, post):
 
     if not post.acl['can_see_hidden']:
         post.acl['can_see_hidden'] = post.id == post.thread.first_post_id
-    if user.is_authenticated and post.acl['can_see_likes']:
+    if user_acl["is_authenticated"] and post.acl['can_see_likes']:
         post.acl['can_like'] = category_acl.get('can_like_posts', False)
 
 
@@ -534,8 +535,8 @@ def register_with(registry):
     registry.acl_annotator(Post, add_acl_to_post)
 
 
-def allow_see_thread(user, target):
-    category_acl = user.acl_cache['categories'].get(
+def allow_see_thread(user_acl, target):
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_see': False,
             'can_browse': False,
@@ -545,10 +546,10 @@ def allow_see_thread(user, target):
     if not (category_acl['can_see'] and category_acl['can_browse']):
         raise Http404()
 
-    if target.is_hidden and (user.is_anonymous or not category_acl['can_hide_threads']):
+    if target.is_hidden and (user_acl["is_anonymous"] or not category_acl['can_hide_threads']):
         raise Http404()
 
-    if user.is_anonymous or user.pk != target.starter_id:
+    if user_acl["is_anonymous"] or user_acl["user_id"] != target.starter_id:
         if not category_acl['can_see_all_threads']:
             raise Http404()
 
@@ -559,11 +560,11 @@ def allow_see_thread(user, target):
 can_see_thread = return_boolean(allow_see_thread)
 
 
-def allow_start_thread(user, target):
-    if user.is_anonymous:
+def allow_start_thread(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to start threads."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.pk, {
             'can_start_threads': False,
         }
@@ -581,11 +582,11 @@ def allow_start_thread(user, target):
 can_start_thread = return_boolean(allow_start_thread)
 
 
-def allow_reply_thread(user, target):
-    if user.is_anonymous:
+def allow_reply_thread(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to reply threads."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_reply_threads': False,
         }
@@ -604,11 +605,11 @@ def allow_reply_thread(user, target):
 can_reply_thread = return_boolean(allow_reply_thread)
 
 
-def allow_edit_thread(user, target):
-    if user.is_anonymous:
+def allow_edit_thread(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to edit threads."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_edit_threads': False,
         }
@@ -618,10 +619,10 @@ def allow_edit_thread(user, target):
         raise PermissionDenied(_("You can't edit threads in this category."))
 
     if category_acl['can_edit_threads'] == 1:
-        if target.starter_id != user.pk:
+        if user_acl["user_id"] != target.starter_id:
             raise PermissionDenied(_("You can't edit other users threads in this category."))
 
-        if not has_time_to_edit_thread(user, target):
+        if not has_time_to_edit_thread(user_acl, target):
             message = ngettext(
                 "You can't edit threads that are older than %(minutes)s minute.",
                 "You can't edit threads that are older than %(minutes)s minutes.",
@@ -639,11 +640,11 @@ def allow_edit_thread(user, target):
 can_edit_thread = return_boolean(allow_edit_thread)
 
 
-def allow_pin_thread(user, target):
-    if user.is_anonymous:
+def allow_pin_thread(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to change threads weights."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_pin_threads': 0,
         }
@@ -662,11 +663,11 @@ def allow_pin_thread(user, target):
 can_pin_thread = return_boolean(allow_pin_thread)
 
 
-def allow_unhide_thread(user, target):
-    if user.is_anonymous:
+def allow_unhide_thread(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to hide threads."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_close_threads': False,
         }
@@ -682,11 +683,11 @@ def allow_unhide_thread(user, target):
 can_unhide_thread = return_boolean(allow_unhide_thread)
 
 
-def allow_hide_thread(user, target):
-    if user.is_anonymous:
+def allow_hide_thread(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to hide threads."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_hide_threads': 0,
             'can_hide_own_threads': 0,
@@ -697,10 +698,10 @@ def allow_hide_thread(user, target):
         raise PermissionDenied(_("You can't hide threads in this category."))
 
     if not category_acl['can_hide_threads'] and category_acl['can_hide_own_threads']:
-        if user.id != target.starter_id:
+        if user_acl["user_id"] != target.starter_id:
             raise PermissionDenied(_("You can't hide other users theads in this category."))
 
-        if not has_time_to_edit_thread(user, target):
+        if not has_time_to_edit_thread(user_acl, target):
             message = ngettext(
                 "You can't hide threads that are older than %(minutes)s minute.",
                 "You can't hide threads that are older than %(minutes)s minutes.",
@@ -718,11 +719,11 @@ def allow_hide_thread(user, target):
 can_hide_thread = return_boolean(allow_hide_thread)
 
 
-def allow_delete_thread(user, target):
-    if user.is_anonymous:
+def allow_delete_thread(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to delete threads."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_hide_threads': 0,
             'can_hide_own_threads': 0,
@@ -733,10 +734,10 @@ def allow_delete_thread(user, target):
         raise PermissionDenied(_("You can't delete threads in this category."))
 
     if category_acl['can_hide_threads'] != 2 and category_acl['can_hide_own_threads'] == 2:
-        if user.id != target.starter_id:
+        if user_acl["user_id"] != target.starter_id:
             raise PermissionDenied(_("You can't delete other users theads in this category."))
 
-        if not has_time_to_edit_thread(user, target):
+        if not has_time_to_edit_thread(user_acl, target):
             message = ngettext(
                 "You can't delete threads that are older than %(minutes)s minute.",
                 "You can't delete threads that are older than %(minutes)s minutes.",
@@ -754,11 +755,11 @@ def allow_delete_thread(user, target):
 can_delete_thread = return_boolean(allow_delete_thread)
 
 
-def allow_move_thread(user, target):
-    if user.is_anonymous:
+def allow_move_thread(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to move threads."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_move_threads': 0,
         }
@@ -777,11 +778,11 @@ def allow_move_thread(user, target):
 can_move_thread = return_boolean(allow_move_thread)
 
 
-def allow_merge_thread(user, target, otherthread=False):
-    if user.is_anonymous:
+def allow_merge_thread(user_acl, target, otherthread=False):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to merge threads."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_merge_threads': 0,
         }
@@ -806,11 +807,11 @@ def allow_merge_thread(user, target, otherthread=False):
 can_merge_thread = return_boolean(allow_merge_thread)
 
 
-def allow_approve_thread(user, target):
-    if user.is_anonymous:
+def allow_approve_thread(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to approve threads."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_approve_content': 0,
         }
@@ -829,8 +830,8 @@ def allow_approve_thread(user, target):
 can_approve_thread = return_boolean(allow_approve_thread)
 
 
-def allow_see_post(user, target):
-    category_acl = user.acl_cache['categories'].get(
+def allow_see_post(user_acl, target):
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_approve_content': False,
             'can_hide_events': False,
@@ -838,10 +839,10 @@ def allow_see_post(user, target):
     )
 
     if not target.is_event and target.is_unapproved:
-        if user.is_anonymous:
+        if user_acl["is_anonymous"]:
             raise Http404()
 
-        if not category_acl['can_approve_content'] and user.id != target.poster_id:
+        if not category_acl['can_approve_content'] and user_acl["user_id"] != target.poster_id:
             raise Http404()
 
     if target.is_event and target.is_hidden and not category_acl['can_hide_events']:
@@ -851,14 +852,14 @@ def allow_see_post(user, target):
 can_see_post = return_boolean(allow_see_post)
 
 
-def allow_edit_post(user, target):
-    if user.is_anonymous:
+def allow_edit_post(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to edit posts."))
 
     if target.is_event:
         raise PermissionDenied(_("Events can't be edited."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {'can_edit_posts': False})
+    category_acl = user_acl['categories'].get(target.category_id, {'can_edit_posts': False})
 
     if not category_acl['can_edit_posts']:
         raise PermissionDenied(_("You can't edit posts in this category."))
@@ -867,13 +868,13 @@ def allow_edit_post(user, target):
         raise PermissionDenied(_("This post is hidden, you can't edit it."))
 
     if category_acl['can_edit_posts'] == 1:
-        if target.poster_id != user.pk:
+        if target.poster_id != user_acl["user_id"]:
             raise PermissionDenied(_("You can't edit other users posts in this category."))
 
         if target.is_protected and not category_acl['can_protect_posts']:
             raise PermissionDenied(_("This post is protected. You can't edit it."))
 
-        if not has_time_to_edit_post(user, target):
+        if not has_time_to_edit_post(user_acl, target):
             message = ngettext(
                 "You can't edit posts that are older than %(minutes)s minute.",
                 "You can't edit posts that are older than %(minutes)s minutes.",
@@ -891,11 +892,11 @@ def allow_edit_post(user, target):
 can_edit_post = return_boolean(allow_edit_post)
 
 
-def allow_unhide_post(user, target):
-    if user.is_anonymous:
+def allow_unhide_post(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to reveal posts."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_hide_posts': 0,
             'can_hide_own_posts': 0,
@@ -906,13 +907,13 @@ def allow_unhide_post(user, target):
         if not category_acl['can_hide_own_posts']:
             raise PermissionDenied(_("You can't reveal posts in this category."))
 
-        if user.id != target.poster_id:
+        if user_acl["user_id"] != target.poster_id:
             raise PermissionDenied(_("You can't reveal other users posts in this category."))
 
         if target.is_protected and not category_acl['can_protect_posts']:
             raise PermissionDenied(_("This post is protected. You can't reveal it."))
 
-        if not has_time_to_edit_post(user, target):
+        if not has_time_to_edit_post(user_acl, target):
             message = ngettext(
                 "You can't reveal posts that are older than %(minutes)s minute.",
                 "You can't reveal posts that are older than %(minutes)s minutes.",
@@ -933,11 +934,11 @@ def allow_unhide_post(user, target):
 can_unhide_post = return_boolean(allow_unhide_post)
 
 
-def allow_hide_post(user, target):
-    if user.is_anonymous:
+def allow_hide_post(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to hide posts."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_hide_posts': 0,
             'can_hide_own_posts': 0,
@@ -948,13 +949,13 @@ def allow_hide_post(user, target):
         if not category_acl['can_hide_own_posts']:
             raise PermissionDenied(_("You can't hide posts in this category."))
 
-        if user.id != target.poster_id:
+        if user_acl["user_id"] != target.poster_id:
             raise PermissionDenied(_("You can't hide other users posts in this category."))
 
         if target.is_protected and not category_acl['can_protect_posts']:
             raise PermissionDenied(_("This post is protected. You can't hide it."))
 
-        if not has_time_to_edit_post(user, target):
+        if not has_time_to_edit_post(user_acl, target):
             message = ngettext(
                 "You can't hide posts that are older than %(minutes)s minute.",
                 "You can't hide posts that are older than %(minutes)s minutes.",
@@ -975,11 +976,11 @@ def allow_hide_post(user, target):
 can_hide_post = return_boolean(allow_hide_post)
 
 
-def allow_delete_post(user, target):
-    if user.is_anonymous:
+def allow_delete_post(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to delete posts."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_hide_posts': 0,
             'can_hide_own_posts': 0,
@@ -990,13 +991,13 @@ def allow_delete_post(user, target):
         if category_acl['can_hide_own_posts'] != 2:
             raise PermissionDenied(_("You can't delete posts in this category."))
 
-        if user.id != target.poster_id:
+        if user_acl["user_id"] != target.poster_id:
             raise PermissionDenied(_("You can't delete other users posts in this category."))
 
         if target.is_protected and not category_acl['can_protect_posts']:
             raise PermissionDenied(_("This post is protected. You can't delete it."))
 
-        if not has_time_to_edit_post(user, target):
+        if not has_time_to_edit_post(user_acl, target):
             message = ngettext(
                 "You can't delete posts that are older than %(minutes)s minute.",
                 "You can't delete posts that are older than %(minutes)s minutes.",
@@ -1017,28 +1018,28 @@ def allow_delete_post(user, target):
 can_delete_post = return_boolean(allow_delete_post)
 
 
-def allow_protect_post(user, target):
-    if user.is_anonymous:
+def allow_protect_post(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to protect posts."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {'can_protect_posts': False}
     )
 
     if not category_acl['can_protect_posts']:
         raise PermissionDenied(_("You can't protect posts in this category."))
-    if not can_edit_post(user, target):
+    if not can_edit_post(user_acl, target):
         raise PermissionDenied(_("You can't protect posts you can't edit."))
 
 
 can_protect_post = return_boolean(allow_protect_post)
 
 
-def allow_approve_post(user, target):
-    if user.is_anonymous:
+def allow_approve_post(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to approve posts."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {'can_approve_content': False}
     )
 
@@ -1059,11 +1060,11 @@ def allow_approve_post(user, target):
 can_approve_post = return_boolean(allow_approve_post)
 
 
-def allow_move_post(user, target):
-    if user.is_anonymous:
+def allow_move_post(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to move posts."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_move_posts': False,
         }
@@ -1088,11 +1089,11 @@ def allow_move_post(user, target):
 can_move_post = return_boolean(allow_move_post)
 
 
-def allow_merge_post(user, target):
-    if user.is_anonymous:
+def allow_merge_post(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to merge posts."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_merge_posts': False,
         }
@@ -1115,11 +1116,11 @@ def allow_merge_post(user, target):
 can_merge_post = return_boolean(allow_merge_post)
 
 
-def allow_split_post(user, target):
-    if user.is_anonymous:
+def allow_split_post(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to split posts."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_move_posts': False,
         }
@@ -1143,11 +1144,11 @@ def allow_split_post(user, target):
 can_split_post = return_boolean(allow_split_post)
 
 
-def allow_unhide_event(user, target):
-    if user.is_anonymous:
+def allow_unhide_event(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to reveal events."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_hide_events': 0,
         }
@@ -1166,11 +1167,11 @@ def allow_unhide_event(user, target):
 can_unhide_event = return_boolean(allow_unhide_event)
 
 
-def allow_hide_event(user, target):
-    if user.is_anonymous:
+def allow_hide_event(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to hide events."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_hide_events': 0,
         }
@@ -1189,11 +1190,11 @@ def allow_hide_event(user, target):
 can_hide_event = return_boolean(allow_hide_event)
 
 
-def allow_delete_event(user, target):
-    if user.is_anonymous:
+def allow_delete_event(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to delete events."))
 
-    category_acl = user.acl_cache['categories'].get(
+    category_acl = user_acl['categories'].get(
         target.category_id, {
             'can_hide_events': 0,
         }
@@ -1212,18 +1213,18 @@ def allow_delete_event(user, target):
 can_delete_event = return_boolean(allow_delete_event)
 
 
-def can_change_owned_thread(user, target):
-    if user.is_anonymous or user.pk != target.starter_id:
+def can_change_owned_thread(user_acl, target):
+    if user_acl["is_anonymous"] or user_acl["user_id"] != target.starter_id:
         return False
 
     if target.category.is_closed or target.is_closed:
         return False
 
-    return has_time_to_edit_thread(user, target)
+    return has_time_to_edit_thread(user_acl, target)
 
 
-def has_time_to_edit_thread(user, target):
-    edit_time = user.acl_cache['categories'].get(target.category_id, {}).get('thread_edit_time', 0)
+def has_time_to_edit_thread(user_acl, target):
+    edit_time = user_acl['categories'].get(target.category_id, {}).get('thread_edit_time', 0)
     if edit_time:
         diff = timezone.now() - target.started_on
         diff_minutes = int(diff.total_seconds() / 60)
@@ -1232,8 +1233,8 @@ def has_time_to_edit_thread(user, target):
         return True
 
 
-def has_time_to_edit_post(user, target):
-    edit_time = user.acl_cache['categories'].get(target.category_id, {}).get('post_edit_time', 0)
+def has_time_to_edit_post(user_acl, target):
+    edit_time = user_acl['categories'].get(target.category_id, {}).get('post_edit_time', 0)
     if edit_time:
         diff = timezone.now() - target.posted_on
         diff_minutes = int(diff.total_seconds() / 60)
@@ -1242,7 +1243,7 @@ def has_time_to_edit_post(user, target):
         return True
 
 
-def exclude_invisible_threads(user, categories, queryset):
+def exclude_invisible_threads(user_acl, categories, queryset):
     show_all = []
     show_accepted_visible = []
     show_accepted = []
@@ -1251,7 +1252,7 @@ def exclude_invisible_threads(user, categories, queryset):
     show_owned_visible = []
 
     for category in categories:
-        add_acl(user, category)
+        add_acl_to_obj(user_acl, category)
 
         if not (category.acl['can_see'] and category.acl['can_browse']):
             continue
@@ -1262,7 +1263,7 @@ def exclude_invisible_threads(user, categories, queryset):
 
             if can_mod and can_hide:
                 show_all.append(category)
-            elif user.is_authenticated:
+            elif user_acl["is_authenticated"]:
                 if not can_mod and not can_hide:
                     show_accepted_visible.append(category)
                 elif not can_mod:
@@ -1271,7 +1272,7 @@ def exclude_invisible_threads(user, categories, queryset):
                     show_visible.append(category)
             else:
                 show_accepted_visible.append(category)
-        elif user.is_authenticated:
+        elif user_acl["is_authenticated"]:
             if can_hide:
                 show_owned.append(category)
             else:
@@ -1282,9 +1283,9 @@ def exclude_invisible_threads(user, categories, queryset):
         conditions = Q(category__in=show_all)
 
     if show_accepted_visible:
-        if user.is_authenticated:
+        if user_acl["is_authenticated"]:
             condition = Q(
-                Q(starter=user) | Q(is_unapproved=False),
+                Q(starter_id=user_acl["user_id"]) | Q(is_unapproved=False),
                 category__in=show_accepted_visible,
                 is_hidden=False,
             )
@@ -1302,7 +1303,7 @@ def exclude_invisible_threads(user, categories, queryset):
 
     if show_accepted:
         condition = Q(
-            Q(starter=user) | Q(is_unapproved=False),
+            Q(starter_id=user_acl["user_id"]) | Q(is_unapproved=False),
             category__in=show_accepted,
         )
 
@@ -1320,7 +1321,7 @@ def exclude_invisible_threads(user, categories, queryset):
             conditions = condition
 
     if show_owned:
-        condition = Q(category__in=show_owned, starter=user)
+        condition = Q(category__in=show_owned, starter_id=user_acl["user_id"])
 
         if conditions:
             conditions = conditions | condition
@@ -1330,7 +1331,7 @@ def exclude_invisible_threads(user, categories, queryset):
     if show_owned_visible:
         condition = Q(
             category__in=show_owned_visible,
-            starter=user,
+            starter_id=user_acl["user_id"],
             is_hidden=False,
         )
 
@@ -1345,14 +1346,14 @@ def exclude_invisible_threads(user, categories, queryset):
         return Thread.objects.none()
 
 
-def exclude_invisible_posts(user, categories, queryset):
+def exclude_invisible_posts(user_acl, categories, queryset):
     if hasattr(categories, '__iter__'):
-        return exclude_invisible_posts_in_categories(user, categories, queryset)
+        return exclude_invisible_posts_in_categories(user_acl, categories, queryset)
     else:
-        return exclude_invisible_posts_in_category(user, categories, queryset)
+        return exclude_invisible_posts_in_category(user_acl, categories, queryset)
 
 
-def exclude_invisible_posts_in_categories(user, categories, queryset):
+def exclude_invisible_posts_in_categories(user_acl, categories, queryset):
     show_all = []
     show_approved = []
     show_approved_owned = []
@@ -1360,12 +1361,12 @@ def exclude_invisible_posts_in_categories(user, categories, queryset):
     hide_invisible_events = []
 
     for category in categories:
-        add_acl(user, category)
+        add_acl_to_obj(user_acl, category)
 
         if category.acl['can_approve_content']:
             show_all.append(category.pk)
         else:
-            if user.is_authenticated:
+            if user_acl["is_authenticated"]:
                 show_approved_owned.append(category.pk)
             else:
                 show_approved.append(category.pk)
@@ -1390,7 +1391,7 @@ def exclude_invisible_posts_in_categories(user, categories, queryset):
 
     if show_approved_owned:
         condition = Q(
-            Q(poster=user) | Q(is_unapproved=False),
+            Q(poster_id=user_acl["user_id"]) | Q(is_unapproved=False),
             category__in=show_approved_owned,
         )
 
@@ -1412,12 +1413,12 @@ def exclude_invisible_posts_in_categories(user, categories, queryset):
         return Post.objects.none()
 
 
-def exclude_invisible_posts_in_category(user, category, queryset):
-    add_acl(user, category)
+def exclude_invisible_posts_in_category(user_acl, category, queryset):
+    add_acl_to_obj(user_acl, category)
 
     if not category.acl['can_approve_content']:
-        if user.is_authenticated:
-            queryset = queryset.filter(Q(is_unapproved=False) | Q(poster=user))
+        if user_acl["is_authenticated"]:
+            queryset = queryset.filter(Q(is_unapproved=False) | Q(poster_id=user_acl["user_id"]))
         else:
             queryset = queryset.exclude(is_unapproved=True)
 

+ 1 - 1
misago/threads/search.py

@@ -27,7 +27,7 @@ class SearchThreads(SearchProvider):
 
         if len(query) > 2:
             visible_threads = exclude_invisible_threads(
-                self.request.user, threads_categories, Thread.objects
+                self.request.user_acl, threads_categories, Thread.objects
             )
             results = search_threads(self.request, query, visible_threads)
         else:

+ 26 - 25
misago/threads/serializers/moderation.py

@@ -4,7 +4,7 @@ from django.core.exceptions import PermissionDenied, ValidationError
 from django.http import Http404
 from django.utils.translation import gettext as _, gettext_lazy, ngettext
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories import THREADS_ROOT_NAME
 from misago.conf import settings
 from misago.threads.mergeconflict import MergeConflict
@@ -62,10 +62,10 @@ class DeletePostsSerializer(serializers.Serializer):
             )
             raise ValidationError(message % {'limit': POSTS_LIMIT})
 
-        user = self.context['user']
+        user_acl = self.context['user_acl']
         thread = self.context['thread']
 
-        posts_queryset = exclude_invisible_posts(user, thread.category, thread.post_set)
+        posts_queryset = exclude_invisible_posts(user_acl, thread.category, thread.post_set)
         posts_queryset = posts_queryset.filter(id__in=data).order_by('id')
 
         posts = []
@@ -74,10 +74,10 @@ class DeletePostsSerializer(serializers.Serializer):
             post.thread = thread
 
             if post.is_event:
-                allow_delete_event(user, post)
+                allow_delete_event(user_acl, post)
             else:
-                allow_delete_best_answer(user, post)
-                allow_delete_post(user, post)
+                allow_delete_best_answer(user_acl, post)
+                allow_delete_post(user_acl, post)
 
             posts.append(post)
 
@@ -115,10 +115,10 @@ class MergePostsSerializer(serializers.Serializer):
             )
             raise serializers.ValidationError(message % {'limit': POSTS_LIMIT})
 
-        user = self.context['user']
+        user_acl = self.context['user_acl']
         thread = self.context['thread']
 
-        posts_queryset = exclude_invisible_posts(user, thread.category, thread.post_set)
+        posts_queryset = exclude_invisible_posts(user_acl, thread.category, thread.post_set)
         posts_queryset = posts_queryset.filter(id__in=data).order_by('id')
 
         posts = []
@@ -127,7 +127,7 @@ class MergePostsSerializer(serializers.Serializer):
             post.thread = thread
 
             try:
-                allow_merge_post(user, post)
+                allow_merge_post(user_acl, post)
             except PermissionDenied as e:
                 raise serializers.ValidationError(e)
 
@@ -223,7 +223,7 @@ class MovePostsSerializer(serializers.Serializer):
         request = self.context['request']
         thread = self.context['thread']
 
-        posts_queryset = exclude_invisible_posts(request.user, thread.category, thread.post_set)
+        posts_queryset = exclude_invisible_posts(request.user_acl, thread.category, thread.post_set)
         posts_queryset = posts_queryset.filter(id__in=data).order_by('id')
 
         posts = []
@@ -232,7 +232,7 @@ class MovePostsSerializer(serializers.Serializer):
             post.thread = thread
 
             try:
-                allow_move_post(request.user, post)
+                allow_move_post(request.user_acl, post)
                 posts.append(post)
             except PermissionDenied as e:
                 raise serializers.ValidationError(e)
@@ -259,14 +259,15 @@ class NewThreadSerializer(serializers.Serializer):
         return validate_title(title)
 
     def validate_category(self, category_id):
-        self.category = validate_category(self.context['user'], category_id)
-        if not can_start_thread(self.context['user'], self.category):
+        user_acl = self.context['user_acl']
+        self.category = validate_category(user_acl, category_id)
+        if not can_start_thread(user_acl, self.category):
             raise ValidationError(_("You can't create new threads in selected category."))
         return self.category
 
     def validate_weight(self, weight):
         try:
-            add_acl(self.context['user'], self.category)
+            add_acl_to_obj(self.context['user_acl'], self.category)
         except AttributeError:
             return weight  # don't validate weight further if category failed
 
@@ -283,7 +284,7 @@ class NewThreadSerializer(serializers.Serializer):
 
     def validate_is_hidden(self, is_hidden):
         try:
-            add_acl(self.context['user'], self.category)
+            add_acl_to_obj(self.context['user_acl'], self.category)
         except AttributeError:
             return is_hidden  # don't validate hidden further if category failed
 
@@ -293,7 +294,7 @@ class NewThreadSerializer(serializers.Serializer):
 
     def validate_is_closed(self, is_closed):
         try:
-            add_acl(self.context['user'], self.category)
+            add_acl_to_obj(self.context['user_acl'], self.category)
         except AttributeError:
             return is_closed  # don't validate closed further if category failed
 
@@ -331,9 +332,9 @@ class SplitPostsSerializer(NewThreadSerializer):
             raise ValidationError(message % {'limit': POSTS_LIMIT})
 
         thread = self.context['thread']
-        user = self.context['user']
+        user_acl = self.context['user_acl']
 
-        posts_queryset = exclude_invisible_posts(user, thread.category, thread.post_set)
+        posts_queryset = exclude_invisible_posts(user_acl, thread.category, thread.post_set)
         posts_queryset = posts_queryset.filter(id__in=data).order_by('id')
 
         posts = []
@@ -342,7 +343,7 @@ class SplitPostsSerializer(NewThreadSerializer):
             post.thread = thread
 
             try:
-                allow_split_post(user, post)
+                allow_split_post(user_acl, post)
             except PermissionDenied as e:
                 raise ValidationError(e)
 
@@ -389,7 +390,7 @@ class DeleteThreadsSerializer(serializers.Serializer):
         for thread_id in data:
             try:
                 thread = viewmodel(request, thread_id).unwrap()
-                allow_delete_thread(request.user, thread)
+                allow_delete_thread(request.user_acl, thread)
                 threads.append(thread)
             except PermissionDenied as e:
                 errors.append({
@@ -443,7 +444,7 @@ class MergeThreadSerializer(serializers.Serializer):
 
         try:
             other_thread = viewmodel(request, other_thread_id).unwrap()
-            allow_merge_thread(request.user, other_thread, otherthread=True)
+            allow_merge_thread(request.user_acl, other_thread, otherthread=True)
         except PermissionDenied as e:
             raise serializers.ValidationError(e)
         except Http404:
@@ -454,7 +455,7 @@ class MergeThreadSerializer(serializers.Serializer):
                 )
             )
 
-        if not can_reply_thread(request.user, other_thread):
+        if not can_reply_thread(request.user_acl, other_thread):
             raise ValidationError(_("You can't merge this thread into thread you can't reply."))
 
         return other_thread
@@ -518,12 +519,12 @@ class MergeThreadsSerializer(NewThreadSerializer):
             category__tree_id=threads_tree_id,
         ).select_related('category').order_by('-id')
 
-        user = self.context['user']
+        user_acl = self.context['user_acl']
 
         threads = []
         for thread in threads_queryset:
-            add_acl(user, thread)
-            if can_see_thread(user, thread):
+            add_acl_to_obj(user_acl, thread)
+            if can_see_thread(user_acl, thread):
                 threads.append(thread)
 
         if len(threads) != len(data):

+ 109 - 0
misago/threads/test.py

@@ -0,0 +1,109 @@
+from misago.acl.test import patch_user_acl
+from misago.categories.models import Category
+
+default_category_acl = {
+    'can_see': 1,
+    'can_browse': 1,
+    'can_see_all_threads': 1,
+    'can_see_own_threads': 0,
+    'can_hide_threads': 0,
+    'can_approve_content': 0,
+    'can_edit_posts': 0,
+    'can_hide_posts': 0,
+    'can_hide_own_posts': 0,
+    'can_merge_threads': 0,
+    'can_close_threads': 0,
+}
+
+
+def patch_category_acl(acl_patch=None):
+    def patch_acl(_, user_acl):
+        category = Category.objects.get(slug="first-category")
+        category_acl = user_acl['categories'][category.id]
+        category_acl.update(default_category_acl)
+        if acl_patch:
+            category_acl.update(acl_patch)
+        cleanup_patched_acl(user_acl, category_acl, category)
+
+    return patch_user_acl(patch_acl)
+
+
+def patch_other_user_category_acl(acl_patch=None):
+    def patch_acl(user, user_acl):
+        if user.slug != "bobbobertson":
+            return
+
+        category = Category.objects.get(slug="first-category")
+        category_acl = user_acl['categories'][category.id]
+        category_acl.update(default_category_acl)
+        if acl_patch:
+            category_acl.update(acl_patch)
+        cleanup_patched_acl(user_acl, category_acl, category)
+
+    return patch_user_acl(patch_acl)
+
+
+def patch_other_category_acl(acl_patch=None):
+    def patch_acl(_, user_acl):
+        src_category = Category.objects.get(slug="first-category")
+        category_acl = user_acl['categories'][src_category.id].copy()
+
+        dst_category = Category.objects.get(slug="other-category")
+        user_acl['categories'][dst_category.id] = category_acl
+
+        category_acl.update(default_category_acl)
+        if acl_patch:
+            category_acl.update(acl_patch)
+
+        cleanup_patched_acl(user_acl, category_acl, dst_category)
+
+    return patch_user_acl(patch_acl)
+
+
+def patch_private_threads_acl(acl_patch=None):
+    def patch_acl(_, user_acl):
+        category = Category.objects.private_threads()
+        category_acl = user_acl['categories'][category.id]
+        category_acl.update(default_category_acl)
+        if acl_patch:
+            category_acl.update(acl_patch)
+        cleanup_patched_acl(user_acl, category_acl, category)
+
+    return patch_user_acl(patch_acl)
+
+
+def other_user_cant_use_private_threads(user, user_acl):
+    if user.slug == "bobboberson":
+        user_acl.update({"can_use_private_threads": False})
+
+
+def create_category_acl_patch(category_slug, acl_patch):
+    def created_category_acl_patch(_, user_acl):
+        category = Category.objects.get(slug=category_slug)
+        category_acl = user_acl['categories'].get(category.id, {})
+        category_acl.update(default_category_acl)
+        if acl_patch:
+            category_acl.update(acl_patch)
+        cleanup_patched_acl(user_acl, category_acl, category)
+    
+    return created_category_acl_patch
+
+
+def cleanup_patched_acl(user_acl, category_acl, category):
+    visible_categories = user_acl['visible_categories']
+    browseable_categories = user_acl['browseable_categories']
+
+    if not category_acl['can_see'] and category.id in visible_categories:
+        visible_categories.remove(category.id)
+
+    if not category_acl['can_see'] and category.id in browseable_categories:
+        browseable_categories.remove(category.id)
+
+    if not category_acl['can_browse'] and category.id in browseable_categories:
+        browseable_categories.remove(category.id)
+
+    if category_acl['can_see'] and category.id not in visible_categories:
+        visible_categories.append(category.id)
+
+    if category_acl['can_browse'] and category.id not in browseable_categories:
+        browseable_categories.append(category.id)

+ 4 - 14
misago/threads/tests/test_attachments_api.py

@@ -5,12 +5,11 @@ from PIL import Image
 from django.urls import reverse
 
 from misago.acl.models import Role
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.conf import settings
 from misago.threads.models import Attachment, AttachmentType
 from misago.users.testutils import AuthenticatedUserTestCase
 
-
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
 TEST_LARGEPNG_PATH = os.path.join(TESTFILES_DIR, 'large.png')
@@ -27,12 +26,6 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
 
         self.api_link = reverse('misago:api:attachment-list')
 
-    def override_acl(self, new_acl=None):
-        if new_acl:
-            acl = self.user.acl_cache.copy()
-            acl.update(new_acl)
-            override_acl(self.user, acl)
-
     def test_anonymous(self):
         """user has to be authenticated to be able to upload files"""
         self.logout_user()
@@ -40,10 +33,9 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
 
+    @patch_user_acl({"max_attachment_size": 0})
     def test_no_permission(self):
         """user needs permission to upload files"""
-        self.override_acl({'max_attachment_size': 0})
-
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
@@ -181,10 +173,9 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                 ),
             })
 
+    @patch_user_acl({"max_attachment_size": 100})
     def test_upload_too_big_for_user(self):
         """too big uploads are rejected"""
-        self.override_acl({'max_attachment_size': 100})
-
         AttachmentType.objects.create(
             name="Test extension",
             extensions='png',
@@ -302,10 +293,9 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
 
         self.assertEqual(self.user.audittrail_set.count(), 1)
 
+    @patch_user_acl({"max_attachment_size": 10 * 1024})
     def test_large_image_upload(self):
         """successful large image upload creates orphan attachment with thumbnail"""
-        self.override_acl({'max_attachment_size': 10 * 1024})
-
         AttachmentType.objects.create(
             name="Test extension",
             extensions='png',

+ 58 - 31
misago/threads/tests/test_attachments_middleware.py

@@ -1,6 +1,9 @@
+from unittest.mock import Mock
+
 from rest_framework import serializers
 
-from misago.acl.testutils import override_acl
+from misago.acl import useracl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.threads import testutils
@@ -10,10 +13,13 @@ from misago.threads.api.postingendpoint.attachments import (
 from misago.threads.models import Attachment, AttachmentType
 from misago.users.testutils import AuthenticatedUserTestCase
 
+cache_versions = {"acl": "abcdefgh"}
+
 
-class RequestMock(object):
-    def __init__(self, data=None):
-        self.data = data or {}
+def patch_attachments_acl(acl_patch=None):
+    acl_patch = acl_patch or {}
+    acl_patch.setdefault("max_attachment_size", 1024)
+    return patch_user_acl(acl_patch)
 
 
 class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
@@ -26,12 +32,8 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
 
         self.post.update_fields = []
 
-        self.override_acl()
         self.filetype = AttachmentType.objects.order_by('id').last()
 
-    def override_acl(self, new_acl=None):
-        override_acl(self.user, new_acl or {'max_attachment_size': 1024})
-
     def mock_attachment(self, user=True, post=None):
         return Attachment.objects.create(
             secret=Attachment.generate_new_secret(),
@@ -46,54 +48,65 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
 
     def test_use_this_middleware(self):
         """use_this_middleware returns False if we can't upload attachments"""
-        middleware = AttachmentsMiddleware(user=self.user)
-
-        self.override_acl({'max_attachment_size': 0})
-
-        self.assertFalse(middleware.use_this_middleware())
+        with patch_user_acl({'max_attachment_size': 0}):
+            user_acl = useracl.get_user_acl(self.user, cache_versions)
+            middleware = AttachmentsMiddleware(user=self.user, user_acl=user_acl)
+            self.assertFalse(middleware.use_this_middleware())
 
-        self.override_acl({'max_attachment_size': 1024})
-
-        self.assertTrue(middleware.use_this_middleware())
+        with patch_user_acl({'max_attachment_size': 1024}):
+            user_acl = useracl.get_user_acl(self.user, cache_versions)
+            middleware = AttachmentsMiddleware(user=self.user, user_acl=user_acl)
+            self.assertTrue(middleware.use_this_middleware())
 
+    @patch_attachments_acl()
     def test_middleware_is_optional(self):
         """middleware is optional"""
         INPUTS = [{}, {'attachments': []}]
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
+
         for test_input in INPUTS:
             middleware = AttachmentsMiddleware(
-                request=RequestMock(test_input),
+                request=Mock(data=test_input),
                 mode=PostingEndpoint.START,
                 user=self.user,
+                user_acl=user_acl,
                 post=self.post,
             )
 
             serializer = middleware.get_serializer()
             self.assertTrue(serializer.is_valid())
 
+    @patch_attachments_acl()
     def test_middleware_validates_ids(self):
         """middleware validates attachments ids"""
         INPUTS = ['none', ['a', 'b', 123], range(settings.MISAGO_POST_ATTACHMENTS_LIMIT + 1)]
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
+
         for test_input in INPUTS:
             middleware = AttachmentsMiddleware(
-                request=RequestMock({
+                request=Mock(data={
                     'attachments': test_input
                 }),
                 mode=PostingEndpoint.START,
                 user=self.user,
+                user_acl=user_acl,
                 post=self.post,
             )
 
             serializer = middleware.get_serializer()
             self.assertFalse(serializer.is_valid(), "%r shouldn't validate" % test_input)
 
+    @patch_attachments_acl()
     def test_get_initial_attachments(self):
         """get_initial_attachments returns list of attachments already existing on post"""
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
-            request=RequestMock(),
+            request=Mock(data={}),
             mode=PostingEndpoint.EDIT,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
         )
 
@@ -106,16 +119,19 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
 
         attachment = self.mock_attachment(post=self.post)
         attachments = serializer.get_initial_attachments(
-            middleware.mode, middleware.user, middleware.post
+            middleware.mode, middleware.user_acl, middleware.post
         )
         self.assertEqual(attachments, [attachment])
 
+    @patch_attachments_acl()
     def test_get_new_attachments(self):
         """get_initial_attachments returns list of attachments already existing on post"""
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
-            request=RequestMock(),
+            request=Mock(data={}),
             mode=PostingEndpoint.EDIT,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
         )
 
@@ -133,27 +149,27 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         attachments = serializer.get_new_attachments(middleware.user, [other_user_attachment.pk])
         self.assertEqual(attachments, [])
 
+    
+    @patch_attachments_acl({'can_delete_other_users_attachments': False})
     def test_cant_delete_attachment(self):
         """middleware validates if we have permission to delete other users attachments"""
-        self.override_acl({
-            'max_attachment_size': 1024,
-            'can_delete_other_users_attachments': False,
-        })
-
         attachment = self.mock_attachment(user=False, post=self.post)
         self.assertIsNone(attachment.uploader)
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         serializer = AttachmentsMiddleware(
-            request=RequestMock({
+            request=Mock(data={
                 'attachments': []
             }),
             mode=PostingEndpoint.EDIT,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
         ).get_serializer()
 
         self.assertFalse(serializer.is_valid())
 
+    @patch_attachments_acl()
     def test_add_attachments(self):
         """middleware adds attachments to post"""
         attachments = [
@@ -161,12 +177,14 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             self.mock_attachment(),
         ]
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
-            request=RequestMock({
+            request=Mock(data={
                 'attachments': [a.pk for a in attachments]
             }),
             mode=PostingEndpoint.EDIT,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
         )
 
@@ -182,6 +200,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         self.assertEqual([a['filename'] for a in self.post.attachments_cache],
                          attachments_filenames)
 
+    @patch_attachments_acl()
     def test_remove_attachments(self):
         """middleware removes attachment from post and db"""
         attachments = [
@@ -189,12 +208,14 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             self.mock_attachment(post=self.post),
         ]
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
-            request=RequestMock({
+            request=Mock(data={
                 'attachments': [attachments[0].pk]
             }),
             mode=PostingEndpoint.EDIT,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
         )
 
@@ -212,6 +233,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         self.assertEqual([a['filename'] for a in self.post.attachments_cache],
                          attachments_filenames)
 
+    @patch_attachments_acl()
     def test_steal_attachments(self):
         """middleware validates if attachments are already assigned to other posts"""
         other_post = testutils.reply_thread(self.thread)
@@ -221,12 +243,14 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             self.mock_attachment(),
         ]
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
-            request=RequestMock({
+            request=Mock(data={
                 'attachments': [attachments[0].pk, attachments[1].pk]
             }),
             mode=PostingEndpoint.EDIT,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
         )
 
@@ -241,6 +265,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         self.assertEqual(Attachment.objects.get(pk=attachments[0].pk).post, other_post)
         self.assertEqual(Attachment.objects.get(pk=attachments[1].pk).post, self.post)
 
+    @patch_attachments_acl()
     def test_edit_attachments(self):
         """middleware removes and adds attachments to post"""
         attachments = [
@@ -249,12 +274,14 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             self.mock_attachment(),
         ]
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
-            request=RequestMock({
+            request=Mock(data={
                 'attachments': [attachments[0].pk, attachments[2].pk]
             }),
             mode=PostingEndpoint.EDIT,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
         )
 

+ 27 - 21
misago/threads/tests/test_attachmentview.py

@@ -3,19 +3,25 @@ import os
 from django.urls import reverse
 
 from misago.acl.models import Role
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.threads import testutils
 from misago.threads.models import Attachment, AttachmentType
 from misago.users.testutils import AuthenticatedUserTestCase
 
-
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
 TEST_SMALLJPG_PATH = os.path.join(TESTFILES_DIR, 'small.jpg')
 
 
+def patch_attachments_acl(acl_patch=None):
+    acl_patch = acl_patch or {}
+    acl_patch.setdefault("max_attachment_size", 1024)
+    acl_patch.setdefault("can_download_other_users_attachments", True)
+    return patch_user_acl(acl_patch)
+
+
 class AttachmentViewTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
@@ -36,16 +42,6 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
             extensions='pdf',
         )
 
-        self.override_acl()
-
-    def override_acl(self, allow_download=True):
-        acl = self.user.acl_cache.copy()
-        acl.update({
-            'max_attachment_size': 1000,
-            'can_download_other_users_attachments': allow_download,
-        })
-        override_acl(self.user, acl)
-
     def upload_document(self, is_orphaned=False, by_other_user=False):
         with open(TEST_DOCUMENT_PATH, 'rb') as upload:
             response = self.client.post(
@@ -64,8 +60,6 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
             attachment.uploader = None
             attachment.save()
 
-        self.override_acl()
-
         return attachment
 
     def upload_image(self):
@@ -77,25 +71,25 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
             )
         self.assertEqual(response.status_code, 200)
 
-        attachment = Attachment.objects.order_by('id').last()
-
-        self.override_acl()
-
-        return attachment
+        return Attachment.objects.order_by('id').last()
 
+    @patch_attachments_acl()
     def assertIs404(self, response):
         self.assertEqual(response.status_code, 302)
         self.assertTrue(response['location'].endswith(settings.MISAGO_404_IMAGE))
 
+    @patch_attachments_acl()
     def assertIs403(self, response):
         self.assertEqual(response.status_code, 302)
         self.assertTrue(response['location'].endswith(settings.MISAGO_403_IMAGE))
 
+    @patch_attachments_acl()
     def assertSuccess(self, response):
         self.assertEqual(response.status_code, 302)
         self.assertFalse(response['location'].endswith(settings.MISAGO_404_IMAGE))
         self.assertFalse(response['location'].endswith(settings.MISAGO_403_IMAGE))
 
+    @patch_attachments_acl()
     def test_nonexistant_file(self):
         """user tries to retrieve nonexistant file"""
         response = self.client.get(
@@ -107,6 +101,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
 
         self.assertIs404(response)
 
+    @patch_attachments_acl()
     def test_invalid_secret(self):
         """user tries to retrieve existing file using invalid secret"""
         attachment = self.upload_document()
@@ -120,15 +115,15 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
 
         self.assertIs404(response)
 
+    @patch_attachments_acl({"can_download_other_users_attachments": False})
     def test_other_user_file_no_permission(self):
         """user tries to retrieve other user's file without perm"""
         attachment = self.upload_document(by_other_user=True)
 
-        self.override_acl(False)
-
         response = self.client.get(attachment.get_absolute_url())
         self.assertIs403(response)
 
+    @patch_attachments_acl({"can_download_other_users_attachments": False})
     def test_other_user_orphaned_file(self):
         """user tries to retrieve other user's orphaned file"""
         attachment = self.upload_document(is_orphaned=True, by_other_user=True)
@@ -139,6 +134,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         self.assertIs404(response)
 
+    @patch_attachments_acl()
     def test_document_thumbnail(self):
         """user tries to retrieve thumbnail from non-image attachment"""
         attachment = self.upload_document()
@@ -154,6 +150,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         )
         self.assertIs404(response)
 
+    @patch_attachments_acl()
     def test_no_role(self):
         """user tries to retrieve attachment without perm to its type"""
         attachment = self.upload_document()
@@ -164,6 +161,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         self.assertIs403(response)
 
+    @patch_attachments_acl()
     def test_type_disabled(self):
         """user tries to retrieve attachment the type disabled downloads"""
         attachment = self.upload_document()
@@ -174,6 +172,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         self.assertIs403(response)
 
+    @patch_attachments_acl()
     def test_locked_type(self):
         """user retrieves own locked file"""
         attachment = self.upload_document()
@@ -184,6 +183,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         self.assertSuccess(response)
 
+    @patch_attachments_acl()
     def test_own_file(self):
         """user retrieves own file"""
         attachment = self.upload_document()
@@ -191,6 +191,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         self.assertSuccess(response)
 
+    @patch_attachments_acl()
     def test_other_user_file(self):
         """user retrieves other user's file with perm"""
         attachment = self.upload_document(by_other_user=True)
@@ -198,6 +199,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         self.assertSuccess(response)
 
+    @patch_attachments_acl()
     def test_other_user_orphaned_file_is_staff(self):
         """user retrieves other user's orphaned file because he is staff"""
         attachment = self.upload_document(is_orphaned=True, by_other_user=True)
@@ -211,6 +213,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         self.assertSuccess(response)
 
+    @patch_attachments_acl()
     def test_orphaned_file_is_uploader(self):
         """user retrieves orphaned file because he is its uploader"""
         attachment = self.upload_document(is_orphaned=True)
@@ -221,6 +224,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         self.assertSuccess(response)
 
+    @patch_attachments_acl()
     def test_has_role(self):
         """user retrieves file he has roles to download"""
         attachment = self.upload_document()
@@ -231,6 +235,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         self.assertSuccess(response)
 
+    @patch_attachments_acl()
     def test_image(self):
         """user retrieves """
         attachment = self.upload_image()
@@ -238,6 +243,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         self.assertSuccess(response)
 
+    @patch_attachments_acl()
     def test_image_thumb(self):
         """user retrieves image's thumbnail"""
         attachment = self.upload_image()

+ 34 - 36
misago/threads/tests/test_emailnotification_middleware.py

@@ -7,9 +7,11 @@ from django.urls import reverse
 from django.utils import timezone
 from django.utils.encoding import smart_str
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.threads import testutils
+from misago.threads.test import (
+    patch_category_acl, patch_other_user_category_acl
+)
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -25,7 +27,6 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             category=self.category,
             started_on=timezone.now() - timedelta(seconds=5),
         )
-        self.override_acl()
 
         self.api_link = reverse(
             'misago:api:thread-post-list', kwargs={
@@ -33,37 +34,9 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             }
         )
 
-        self.other_user = UserModel.objects.create_user('Bob', 'bob@boberson.com', 'pass123')
-
-    def override_acl(self):
-        new_acl = deepcopy(self.user.acl_cache)
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 1,
-            'can_reply_threads': 1,
-            'can_edit_posts': 1,
-        })
-
-        override_acl(self.user, new_acl)
-
-    def override_other_user_acl(self, hide=False):
-        new_acl = deepcopy(self.other_user.acl_cache)
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 1,
-            'can_reply_threads': 1,
-            'can_edit_posts': 1,
-        })
-
-        if hide:
-            new_acl['categories'][self.category.pk].update({
-                'can_browse': False,
-            })
-
-        override_acl(self.other_user, new_acl)
+        self.other_user = UserModel.objects.create_user('BobBobertson', 'bob@boberson.com')
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_no_subscriptions(self):
         """no emails are sent because noone subscibes to thread"""
         response = self.client.post(
@@ -75,6 +48,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
 
         self.assertEqual(len(mail.outbox), 0)
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_poster_not_notified(self):
         """no emails are sent because only poster subscribes to thread"""
         self.user.subscription_set.create(
@@ -93,6 +67,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
 
         self.assertEqual(len(mail.outbox), 0)
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_other_user_no_email_subscription(self):
         """no emails are sent because subscriber has e-mails off"""
         self.other_user.subscription_set.create(
@@ -111,6 +86,8 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
 
         self.assertEqual(len(mail.outbox), 0)
 
+    @patch_category_acl({"can_reply_threads": True})
+    @patch_other_user_category_acl({"can_see": False})
     def test_other_user_no_permission(self):
         """no emails are sent because subscriber has no permission to read thread"""
         self.other_user.subscription_set.create(
@@ -119,7 +96,6 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             last_read_on=timezone.now(),
             send_email=True,
         )
-        self.override_other_user_acl(hide=True)
 
         response = self.client.post(
             self.api_link, data={
@@ -130,6 +106,29 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
 
         self.assertEqual(len(mail.outbox), 0)
 
+    @patch_category_acl({"can_reply_threads": True})
+    def test_moderation_queue(self):
+        """no emails are sent because new post is moderated"""
+        self.category.require_replies_approval = True
+        self.category.save()
+
+        self.other_user.subscription_set.create(
+            thread=self.thread,
+            category=self.category,
+            last_read_on=timezone.now(),
+            send_email=True,
+        )
+
+        response = self.client.post(
+            self.api_link, data={
+                'post': 'This is test response!',
+            }
+        )
+        self.assertEqual(response.status_code, 200)
+
+        self.assertEqual(len(mail.outbox), 0)
+
+    @patch_category_acl({"can_reply_threads": True})
     def test_other_user_not_read(self):
         """no emails are sent because subscriber didn't read previous post"""
         self.other_user.subscription_set.create(
@@ -138,7 +137,6 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             last_read_on=timezone.now(),
             send_email=True,
         )
-        self.override_other_user_acl()
 
         testutils.reply_thread(self.thread, posted_on=timezone.now())
 
@@ -151,6 +149,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
 
         self.assertEqual(len(mail.outbox), 0)
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_other_notified(self):
         """email is sent to subscriber"""
         self.other_user.subscription_set.create(
@@ -159,7 +158,6 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             last_read_on=timezone.now(),
             send_email=True,
         )
-        self.override_other_user_acl()
 
         response = self.client.post(
             self.api_link, data={
@@ -183,6 +181,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         last_post = self.thread.post_set.order_by('id').last()
         self.assertIn(last_post.get_absolute_url(), message)
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_other_notified_after_reading(self):
         """email is sent to subscriber that had sub updated by read api"""
         self.other_user.subscription_set.create(
@@ -191,7 +190,6 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             last_read_on=self.thread.last_post_on,
             send_email=True,
         )
-        self.override_other_user_acl()
 
         response = self.client.post(
             self.api_link, data={

+ 12 - 14
misago/threads/tests/test_events.py

@@ -1,25 +1,22 @@
+from unittest.mock import Mock
+
 from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.utils import timezone
 
-from misago.acl import add_acl
+from misago.acl import useracl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.threads.events import record_event
 from misago.threads.models import Thread
 
-
-UserModel = get_user_model()
-
-
-class MockRequest(object):
-    def __init__(self, user):
-        self.user = user
-        self.user_ip = '123.14.15.222'
+User = get_user_model()
+cache_versions = {"acl": "abcdefgh"}
 
 
 class EventsApiTests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user("Bob", "bob@bob.com", "Pass.123")
+        self.user = User.objects.create_user("Bob", "bob@bob.com", "Pass.123")
 
         datetime = timezone.now()
 
@@ -37,12 +34,13 @@ class EventsApiTests(TestCase):
         self.thread.set_title("Test thread")
         self.thread.save()
 
-        add_acl(self.user, self.category)
-        add_acl(self.user, self.thread)
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
+        add_acl_to_obj(user_acl, self.category)
+        add_acl_to_obj(user_acl, self.thread)
 
     def test_record_event_with_context(self):
         """record_event registers event with context in thread"""
-        request = MockRequest(self.user)
+        request = Mock(user=self.user, user_ip="123.14.15.222")
         context = {'user': 'Lorem ipsum'}
         event = record_event(request, self.thread, 'announcement', context)
 
@@ -59,7 +57,7 @@ class EventsApiTests(TestCase):
 
     def test_record_event_is_read(self):
         """record_event makes recorded event read to its author"""
-        request = MockRequest(self.user)
+        request = Mock(user=self.user, user_ip="123.14.15.222")
         event = record_event(request, self.thread, 'announcement')
 
         self.user.postread_set.get(

+ 18 - 14
misago/threads/tests/test_floodprotection.py

@@ -1,18 +1,17 @@
 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.threads import testutils
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-class PostMentionsTests(AuthenticatedUserTestCase):
+class FloodProtectionTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
         self.category = Category.objects.get(slug='first-category')
         self.thread = testutils.post_thread(category=self.category)
-        self.override_acl()
 
         self.post_link = reverse(
             'misago:api:thread-post-list', kwargs={
@@ -20,17 +19,6 @@ class PostMentionsTests(AuthenticatedUserTestCase):
             }
         )
 
-    def override_acl(self):
-        new_acl = self.user.acl_cache
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 1,
-            'can_reply_threads': 1,
-        })
-
-        override_acl(self.user, new_acl)
-
     def test_flood_has_no_showstoppers(self):
         """endpoint handles posting interruption"""
         response = self.client.post(
@@ -49,3 +37,19 @@ class PostMentionsTests(AuthenticatedUserTestCase):
         self.assertEqual(response.json(), {
             "detail": "You can't post message so quickly after previous one."
         })
+
+    @patch_user_acl({"can_omit_flood_protection": True})
+    def test_user_with_permission_omits_flood_protection(self):
+        response = self.client.post(
+            self.post_link, data={
+                'post': "This is test response!",
+            }
+        )
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(
+            self.post_link, data={
+                'post': "This is test response!",
+            }
+        )
+        self.assertEqual(response.status_code, 200)

+ 9 - 7
misago/threads/tests/test_floodprotection_middleware.py

@@ -2,11 +2,12 @@ from datetime import timedelta
 
 from django.utils import timezone
 
-from misago.acl.testutils import override_acl
 from misago.threads.api.postingendpoint import PostingInterrupt
 from misago.threads.api.postingendpoint.floodprotection import FloodProtectionMiddleware
 from misago.users.testutils import AuthenticatedUserTestCase
 
+user_acl = {'can_omit_flood_protection': False}
+
 
 class FloodProtectionMiddlewareTests(AuthenticatedUserTestCase):
     def test_flood_protection_middleware_on_no_posts(self):
@@ -14,7 +15,7 @@ class FloodProtectionMiddlewareTests(AuthenticatedUserTestCase):
         self.user.update_fields = []
         self.assertIsNone(self.user.last_posted_on)
 
-        middleware = FloodProtectionMiddleware(user=self.user)
+        middleware = FloodProtectionMiddleware(user=self.user, user_acl=user_acl)
         middleware.interrupt_posting(None)
 
         self.assertIsNotNone(self.user.last_posted_on)
@@ -26,7 +27,7 @@ class FloodProtectionMiddlewareTests(AuthenticatedUserTestCase):
         original_last_posted_on = timezone.now() - timedelta(days=1)
         self.user.last_posted_on = original_last_posted_on
 
-        middleware = FloodProtectionMiddleware(user=self.user)
+        middleware = FloodProtectionMiddleware(user=self.user, user_acl=user_acl)
         middleware.interrupt_posting(None)
 
         self.assertTrue(self.user.last_posted_on > original_last_posted_on)
@@ -36,12 +37,13 @@ class FloodProtectionMiddlewareTests(AuthenticatedUserTestCase):
         self.user.last_posted_on = timezone.now()
 
         with self.assertRaises(PostingInterrupt):
-            middleware = FloodProtectionMiddleware(user=self.user)
+            middleware = FloodProtectionMiddleware(user=self.user, user_acl=user_acl)
             middleware.interrupt_posting(None)
 
     def test_flood_permission(self):
         """middleware is respects permission to flood for team members"""
-        override_acl(self.user, {'can_omit_flood_protection': True})
-
-        middleware = FloodProtectionMiddleware(user=self.user)
+        can_omit_flood_protection_user_acl = {'can_omit_flood_protection': True}
+        middleware = FloodProtectionMiddleware(
+            user=self.user, user_acl=can_omit_flood_protection_user_acl
+        )
         self.assertFalse(middleware.use_this_middleware())

+ 6 - 13
misago/threads/tests/test_gotoviews.py

@@ -1,10 +1,10 @@
 from django.utils import timezone
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.readtracker.poststracker import save_read
 from misago.threads import testutils
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -233,24 +233,18 @@ class GotoBestAnswerTests(GotoViewTestCase):
 
 
 class GotoUnapprovedTests(GotoViewTestCase):
-    def grant_permission(self):
-        self.user.acl_cache['categories'][self.category.pk]['can_approve_content'] = 1
-        override_acl(self.user, self.user.acl_cache)
-
     def test_view_validates_permission(self):
         """view validates permission to see unapproved posts"""
         response = self.client.get(self.thread.get_unapproved_post_url())
         self.assertContains(response, "You need permission to approve content", status_code=403)
 
-        self.grant_permission()
-
-        response = self.client.get(self.thread.get_unapproved_post_url())
-        self.assertEqual(response.status_code, 302)
+        with patch_category_acl({"can_approve_content": True}):
+            response = self.client.get(self.thread.get_unapproved_post_url())
+            self.assertEqual(response.status_code, 302)
 
+    @patch_category_acl({"can_approve_content": True})
     def test_view_handles_no_unapproved_posts(self):
         """if thread has no unapproved posts, redirect to last post"""
-        self.grant_permission()
-
         response = self.client.get(self.thread.get_unapproved_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
@@ -258,6 +252,7 @@ class GotoUnapprovedTests(GotoViewTestCase):
             GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id)
         )
 
+    @patch_category_acl({"can_approve_content": True})
     def test_view_handles_unapproved_posts(self):
         """if thread has unapproved posts, redirect to first of them"""
         for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
@@ -267,8 +262,6 @@ class GotoUnapprovedTests(GotoViewTestCase):
         for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
             testutils.reply_thread(self.thread, posted_on=timezone.now())
 
-        self.grant_permission()
-
         response = self.client.get(self.thread.get_unapproved_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(

+ 0 - 18
misago/threads/tests/test_post_mentions.py

@@ -2,13 +2,11 @@ from django.contrib.auth import get_user_model
 from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.markup.mentions import MENTIONS_LIMIT
 from misago.threads import testutils
 from misago.users.testutils import AuthenticatedUserTestCase
 
-
 UserModel = get_user_model()
 
 
@@ -18,7 +16,6 @@ class PostMentionsTests(AuthenticatedUserTestCase):
 
         self.category = Category.objects.get(slug='first-category')
         self.thread = testutils.post_thread(category=self.category)
-        self.override_acl()
 
         self.post_link = reverse(
             'misago:api:thread-post-list', kwargs={
@@ -26,18 +23,6 @@ class PostMentionsTests(AuthenticatedUserTestCase):
             }
         )
 
-    def override_acl(self):
-        new_acl = self.user.acl_cache
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 1,
-            'can_reply_threads': 1,
-            'can_edit_posts': 1,
-        })
-
-        override_acl(self.user, new_acl)
-
     def put(self, url, data=None):
         content = encode_multipart(BOUNDARY, data or {})
         return self.client.put(url, content, content_type=MULTIPART_CONTENT)
@@ -129,7 +114,6 @@ class PostMentionsTests(AuthenticatedUserTestCase):
             }
         )
 
-        self.override_acl()
         response = self.put(
             edit_link,
             data={
@@ -142,7 +126,6 @@ class PostMentionsTests(AuthenticatedUserTestCase):
         self.assertEqual(list(post.mentions.order_by('id')), [user_a, user_b])
 
         # remove first mention from post - should preserve mentions
-        self.override_acl()
         response = self.put(
             edit_link, data={
                 'post': "This is test response, @%s!" % user_b,
@@ -154,7 +137,6 @@ class PostMentionsTests(AuthenticatedUserTestCase):
         self.assertEqual(list(post.mentions.order_by('id')), [user_a, user_b])
 
         # remove mentions from post - should preserve mentions
-        self.override_acl()
         response = self.put(
             edit_link, data={
                 'post': "This is test response!",

+ 13 - 19
misago/threads/tests/test_privatethread_patch_api.py

@@ -3,13 +3,13 @@ import json
 from django.contrib.auth import get_user_model
 from django.core import mail
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.threads import testutils
+from misago.threads.test import other_user_cant_use_private_threads
 from misago.threads.models import Thread, ThreadParticipant
 
 from .test_privatethreads import PrivateThreadsTestCase
 
-
 UserModel = get_user_model()
 
 
@@ -125,12 +125,11 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
             'detail': ["BobBoberson is blocking you."],
         })
 
+    @patch_user_acl(other_user_cant_use_private_threads)
     def test_add_no_perm_user(self):
         """can't add user that has no permission to use private threads"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
-        override_acl(self.other_user, {'can_use_private_threads': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -146,11 +145,12 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
             'detail': ["BobBoberson can't participate in private threads."],
         })
 
+    @patch_user_acl({"max_private_thread_participants": 3})
     def test_add_too_many_users(self):
         """can't add user that is already participant"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
-        for i in range(self.user.acl_cache['max_private_thread_participants']):
+        for i in range(3):
             user = UserModel.objects.create_user(
                 'User%s' % i, 'user%s@example.com' % i, 'Pass.123'
             )
@@ -219,6 +219,7 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.assertIn(self.user.username, email.subject)
         self.assertIn(self.thread.title, email.subject)
 
+    @patch_user_acl({'can_moderate_private_threads': True})
     def test_add_user_to_other_user_thread_moderator(self):
         """moderators can add users to other users threads"""
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
@@ -226,8 +227,6 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.has_reported_posts = True
         self.thread.save()
 
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         self.patch(
             self.api_link, [
                 {
@@ -246,6 +245,7 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         # notification about new private thread wasn't send because we invited ourselves
         self.assertEqual(len(mail.outbox), 0)
 
+    @patch_user_acl({'can_moderate_private_threads': True})
     def test_add_user_to_closed_moderator(self):
         """moderators can add users to closed threads"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
@@ -253,8 +253,6 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         self.patch(
             self.api_link, [
                 {
@@ -458,6 +456,7 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.assertEqual(self.thread.participants.count(), 1)
         self.assertEqual(self.thread.participants.filter(pk=self.user.pk).count(), 0)
 
+    @patch_user_acl({'can_moderate_private_threads': True})
     def test_moderator_remove_user(self):
         """api allows moderator to remove other user"""
         removed_user = UserModel.objects.create_user('Vigilante', 'test@test.com', 'pass123')
@@ -465,8 +464,6 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.add_participants(self.thread, [self.user, removed_user])
 
-        override_acl(self.user, {'can_moderate_private_threads': True})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -742,6 +739,7 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertTrue(event.is_event)
         self.assertTrue(event.event_type, 'changed_owner')
 
+    @patch_user_acl({'can_moderate_private_threads': True})
     def test_moderator_change_owner(self):
         """moderator can change thread owner to other user"""
         new_owner = UserModel.objects.create_user('NewOwner', 'new@owner.com', 'pass123')
@@ -749,8 +747,6 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.add_participants(self.thread, [self.user, new_owner])
 
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -768,7 +764,7 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertFalse(UserModel.objects.get(pk=self.user.pk).sync_unread_private_threads)
         self.assertTrue(UserModel.objects.get(pk=self.other_user.pk).sync_unread_private_threads)
 
-        # ownership was transfered
+        # ownership was transferred
         self.assertEqual(self.thread.participants.count(), 3)
         self.assertTrue(ThreadParticipant.objects.get(user=new_owner).is_owner)
         self.assertFalse(ThreadParticipant.objects.get(user=self.user).is_owner)
@@ -779,13 +775,12 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertTrue(event.is_event)
         self.assertTrue(event.event_type, 'changed_owner')
 
+    @patch_user_acl({'can_moderate_private_threads': True})
     def test_moderator_takeover(self):
         """moderator can takeover the thread"""
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
 
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -812,6 +807,7 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertTrue(event.is_event)
         self.assertTrue(event.event_type, 'tookover')
 
+    @patch_user_acl({'can_moderate_private_threads': True})
     def test_moderator_closed_thread_takeover(self):
         """moderator can takeover closed thread thread"""
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
@@ -820,8 +816,6 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -838,7 +832,7 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertFalse(UserModel.objects.get(pk=self.user.pk).sync_unread_private_threads)
         self.assertTrue(UserModel.objects.get(pk=self.other_user.pk).sync_unread_private_threads)
 
-        # ownership was transfered
+        # ownership was transferred
         self.assertEqual(self.thread.participants.count(), 2)
         self.assertTrue(ThreadParticipant.objects.get(user=self.user).is_owner)
         self.assertFalse(ThreadParticipant.objects.get(user=self.other_user).is_owner)

+ 41 - 45
misago/threads/tests/test_privatethread_start_api.py

@@ -3,12 +3,12 @@ from django.core import mail
 from django.urls import reverse
 from django.utils.encoding import smart_str
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.threads.models import ThreadParticipant
+from misago.threads.test import other_user_cant_use_private_threads
 from misago.users.testutils import AuthenticatedUserTestCase
 
-
 UserModel = get_user_model()
 
 
@@ -30,20 +30,18 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
 
+    @patch_user_acl({'can_use_private_threads': False})
     def test_cant_use_private_threads(self):
         """has no permission to use private threads"""
-        override_acl(self.user, {'can_use_private_threads': 0})
-
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't use private threads.",
         })
 
+    @patch_user_acl({'can_start_private_threads': False})
     def test_cant_start_private_thread(self):
         """permission to start private thread is validated"""
-        override_acl(self.user, {'can_start_private_threads': 0})
-
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
@@ -153,10 +151,9 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_user_acl(other_user_cant_use_private_threads)
     def test_cant_invite_no_permission(self):
         """api validates invited user permission to private thread"""
-        override_acl(self.other_user, {'can_use_private_threads': 0})
-
         response = self.client.post(
             self.api_link,
             data={
@@ -191,8 +188,10 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
             'to': ["BobBoberson is blocking you."],
         })
 
-        # allow us to bypass blocked check
-        override_acl(self.user, {'can_add_everyone_to_private_threads': 1})
+    @patch_user_acl({'can_add_everyone_to_private_threads': 1})
+    def test_cant_invite_blocking_override(self):
+        """api validates that you cant invite blocking user to thread"""
+        self.other_user.blocks.add(self.user)
 
         response = self.client.post(
             self.api_link,
@@ -233,26 +232,24 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         )
 
         # allow us to bypass following check
-        override_acl(self.user, {'can_add_everyone_to_private_threads': 1})
-
-        response = self.client.post(
-            self.api_link,
-            data={
-                'to': [self.other_user.username],
-                'title': "-----",
-                'post': "Lorem ipsum dolor.",
-            }
-        )
-
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(
-            response.json(), {
-                'title': ["Thread title should contain alpha-numeric characters."],
-            }
-        )
+        with patch_user_acl({'can_add_everyone_to_private_threads': 1}):
+            response = self.client.post(
+                self.api_link,
+                data={
+                    'to': [self.other_user.username],
+                    'title': "-----",
+                    'post': "Lorem ipsum dolor.",
+                }
+            )
+
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(
+                response.json(), {
+                    'title': ["Thread title should contain alpha-numeric characters."],
+                }
+            )
 
         # make user follow us
-        override_acl(self.user, {'can_add_everyone_to_private_threads': 0})
         self.other_user.follows.add(self.user)
 
         response = self.client.post(
@@ -294,23 +291,22 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         )
 
         # allow us to bypass user preference check
-        override_acl(self.user, {'can_add_everyone_to_private_threads': 1})
-
-        response = self.client.post(
-            self.api_link,
-            data={
-                'to': [self.other_user.username],
-                'title': "-----",
-                'post': "Lorem ipsum dolor.",
-            }
-        )
-
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(
-            response.json(), {
-                'title': ["Thread title should contain alpha-numeric characters."],
-            }
-        )
+        with patch_user_acl({'can_add_everyone_to_private_threads': 1}):
+            response = self.client.post(
+                self.api_link,
+                data={
+                    'to': [self.other_user.username],
+                    'title': "-----",
+                    'post': "Lorem ipsum dolor.",
+                }
+            )
+
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(
+                response.json(), {
+                    'title': ["Thread title should contain alpha-numeric characters."],
+                }
+            )
 
     def test_can_start_thread(self):
         """endpoint creates new thread"""

+ 4 - 7
misago/threads/tests/test_privatethread_view.py

@@ -1,4 +1,4 @@
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.threads import testutils
 from misago.threads.models import ThreadParticipant
 
@@ -19,10 +19,9 @@ class PrivateThreadViewTests(PrivateThreadsTestCase):
         response = self.client.get(self.test_link)
         self.assertContains(response, "sign in to use private threads", status_code=403)
 
+    @patch_user_acl({"can_use_private_threads": False})
     def test_no_permission(self):
         """user needs to have permission to see private thread"""
-        override_acl(self.user, {'can_use_private_threads': 0})
-
         response = self.client.get(self.test_link)
         self.assertContains(response, "t use private threads", status_code=403)
 
@@ -31,10 +30,9 @@ class PrivateThreadViewTests(PrivateThreadsTestCase):
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 404)
 
+    @patch_user_acl({"can_moderate_private_threads": True})
     def test_mod_not_reported(self):
         """moderator can't see private thread that has no reports"""
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 404)
 
@@ -60,10 +58,9 @@ class PrivateThreadViewTests(PrivateThreadsTestCase):
         response = self.client.get(self.test_link)
         self.assertContains(response, self.thread.title)
 
+    @patch_user_acl({"can_moderate_private_threads": True})
     def test_mod_can_see_reported(self):
         """moderator can see private thread that has reports"""
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         self.thread.has_reported_posts = True
         self.thread.save()
 

+ 0 - 32
misago/threads/tests/test_privatethreads.py

@@ -1,4 +1,3 @@
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.users.testutils import AuthenticatedUserTestCase
 
@@ -7,34 +6,3 @@ class PrivateThreadsTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
         self.category = Category.objects.private_threads()
-
-        override_acl(self.user, {
-            'can_use_private_threads': 1,
-            'can_start_private_threads': 1,
-        })
-
-        self.override_acl()
-
-    def override_acl(self, acl=None):
-        final_acl = self.user.acl_cache['categories'][self.category.pk]
-        final_acl.update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_see_all_threads': 1,
-            'can_see_own_threads': 0,
-            'can_hide_threads': 0,
-            'can_approve_content': 0,
-            'can_edit_posts': 0,
-            'can_hide_posts': 0,
-            'can_hide_own_posts': 0,
-            'can_merge_threads': 0,
-        })
-
-        if acl:
-            final_acl.update(acl)
-
-        override_acl(self.user, {
-            'categories': {
-                self.category.pk: final_acl,
-            },
-        })

+ 35 - 25
misago/threads/tests/test_privatethreads_api.py

@@ -1,8 +1,9 @@
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.threads import testutils
 from misago.threads.models import Thread, ThreadParticipant
+from misago.threads.test import patch_private_threads_acl
 
 from .test_privatethreads import PrivateThreadsTestCase
 
@@ -23,16 +24,16 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
             "detail": "You have to sign in to use private threads."
         })
 
+    @patch_user_acl({"can_use_private_threads": False})
     def test_no_permission(self):
         """api requires user to have permission to be able to access it"""
-        override_acl(self.user, {'can_use_private_threads': 0})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't use private threads."
         })
 
+    @patch_user_acl({"can_use_private_threads": True})
     def test_empty_list(self):
         """api has no showstoppers on returning empty list"""
         response = self.client.get(self.api_link)
@@ -41,6 +42,7 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
         response_json = response.json()
         self.assertEqual(response_json['count'], 0)
 
+    @patch_user_acl({"can_use_private_threads": True})
     def test_thread_visibility(self):
         """only participated threads are returned by private threads api"""
         visible = testutils.post_thread(category=self.category, poster=self.user)
@@ -62,15 +64,14 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
         self.assertEqual(response_json['results'][0]['id'], visible.id)
 
         # threads with reported posts will also show to moderators
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 200)
+        with patch_user_acl({"can_moderate_private_threads": True}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 200)
 
-        response_json = response.json()
-        self.assertEqual(response_json['count'], 2)
-        self.assertEqual(response_json['results'][0]['id'], reported.id)
-        self.assertEqual(response_json['results'][1]['id'], visible.id)
+            response_json = response.json()
+            self.assertEqual(response_json['count'], 2)
+            self.assertEqual(response_json['results'][0]['id'], reported.id)
+            self.assertEqual(response_json['results'][1]['id'], visible.id)
 
 
 class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
@@ -90,28 +91,34 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
             "detail": "You have to sign in to use private threads."
         })
 
+    @patch_user_acl({"can_use_private_threads": False})
     def test_no_permission(self):
         """user needs to have permission to see private thread"""
-        override_acl(self.user, {'can_use_private_threads': 0})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't use private threads."
         })
 
+    @patch_user_acl({"can_use_private_threads": True})
     def test_no_participant(self):
         """user cant see thread he isn't part of"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
 
+    @patch_user_acl({
+        "can_use_private_threads": True,
+        "can_moderate_private_threads": True,
+    })
     def test_mod_not_reported(self):
         """moderator can't see private thread that has no reports"""
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
 
+    @patch_user_acl({
+        "can_use_private_threads": True,
+        "can_moderate_private_threads": False,
+    })
     def test_reported_not_mod(self):
         """non-mod can't see private thread that has reported posts"""
         self.thread.has_reported_posts = True
@@ -120,6 +127,7 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
 
+    @patch_user_acl({"can_use_private_threads": True})
     def test_can_see_owner(self):
         """user can see thread he is owner of"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
@@ -141,6 +149,7 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
             ]
         )
 
+    @patch_user_acl({"can_use_private_threads": True})
     def test_can_see_participant(self):
         """user can see thread he is participant of"""
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
@@ -162,10 +171,12 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
             ]
         )
 
+    @patch_user_acl({
+        "can_use_private_threads": True,
+        "can_moderate_private_threads": True,
+    })
     def test_mod_can_see_reported(self):
         """moderator can see private thread that has reports"""
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         self.thread.has_reported_posts = True
         self.thread.save()
 
@@ -186,30 +197,29 @@ class PrivateThreadDeleteApiTests(PrivateThreadsTestCase):
 
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
 
-    def test_delete_thread_no_permission(self):
+    @patch_private_threads_acl({"can_hide_threads": 0})
+    def test_hide_thread_no_permission(self):
         """api tests permission to delete threads"""
-        self.override_acl({'can_hide_threads': 0})
-
+        
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-
         self.assertEqual(
             response.json()['detail'], "You can't delete threads in this category."
         )
 
-        self.override_acl({'can_hide_threads': 1})
 
+    @patch_private_threads_acl({"can_hide_threads": 1})
+    def test_delete_thread_no_permission(self):
+        """api tests permission to delete threads"""
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-
         self.assertEqual(
             response.json()['detail'], "You can't delete threads in this category."
         )
 
+    @patch_private_threads_acl({"can_hide_threads": 2})
     def test_delete_thread(self):
         """DELETE to API link with permission deletes thread"""
-        self.override_acl({'can_hide_threads': 2})
-
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
 

+ 7 - 9
misago/threads/tests/test_privatethreads_lists.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 import testutils
 from misago.threads.models import ThreadParticipant
 
@@ -20,10 +20,9 @@ class PrivateThreadsListTests(PrivateThreadsTestCase):
         response = self.client.get(self.test_link)
         self.assertContains(response, "sign in to use private threads", status_code=403)
 
+    @patch_user_acl({"can_use_private_threads": False})
     def test_no_permission(self):
         """view requires user to have permission to be able to access it"""
-        override_acl(self.user, {'can_use_private_threads': 0})
-
         response = self.client.get(self.test_link)
         self.assertContains(response, "use private threads", status_code=403)
 
@@ -51,9 +50,8 @@ class PrivateThreadsListTests(PrivateThreadsTestCase):
         self.assertContains(response, visible.get_absolute_url())
 
         # threads with reported posts will also show to moderators
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
-        response = self.client.get(self.test_link)
-        self.assertEqual(response.status_code, 200)
-        self.assertContains(response, reported.get_absolute_url())
-        self.assertContains(response, visible.get_absolute_url())
+        with patch_user_acl({"can_moderate_private_threads": True}):
+            response = self.client.get(self.test_link)
+            self.assertEqual(response.status_code, 200)
+            self.assertContains(response, reported.get_absolute_url())
+            self.assertContains(response, visible.get_absolute_url())

+ 11 - 14
misago/threads/tests/test_subscription_middleware.py

@@ -1,9 +1,10 @@
 from django.contrib.auth import get_user_model
 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.threads import testutils
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -14,19 +15,6 @@ class SubscriptionMiddlewareTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
         self.category = Category.objects.get(slug='first-category')
-        self.override_acl()
-
-    def override_acl(self):
-        new_acl = self.user.acl_cache
-        new_acl['can_omit_flood_protection'] = True
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 1,
-            'can_reply_threads': 1,
-        })
-
-        override_acl(self.user, new_acl)
 
 
 class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
@@ -34,6 +22,7 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         super().setUp()
         self.api_link = reverse('misago:api:thread-list')
 
+    @patch_category_acl({"can_start_threads": True})
     def test_dont_subscribe(self):
         """middleware makes no subscription to thread"""
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NONE
@@ -53,6 +42,7 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         # user has no subscriptions
         self.assertEqual(self.user.subscription_set.count(), 0)
 
+    @patch_category_acl({"can_start_threads": True})
     def test_subscribe(self):
         """middleware subscribes thread"""
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NOTIFY
@@ -75,6 +65,7 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertFalse(subscription.send_email)
 
+    @patch_category_acl({"can_start_threads": True})
     def test_email_subscribe(self):
         """middleware subscribes thread with an email"""
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_ALL
@@ -108,6 +99,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
             }
         )
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_dont_subscribe(self):
         """middleware makes no subscription to thread"""
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NOTIFY
@@ -124,6 +116,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         # user has no subscriptions
         self.assertEqual(self.user.subscription_set.count(), 0)
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_subscribe(self):
         """middleware subscribes thread"""
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NOTIFY
@@ -142,6 +135,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertFalse(subscription.send_email)
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_email_subscribe(self):
         """middleware subscribes thread with an email"""
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
@@ -160,6 +154,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertTrue(subscription.send_email)
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_subscribe_with_events(self):
         """middleware omits events when testing for replied thread"""
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
@@ -182,6 +177,8 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertTrue(subscription.send_email)
 
+    @patch_category_acl({"can_reply_threads": True})
+    @patch_user_acl({"can_omit_flood_protection": True})
     def test_dont_subscribe_replied(self):
         """middleware omits threads user already replied"""
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL

+ 18 - 49
misago/threads/tests/test_thread_bulkpatch_api.py

@@ -2,10 +2,10 @@ import json
 
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads.models import Thread
+from misago.threads.test import patch_category_acl, patch_other_category_acl
 
 from .test_threads_api import ThreadsApiTestCase
 
@@ -183,10 +183,9 @@ class ThreadAddAclApiTests(ThreadsBulkPatchApiTestCase):
 
 
 class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
+    @patch_category_acl({"can_edit_threads": 2})
     def test_change_thread_title(self):
         """api changes thread title and resyncs the category"""
-        self.override_acl({'can_edit_threads': 2})
-
         response = self.patch(
             self.api_link,
             {
@@ -210,13 +209,12 @@ class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
         for thread in Thread.objects.filter(id__in=self.ids):
             self.assertEqual(thread.title, 'Changed the title!')
 
-        category = Category.objects.get(pk=self.category.pk)
+        category = Category.objects.get(pk=self.category.id)
         self.assertEqual(category.last_thread_title, 'Changed the title!')
 
+    @patch_category_acl({"can_edit_threads": 0})
     def test_change_thread_title_no_permission(self):
         """api validates permission to change title, returns errors"""
-        self.override_acl({'can_edit_threads': 0})
-
         response = self.patch(
             self.api_link,
             {
@@ -246,46 +244,19 @@ class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
         super().setUp()
 
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other Category',
+            slug='other-category',
         ).insert_at(
             self.category,
             position='last-child',
             save=True,
         )
-        self.category_b = Category.objects.get(slug='category-b')
-
-    def override_other_acl(self, acl):
-        other_category_acl = self.user.acl_cache['categories'][self.category.pk].copy()
-        other_category_acl.update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_see_all_threads': 1,
-            'can_see_own_threads': 0,
-            'can_hide_threads': 0,
-            'can_approve_content': 0,
-        })
-        other_category_acl.update(acl)
-
-        categories_acl = self.user.acl_cache['categories']
-        categories_acl[self.category_b.pk] = other_category_acl
-
-        visible_categories = [self.category.pk]
-        if other_category_acl['can_see']:
-            visible_categories.append(self.category_b.pk)
-
-        override_acl(
-            self.user, {
-                'visible_categories': visible_categories,
-                'categories': categories_acl,
-            }
-        )
+        self.other_category = Category.objects.get(slug='other-category')
 
+    @patch_category_acl({"can_move_threads": True})
+    @patch_other_category_acl({"can_start_threads": 2})
     def test_move_thread(self):
         """api moves threads to other category and syncs both categories"""
-        self.override_acl({'can_move_threads': True})
-        self.override_other_acl({'can_start_threads': 2})
-
         response = self.patch(
             self.api_link,
             {
@@ -294,7 +265,7 @@ class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
                     {
                         'op': 'replace',
                         'path': 'category',
-                        'value': self.category_b.pk,
+                        'value': self.other_category.id,
                     },
                     {
                         'op': 'replace',
@@ -309,23 +280,22 @@ class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
         response_json = response.json()
         for i, thread in enumerate(self.threads):
             self.assertEqual(response_json[i]['id'], thread.id)
-            self.assertEqual(response_json[i]['category'], self.category_b.pk)
+            self.assertEqual(response_json[i]['category'], self.other_category.id)
 
         for thread in Thread.objects.filter(id__in=self.ids):
-            self.assertEqual(thread.category_id, self.category_b.pk)
+            self.assertEqual(thread.category_id, self.other_category.id)
 
-        category = Category.objects.get(pk=self.category.pk)
+        category = Category.objects.get(pk=self.category.id)
         self.assertEqual(category.threads, self.category.threads - 3)
 
-        new_category = Category.objects.get(pk=self.category_b.pk)
+        new_category = Category.objects.get(pk=self.other_category.id)
         self.assertEqual(new_category.threads, 3)
 
 
 class BulkThreadsHideApiTests(ThreadsBulkPatchApiTestCase):
+    @patch_category_acl({"can_hide_threads": 1})
     def test_hide_thread(self):
         """api makes it possible to hide thread"""
-        self.override_acl({'can_hide_threads': 1})
-
         response = self.patch(
             self.api_link,
             {
@@ -349,11 +319,12 @@ class BulkThreadsHideApiTests(ThreadsBulkPatchApiTestCase):
         for thread in Thread.objects.filter(id__in=self.ids):
             self.assertTrue(thread.is_hidden)
 
-        category = Category.objects.get(pk=self.category.pk)
+        category = Category.objects.get(pk=self.category.id)
         self.assertNotIn(category.last_thread_id, self.ids)
 
 
 class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
+    @patch_category_acl({"can_approve_content": True})
     def test_approve_thread(self):
         """api approvse threads and syncs category"""
         for thread in self.threads:
@@ -369,8 +340,6 @@ class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
         self.category.synchronize()
         self.category.save()
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
             self.api_link,
             {
@@ -396,5 +365,5 @@ class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
             self.assertFalse(thread.is_unapproved)
             self.assertFalse(thread.has_unapproved_posts)
 
-        category = Category.objects.get(pk=self.category.pk)
+        category = Category.objects.get(pk=self.category.id)
         self.assertIn(category.last_thread_id, self.ids)

+ 70 - 95
misago/threads/tests/test_thread_editreply_api.py

@@ -4,10 +4,11 @@ from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
 from django.urls import reverse
 from django.utils import timezone
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads.models import Post, Thread
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -27,21 +28,6 @@ class EditReplyTests(AuthenticatedUserTestCase):
             }
         )
 
-    def override_acl(self, extra_acl=None):
-        new_acl = self.user.acl_cache
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 0,
-            'can_reply_threads': 0,
-            'can_edit_posts': 1,
-        })
-
-        if extra_acl:
-            new_acl['categories'][self.category.pk].update(extra_acl)
-
-        override_acl(self.user, new_acl)
-
     def put(self, url, data=None):
         content = encode_multipart(BOUNDARY, data or {})
         return self.client.put(url, content, content_type=MULTIPART_CONTENT)
@@ -55,32 +41,30 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
     def test_thread_visibility(self):
         """thread's visibility is validated"""
-        self.override_acl({'can_see': 0})
-        response = self.put(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_see': False}):
+            response = self.put(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
-        self.override_acl({'can_browse': 0})
-        response = self.put(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_browse': False}):
+            response = self.put(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
-        self.override_acl({'can_see_all_threads': 0})
-        response = self.put(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_see_all_threads': False}):
+            response = self.put(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
+    @patch_category_acl({"can_edit_posts": 0})
     def test_cant_edit_reply(self):
         """permission to edit reply is validated"""
-        self.override_acl({'can_edit_posts': 0})
-
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't edit posts in this category.",
         })
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_cant_edit_other_user_reply(self):
         """permission to edit reply by other users is validated"""
-        self.override_acl({'can_edit_posts': 1})
-
         self.post.poster = None
         self.post.save()
 
@@ -90,13 +74,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "detail": "You can't edit other users posts in this category.",
         })
 
+    @patch_category_acl({"can_edit_posts": 1, "post_edit_time": 1})
     def test_edit_too_old(self):
         """permission to edit reply within timelimit is validated"""
-        self.override_acl({
-            'can_edit_posts': 1,
-            'post_edit_time': 1,
-        })
-
         self.post.posted_on = timezone.now() - timedelta(minutes=5)
         self.post.save()
 
@@ -106,10 +86,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "detail": "You can't edit posts that are older than 1 minute.",
         })
 
-    def test_closed_category(self):
+    @patch_category_acl({"can_edit_posts": 1, "can_close_threads": False})
+    def test_closed_category_no_permission(self):
         """permssion to edit reply in closed category is validated"""
-        self.override_acl({'can_close_threads': 0})
-
         self.category.is_closed = True
         self.category.save()
 
@@ -119,16 +98,18 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "detail": "This category is closed. You can't edit posts in it.",
         })
 
-        # allow to post in closed category
-        self.override_acl({'can_close_threads': 1})
+    @patch_category_acl({"can_edit_posts": 1, "can_close_threads": True})
+    def test_closed_category(self):
+        """permssion to edit reply in closed category is validated"""
+        self.category.is_closed = True
+        self.category.save()
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
 
-    def test_closed_thread(self):
+    @patch_category_acl({"can_edit_posts": 1, "can_close_threads": False})
+    def test_closed_thread_no_permission(self):
         """permssion to edit reply in closed thread is validated"""
-        self.override_acl({'can_close_threads': 0})
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -138,16 +119,18 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "detail": "This thread is closed. You can't edit posts in it.",
         })
 
-        # allow to post in closed thread
-        self.override_acl({'can_close_threads': 1})
+    @patch_category_acl({"can_edit_posts": 1, "can_close_threads": True})
+    def test_closed_thread(self):
+        """permssion to edit reply in closed thread is validated"""
+        self.thread.is_closed = True
+        self.thread.save()
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
 
-    def test_protected_post(self):
+    @patch_category_acl({"can_edit_posts": 1, "can_protect_posts": False})
+    def test_protected_post_no_permission(self):
         """permssion to edit protected post is validated"""
-        self.override_acl({'can_protect_posts': 0})
-
         self.post.is_protected = True
         self.post.save()
 
@@ -157,26 +140,27 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "detail": "This post is protected. You can't edit it.",
         })
 
-        # allow to post in closed thread
-        self.override_acl({'can_protect_posts': 1})
+    @patch_category_acl({"can_edit_posts": 1, "can_protect_posts": True})
+    def test_protected_post_no(self):
+        """permssion to edit protected post is validated"""
+        self.post.is_protected = True
+        self.post.save()
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_empty_data(self):
         """no data sent handling has no showstoppers"""
-        self.override_acl()
-
         response = self.put(self.api_link, data={})
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "post": ["You have to enter a message."],
         })
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_invalid_data(self):
         """api errors for invalid request data"""
-        self.override_acl()
-
         response = self.client.put(
             self.api_link,
             'false',
@@ -187,10 +171,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "non_field_errors": ["Invalid data. Expected a dictionary, but got bool."]
         })
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_edit_event(self):
         """events can't be edited"""
-        self.override_acl()
-
         self.post.is_event = True
         self.post.save()
 
@@ -200,10 +183,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "detail": "Events can't be edited.",
         })
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_post_is_validated(self):
         """post is validated"""
-        self.override_acl()
-
         response = self.put(
             self.api_link, data={
                 'post': "a",
@@ -216,15 +198,14 @@ class EditReplyTests(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_edit_reply_no_change(self):
         """endpoint isn't bumping edits count if no change was made to post's body"""
-        self.override_acl()
         self.assertEqual(self.post.edits_record.count(), 0)
 
         response = self.put(self.api_link, data={'post': self.post.original})
         self.assertEqual(response.status_code, 200)
 
-        self.override_acl()
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, self.post.parsed)
 
@@ -237,15 +218,14 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
         self.assertEqual(self.post.edits_record.count(), 0)
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_edit_reply(self):
         """endpoint updates reply"""
-        self.override_acl()
         self.assertEqual(self.post.edits_record.count(), 0)
 
         response = self.put(self.api_link, data={'post': "This is test edit!"})
         self.assertEqual(response.status_code, 200)
 
-        self.override_acl()
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, "<p>This is test edit!</p>")
 
@@ -268,10 +248,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.assertEqual(post_edit.editor_name, self.user.username)
         self.assertEqual(post_edit.editor_slug, self.user.slug)
 
+    @patch_category_acl({"can_edit_posts": 2, "can_hide_threads": 1})
     def test_edit_first_post_hidden(self):
         """endpoint updates hidden thread's first post"""
-        self.override_acl({'can_hide_threads': 1, 'can_edit_posts': 2})
-
         self.thread.is_hidden = True
         self.thread.save()
         self.thread.first_post.is_hidden = True
@@ -288,10 +267,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
         response = self.put(api_link, data={'post': "This is test edit!"})
         self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({"can_edit_posts": 1, "can_protect_posts": True})
     def test_protect_post(self):
         """can protect post"""
-        self.override_acl({'can_protect_posts': 1})
-
         response = self.put(
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
@@ -303,10 +281,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = self.user.post_set.order_by('id').last()
         self.assertTrue(post.is_protected)
 
+    @patch_category_acl({"can_edit_posts": 1, "can_protect_posts": False})
     def test_protect_post_no_permission(self):
         """cant protect post without permission"""
-        self.override_acl({'can_protect_posts': 0})
-
         response = self.put(
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
@@ -318,10 +295,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = self.user.post_set.order_by('id').last()
         self.assertFalse(post.is_protected)
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_post_unicode(self):
         """unicode characters can be posted"""
-        self.override_acl()
-
         response = self.put(
             self.api_link, data={
                 'post': "Chrzążczyżewoszyce, powiat Łękółody.",
@@ -329,6 +305,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_reply_category_moderation_queue(self):
         """edit sends reply to queue due to category setup"""
         self.category.require_edits_approval = True
@@ -344,10 +321,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertTrue(post.is_unapproved)
 
+    @patch_category_acl({"can_edit_posts": 1})
+    @patch_user_acl({"can_approve_content": True})
     def test_reply_category_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
-        override_acl(self.user, {'can_approve_content': 1})
-
         self.category.require_edits_approval = True
         self.category.save()
 
@@ -361,10 +338,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertFalse(post.is_unapproved)
 
+    @patch_category_acl({"can_edit_posts": 1, "require_edits_approval": True})
     def test_reply_user_moderation_queue(self):
         """edit sends reply to queue due to user acl"""
-        self.override_acl({'require_edits_approval': 1})
-
         response = self.put(
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
@@ -375,12 +351,13 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertTrue(post.is_unapproved)
 
+    @patch_category_acl({
+        "can_edit_posts": 1,
+        "require_edits_approval": True,
+    })
+    @patch_user_acl({"can_approve_content": True})
     def test_reply_user_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
-        override_acl(self.user, {'can_approve_content': 1})
-
-        self.override_acl({'require_edits_approval': 1})
-
         response = self.put(
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
@@ -391,17 +368,17 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertFalse(post.is_unapproved)
 
+    @patch_category_acl({
+        "can_edit_posts": 1,
+        "require_threads_approval": True,
+        "require_replies_approval": True,
+    })
     def test_reply_omit_other_moderation_queues(self):
         """other queues are omitted"""
         self.category.require_threads_approval = True
         self.category.require_replies_approval = True
         self.category.save()
 
-        self.override_acl({
-            'require_threads_approval': 1,
-            'require_replies_approval': 1,
-        })
-
         response = self.put(
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
@@ -426,6 +403,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_first_reply_category_moderation_queue(self):
         """edit sends thread to queue due to category setup"""
         self.setUpFirstReplyTest()
@@ -447,12 +425,12 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = Post.objects.get(pk=self.post.pk)
         self.assertTrue(post.is_unapproved)
 
+    @patch_category_acl({"can_edit_posts": 1})
+    @patch_user_acl({'can_approve_content': True})
     def test_first_reply_category_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         self.setUpFirstReplyTest()
 
-        override_acl(self.user, {'can_approve_content': 1})
-
         self.category.require_edits_approval = True
         self.category.save()
 
@@ -470,12 +448,11 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = Post.objects.get(pk=self.post.pk)
         self.assertFalse(post.is_unapproved)
 
+    @patch_category_acl({"can_edit_posts": 1, "require_edits_approval": True})
     def test_first_reply_user_moderation_queue(self):
         """edit sends thread to queue due to user acl"""
         self.setUpFirstReplyTest()
 
-        self.override_acl({'require_edits_approval': 1})
-
         response = self.put(
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
@@ -490,14 +467,12 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = Post.objects.get(pk=self.post.pk)
         self.assertTrue(post.is_unapproved)
 
+    @patch_category_acl({"can_edit_posts": 1, "require_edits_approval": True})
+    @patch_user_acl({'can_approve_content': True})
     def test_first_reply_user_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         self.setUpFirstReplyTest()
 
-        override_acl(self.user, {'can_approve_content': 1})
-
-        self.override_acl({'require_edits_approval': 1})
-
         response = self.put(
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
@@ -512,6 +487,11 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = Post.objects.get(pk=self.post.pk)
         self.assertFalse(post.is_unapproved)
 
+    @patch_category_acl({
+        "can_edit_posts": 1,
+        "require_threads_approval": True,
+        "require_replies_approval": True,
+    })
     def test_first_reply_omit_other_moderation_queues(self):
         """other queues are omitted"""
         self.setUpFirstReplyTest()
@@ -520,11 +500,6 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.category.require_replies_approval = True
         self.category.save()
 
-        self.override_acl({
-            'require_threads_approval': 1,
-            'require_replies_approval': 1,
-        })
-
         response = self.put(
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",

+ 98 - 186
misago/threads/tests/test_thread_merge_api.py

@@ -1,10 +1,10 @@
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.readtracker import poststracker
 from misago.threads import testutils
 from misago.threads.models import Poll, PollVote, Thread
+from misago.threads.test import patch_category_acl, patch_other_category_acl
 
 from .test_threads_api import ThreadsApiTestCase
 
@@ -12,16 +12,16 @@ from .test_threads_api import ThreadsApiTestCase
 class ThreadMergeApiTests(ThreadsApiTestCase):
     def setUp(self):
         super().setUp()
-
+        
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other Category',
+            slug='other-category',
         ).insert_at(
             self.category,
             position='last-child',
             save=True,
         )
-        self.category_b = Category.objects.get(slug='category-b')
+        self.other_category = Category.objects.get(slug='other-category')
 
         self.api_link = reverse(
             'misago:api:thread-merge', kwargs={
@@ -29,63 +29,27 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             }
         )
 
-    def override_other_acl(self, acl=None):
-        other_category_acl = self.user.acl_cache['categories'][self.category.pk].copy()
-        other_category_acl.update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_see_all_threads': 1,
-            'can_see_own_threads': 0,
-            'can_hide_threads': 0,
-            'can_approve_content': 0,
-            'can_edit_posts': 0,
-            'can_hide_posts': 0,
-            'can_hide_own_posts': 0,
-            'can_merge_threads': 0,
-            'can_close_threads': 0,
-        })
-
-        if acl:
-            other_category_acl.update(acl)
-
-        categories_acl = self.user.acl_cache['categories']
-        categories_acl[self.category_b.pk] = other_category_acl
-
-        visible_categories = [self.category.pk]
-        if other_category_acl['can_see']:
-            visible_categories.append(self.category_b.pk)
-
-        override_acl(
-            self.user, {
-                'visible_categories': visible_categories,
-                'categories': categories_acl,
-            }
-        )
-
+    @patch_category_acl({"can_merge_threads": False})
     def test_merge_no_permission(self):
         """api validates if thread can be merged with other one"""
-        self.override_acl({'can_merge_threads': 0})
-
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't merge threads in this category."
         })
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_no_url(self):
         """api validates if thread url was given"""
-        self.override_acl({'can_merge_threads': 1})
-
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "detail": "Enter link to new thread."
         })
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_invalid_url(self):
         """api validates thread url"""
-        self.override_acl({'can_merge_threads': 1})
-
         response = self.client.post(self.api_link, {
             'other_thread': self.user.get_absolute_url(),
         })
@@ -94,10 +58,9 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "This is not a valid thread link."
         })
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_current_other_thread(self):
         """api validates if thread url given is to current thread"""
-        self.override_acl({'can_merge_threads': 1})
-
         response = self.client.post(
             self.api_link, {
                 'other_thread': self.thread.get_absolute_url(),
@@ -108,12 +71,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "You can't merge thread with itself."
         })
 
+    @patch_other_category_acl()
+    @patch_category_acl({"can_merge_threads": True})
     def test_other_thread_exists(self):
         """api validates if other thread exists"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl()
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         other_other_thread = other_thread.get_absolute_url()
         other_thread.delete()
 
@@ -128,12 +90,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             )
         })
 
+    @patch_other_category_acl({"can_see": False})
+    @patch_category_acl({"can_merge_threads": True})
     def test_other_thread_is_invisible(self):
         """api validates if other thread is visible"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_see': 0})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(
             self.api_link, {
@@ -148,12 +109,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             )
         })
 
+    @patch_other_category_acl({"can_merge_threads": False})
+    @patch_category_acl({"can_merge_threads": True})
     def test_other_thread_isnt_mergeable(self):
         """api validates if other thread can be merged"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 0})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(
             self.api_link, {
@@ -165,17 +125,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "Other thread can't be merged with."
         })
 
+    @patch_other_category_acl({"can_merge_threads": True, "can_close_threads": False})
+    @patch_category_acl({"can_merge_threads": True})
     def test_thread_category_is_closed(self):
         """api validates if thread's category is open"""
-        self.override_acl({'can_merge_threads': 1})
-
-        self.override_other_acl({
-            'can_merge_threads': 1,
-            'can_reply_threads': 0,
-            'can_close_threads': 0,
-        })
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         self.category.is_closed = True
         self.category.save()
@@ -190,17 +144,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "This category is closed. You can't merge it's threads."
         })
 
+    @patch_other_category_acl({"can_merge_threads": True, "can_close_threads": False})
+    @patch_category_acl({"can_merge_threads": True})
     def test_thread_is_closed(self):
         """api validates if thread is open"""
-        self.override_acl({'can_merge_threads': 1})
-
-        self.override_other_acl({
-            'can_merge_threads': 1,
-            'can_reply_threads': 0,
-            'can_close_threads': 0,
-        })
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         self.thread.is_closed = True
         self.thread.save()
@@ -215,20 +163,14 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "This thread is closed. You can't merge it with other threads."
         })
 
+    @patch_other_category_acl({"can_merge_threads": True, "can_close_threads": False})
+    @patch_category_acl({"can_merge_threads": True})
     def test_other_thread_category_is_closed(self):
         """api validates if other thread's category is open"""
-        self.override_acl({'can_merge_threads': 1})
-
-        self.override_other_acl({
-            'can_merge_threads': 1,
-            'can_reply_threads': 0,
-            'can_close_threads': 0,
-        })
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
-        self.category_b.is_closed = True
-        self.category_b.save()
+        self.other_category.is_closed = True
+        self.other_category.save()
 
         response = self.client.post(
             self.api_link, {
@@ -240,17 +182,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "Other thread's category is closed. You can't merge with it."
         })
 
+    @patch_other_category_acl({"can_merge_threads": True, "can_close_threads": False})
+    @patch_category_acl({"can_merge_threads": True})
     def test_other_thread_is_closed(self):
         """api validates if other thread is open"""
-        self.override_acl({'can_merge_threads': 1})
-
-        self.override_other_acl({
-            'can_merge_threads': 1,
-            'can_reply_threads': 0,
-            'can_close_threads': 0,
-        })
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         other_thread.is_closed = True
         other_thread.save()
@@ -265,16 +201,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "Other thread is closed and can't be merged with."
         })
 
+    @patch_other_category_acl({"can_merge_threads": True, "can_reply_threads": False})
+    @patch_category_acl({"can_merge_threads": True})
     def test_other_thread_isnt_replyable(self):
         """api validates if other thread can be replied, which is condition for merge"""
-        self.override_acl({'can_merge_threads': 1})
-
-        self.override_other_acl({
-            'can_merge_threads': 1,
-            'can_reply_threads': 0,
-        })
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(
             self.api_link, {
@@ -286,12 +217,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "You can't merge this thread into thread you can't reply."
         })
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads(self):
         """api merges two threads successfully"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(
             self.api_link, {
@@ -312,12 +242,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         with self.assertRaises(Thread.DoesNotExist):
             Thread.objects.get(pk=self.thread.pk)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_kept_reads(self):
         """api keeps both threads readtrackers after merge"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         poststracker.save_read(self.user, self.thread.first_post)
         poststracker.save_read(self.user, other_thread.first_post)
@@ -342,14 +271,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             [self.thread.first_post_id, other_thread.first_post_id]
         )
         self.assertEqual(postreads.filter(thread=other_thread).count(), 2)
-        self.assertEqual(postreads.filter(category=self.category_b).count(), 2)
+        self.assertEqual(postreads.filter(category=self.other_category).count(), 2)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_kept_subs(self):
         """api keeps other thread's subscription after merge"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         self.user.subscription_set.create(
             thread=self.thread,
@@ -377,14 +305,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         # subscriptions are kept
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.user.subscription_set.get(thread=other_thread)
-        self.user.subscription_set.get(category=self.category_b)
+        self.user.subscription_set.get(category=self.other_category)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_moved_subs(self):
         """api keeps other thread's subscription after merge"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         self.user.subscription_set.create(
             thread=other_thread,
@@ -395,7 +322,7 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
 
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.user.subscription_set.get(thread=other_thread)
-        self.user.subscription_set.get(category=self.category_b)
+        self.user.subscription_set.get(category=self.other_category)
 
         response = self.client.post(
             self.api_link, {
@@ -412,13 +339,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         # subscriptions are kept
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.user.subscription_set.get(thread=other_thread)
-        self.user.subscription_set.get(category=self.category_b)
+        self.user.subscription_set.get(category=self.other_category)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_handle_subs_colision(self):
         """api resolves conflicting thread subscriptions after merge"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
         self.user.subscription_set.create(
             thread=self.thread,
             category=self.thread.category,
@@ -426,7 +352,7 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             send_email=False,
         )
 
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         self.user.subscription_set.create(
             thread=other_thread,
@@ -439,7 +365,7 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.user.subscription_set.get(thread=self.thread)
         self.user.subscription_set.get(category=self.category)
         self.user.subscription_set.get(thread=other_thread)
-        self.user.subscription_set.get(category=self.category_b)
+        self.user.subscription_set.get(category=self.other_category)
 
         response = self.client.post(
             self.api_link, {
@@ -456,14 +382,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         # subscriptions are kept
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.user.subscription_set.get(thread=other_thread)
-        self.user.subscription_set.get(category=self.category_b)
+        self.user.subscription_set.get(category=self.other_category)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_kept_best_answer(self):
         """api merges two threads successfully, keeping best answer from old thread"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, best_answer)
         other_thread.save()
@@ -491,12 +416,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_thread = Thread.objects.get(pk=other_thread.pk)
         self.assertEqual(other_thread.best_answer, best_answer)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_moved_best_answer(self):
         """api merges two threads successfully, moving best answer to old thread"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
@@ -525,16 +449,15 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_thread = Thread.objects.get(pk=other_thread.pk)
         self.assertEqual(other_thread.best_answer, best_answer)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_merge_conflict_best_answer(self):
         """api errors on merge conflict, returning list of available best answers"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
@@ -560,16 +483,15 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(
             Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_best_answer_invalid_resolution(self):
         """api errors on invalid merge conflict resolution"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
@@ -590,16 +512,15 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(
             Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_unmark_all_best_answers(self):
         """api unmarks all best answers when unmark all choice is selected"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
@@ -627,16 +548,15 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         # final thread has no marked best answer
         self.assertIsNone(Thread.objects.get(pk=other_thread.pk).best_answer_id)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_first_best_answer(self):
         """api unmarks other best answer on merge"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
@@ -664,16 +584,15 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         # other thread's best answer was unchanged
         self.assertEqual(Thread.objects.get(pk=other_thread.pk).best_answer_id, best_answer.id)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_other_best_answer(self):
         """api unmarks first best answer on merge"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
@@ -702,12 +621,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(
             Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_kept_poll(self):
         """api merges two threads successfully, keeping poll from other thread"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         poll = testutils.post_poll(other_thread, self.user)
 
         response = self.client.post(
@@ -733,12 +651,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.filter(pk=poll.pk, thread=other_thread).count(), 1)
         self.assertEqual(PollVote.objects.filter(poll=poll, thread=other_thread).count(), 4)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_moved_poll(self):
         """api merges two threads successfully, moving poll from old thread"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         poll = testutils.post_poll(self.thread, self.user)
 
         response = self.client.post(
@@ -764,12 +681,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.filter(pk=poll.pk, thread=other_thread).count(), 1)
         self.assertEqual(PollVote.objects.filter(poll=poll, thread=other_thread).count(), 4)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_polls(self):
         """api errors on merge conflict, returning list of available polls"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
 
@@ -799,12 +715,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 2)
         self.assertEqual(PollVote.objects.count(), 8)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_poll_invalid_resolution(self):
         """api errors on invalid merge conflict resolution"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         testutils.post_poll(self.thread, self.user)
         testutils.post_poll(other_thread, self.user)
@@ -822,12 +737,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 2)
         self.assertEqual(PollVote.objects.count(), 8)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_delete_all_polls(self):
         """api deletes all polls when delete all choice is selected"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         testutils.post_poll(self.thread, self.user)
         testutils.post_poll(other_thread, self.user)
 
@@ -855,12 +769,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 0)
         self.assertEqual(PollVote.objects.count(), 0)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_first_poll(self):
         """api deletes other poll on merge"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
 
@@ -895,12 +808,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         with self.assertRaises(Poll.DoesNotExist):
             Poll.objects.get(pk=other_poll.pk)
 
+    @patch_other_category_acl({"can_merge_threads": True})
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_other_poll(self):
         """api deletes first poll on merge"""
-        self.override_acl({'can_merge_threads': 1})
-        self.override_other_acl({'can_merge_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
 

+ 226 - 368
misago/threads/tests/test_thread_patch_api.py

@@ -3,10 +3,10 @@ from datetime import timedelta
 
 from django.utils import timezone
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.readtracker import poststracker
 from misago.threads import testutils
+from misago.threads.test import patch_category_acl, patch_other_category_acl
 from misago.threads.models import Thread
 
 from .test_threads_api import ThreadsApiTestCase
@@ -48,10 +48,9 @@ class ThreadAddAclApiTests(ThreadPatchApiTestCase):
 
 
 class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_edit_threads': 2})
     def test_change_thread_title(self):
         """api makes it possible to change thread title"""
-        self.override_acl({'can_edit_threads': 2})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -69,10 +68,9 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['title'], "Lorem ipsum change!")
 
+    @patch_category_acl({'can_edit_threads': 0})
     def test_change_thread_title_no_permission(self):
         """api validates permission to change title"""
-        self.override_acl({'can_edit_threads': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -87,13 +85,9 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't edit threads in this category.")
 
+    @patch_category_acl({'can_edit_threads': 2, 'can_close_threads': 0})
     def test_change_thread_title_closed_category_no_permission(self):
         """api test permission to edit thread title in closed category"""
-        self.override_acl({
-            'can_edit_threads': 2,
-            'can_close_threads': 0
-        })
-
         self.category.is_closed = True
         self.category.save()
 
@@ -113,13 +107,9 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "This category is closed. You can't edit threads in it."
         )
 
+    @patch_category_acl({'can_edit_threads': 2, 'can_close_threads': 0})
     def test_change_thread_title_closed_thread_no_permission(self):
         """api test permission to edit closed thread title"""
-        self.override_acl({
-            'can_edit_threads': 2,
-            'can_close_threads': 0
-        })
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -139,10 +129,9 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "This thread is closed. You can't edit it."
         )
 
+    @patch_category_acl({'can_edit_threads': 1, 'thread_edit_time': 1})
     def test_change_thread_title_after_edit_time(self):
         """api cleans, validates and rejects too short title"""
-        self.override_acl({'thread_edit_time': 1, 'can_edit_threads': 1})
-
         self.thread.started_on = timezone.now() - timedelta(minutes=10)
         self.thread.starter = self.user
         self.thread.save()
@@ -163,10 +152,9 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "You can't edit threads that are older than 1 minute."
         )
 
+    @patch_category_acl({'can_edit_threads': 2})
     def test_change_thread_title_invalid(self):
         """api cleans, validates and rejects too short title"""
-        self.override_acl({'can_edit_threads': 2})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -186,10 +174,9 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
 
 
 class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_pin_threads': 2})
     def test_pin_thread(self):
         """api makes it possible to pin globally thread"""
-        self.override_acl({'can_pin_threads': 2})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -207,13 +194,9 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 2)
 
+    @patch_category_acl({'can_pin_threads': 2, 'can_close_threads': 0})
     def test_pin_thread_closed_category_no_permission(self):
         """api checks if category is closed"""
-        self.override_acl({
-            'can_pin_threads': 2,
-            'can_close_threads': 0,
-        })
-
         self.category.is_closed = True
         self.category.save()
 
@@ -233,13 +216,9 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "This category is closed. You can't change threads weights in it."
         )
 
+    @patch_category_acl({'can_pin_threads': 2, 'can_close_threads': 0})
     def test_pin_thread_closed_no_permission(self):
         """api checks if thread is closed"""
-        self.override_acl({
-            'can_pin_threads': 2,
-            'can_close_threads': 0,
-        })
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -259,6 +238,7 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "This thread is closed. You can't change its weight."
         )
 
+    @patch_category_acl({'can_pin_threads': 2})
     def test_unpin_thread(self):
         """api makes it possible to unpin thread"""
         self.thread.weight = 2
@@ -267,8 +247,6 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 2)
 
-        self.override_acl({'can_pin_threads': 2})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -286,10 +264,9 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 0)
 
+    @patch_category_acl({'can_pin_threads': 1})
     def test_pin_thread_no_permission(self):
         """api pin thread globally with no permission fails"""
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -309,6 +286,7 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 0)
 
+    @patch_category_acl({'can_pin_threads': 1})
     def test_unpin_thread_no_permission(self):
         """api unpin thread with no permission fails"""
         self.thread.weight = 2
@@ -317,8 +295,6 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 2)
 
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -340,10 +316,9 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
 
 
 class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_pin_threads': 1})
     def test_pin_thread(self):
         """api makes it possible to pin locally thread"""
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -361,6 +336,7 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 1)
 
+    @patch_category_acl({'can_pin_threads': 1})
     def test_unpin_thread(self):
         """api makes it possible to unpin thread"""
         self.thread.weight = 1
@@ -369,8 +345,6 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 1)
 
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -388,10 +362,9 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 0)
 
+    @patch_category_acl({'can_pin_threads': 0})
     def test_pin_thread_no_permission(self):
         """api pin thread locally with no permission fails"""
-        self.override_acl({'can_pin_threads': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -411,6 +384,7 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 0)
 
+    @patch_category_acl({'can_pin_threads': 0})
     def test_unpin_thread_no_permission(self):
         """api unpin thread with no permission fails"""
         self.thread.weight = 1
@@ -419,8 +393,6 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 1)
 
-        self.override_acl({'can_pin_threads': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -446,57 +418,30 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         super().setUp()
 
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other category',
+            slug='other-category',
         ).insert_at(
             self.category,
             position='last-child',
             save=True,
         )
-        self.category_b = Category.objects.get(slug='category-b')
-
-    def override_other_acl(self, acl):
-        other_category_acl = self.user.acl_cache['categories'][self.category.pk].copy()
-        other_category_acl.update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_see_all_threads': 1,
-            'can_see_own_threads': 0,
-            'can_hide_threads': 0,
-            'can_approve_content': 0,
-        })
-        other_category_acl.update(acl)
-
-        categories_acl = self.user.acl_cache['categories']
-        categories_acl[self.category_b.pk] = other_category_acl
-
-        visible_categories = [self.category.pk]
-        if other_category_acl['can_see']:
-            visible_categories.append(self.category_b.pk)
-
-        override_acl(
-            self.user, {
-                'visible_categories': visible_categories,
-                'categories': categories_acl,
-            }
-        )
+        self.dst_category = Category.objects.get(slug='other-category')
 
+    @patch_other_category_acl({'can_start_threads': 2})
+    @patch_category_acl({'can_move_threads': True})
     def test_move_thread_no_top(self):
         """api moves thread to other category, sets no top category"""
-        self.override_acl({'can_move_threads': True})
-        self.override_other_acl({'can_start_threads': 2})
-
         response = self.patch(
             self.api_link, [
                 {
                     'op': 'replace',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 {
                     'op': 'add',
                     'path': 'top-category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 {
                     'op': 'replace',
@@ -508,24 +453,21 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertEqual(reponse_json['category'], self.category_b.pk)
-
-        self.override_other_acl({})
+        self.assertEqual(reponse_json['category'], self.dst_category.pk)
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['category']['id'], self.category_b.pk)
+        self.assertEqual(thread_json['category']['id'], self.dst_category.pk)
 
+    @patch_other_category_acl({'can_start_threads': 2})
+    @patch_category_acl({'can_move_threads': True})
     def test_move_thread_with_top(self):
         """api moves thread to other category, sets top"""
-        self.override_acl({'can_move_threads': True})
-        self.override_other_acl({'can_start_threads': 2})
-
         response = self.patch(
             self.api_link, [
                 {
                     'op': 'replace',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 {
                     'op': 'add',
@@ -542,18 +484,15 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertEqual(reponse_json['category'], self.category_b.pk)
-
-        self.override_other_acl({})
+        self.assertEqual(reponse_json['category'], self.dst_category.pk)
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['category']['id'], self.category_b.pk)
+        self.assertEqual(thread_json['category']['id'], self.dst_category.pk)
 
+    @patch_other_category_acl({'can_start_threads': 2})
+    @patch_category_acl({'can_move_threads': True})
     def test_move_thread_reads(self):
         """api moves thread reads together with thread"""
-        self.override_acl({'can_move_threads': True})
-        self.override_other_acl({'can_start_threads': 2})
-
         poststracker.save_read(self.user, self.thread.first_post)
 
         self.assertEqual(self.user.postread_set.count(), 1)
@@ -564,12 +503,12 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 {
                     'op': 'replace',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 {
                     'op': 'add',
                     'path': 'top-category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 {
                     'op': 'replace',
@@ -584,13 +523,12 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         postreads = self.user.postread_set.filter(post__is_event=False).order_by('id')
 
         self.assertEqual(postreads.count(), 1)
-        postreads.get(category=self.category_b)
+        postreads.get(category=self.dst_category)
 
+    @patch_other_category_acl({'can_start_threads': 2})
+    @patch_category_acl({'can_move_threads': True})
     def test_move_thread_subscriptions(self):
         """api moves thread subscriptions together with thread"""
-        self.override_acl({'can_move_threads': True})
-        self.override_other_acl({'can_start_threads': 2})
-
         self.user.subscription_set.create(
             thread=self.thread,
             category=self.thread.category,
@@ -606,12 +544,12 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 {
                     'op': 'replace',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 {
                     'op': 'add',
                     'path': 'top-category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 {
                     'op': 'replace',
@@ -624,19 +562,17 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
 
         # thread read was moved to new category
         self.assertEqual(self.user.subscription_set.count(), 1)
-        self.user.subscription_set.get(category=self.category_b)
+        self.user.subscription_set.get(category=self.dst_category)
 
+    @patch_category_acl({'can_move_threads': False})
     def test_move_thread_no_permission(self):
         """api move thread to other category with no permission fails"""
-        self.override_acl({'can_move_threads': False})
-        self.override_other_acl({})
-
         response = self.patch(
             self.api_link, [
                 {
                     'op': 'replace',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
             ]
         )
@@ -647,19 +583,13 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "You can't move threads in this category."
         )
 
-        self.override_other_acl({})
-
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category.pk)
 
+    @patch_other_category_acl({'can_close_threads': False})
+    @patch_category_acl({'can_move_threads': True})
     def test_move_thread_closed_category_no_permission(self):
         """api move thread from closed category with no permission fails"""
-        self.override_acl({
-            'can_move_threads': True,
-            'can_close_threads': False,
-        })
-        self.override_other_acl({})
-
         self.category.is_closed = True
         self.category.save()
 
@@ -668,7 +598,7 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 {
                     'op': 'replace',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
             ]
         )
@@ -679,14 +609,10 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "This category is closed. You can't move it's threads."
         )
 
+    @patch_other_category_acl({'can_close_threads': False})
+    @patch_category_acl({'can_move_threads': True})
     def test_move_closed_thread_no_permission(self):
         """api move closed thread with no permission fails"""
-        self.override_acl({
-            'can_move_threads': True,
-            'can_close_threads': False,
-        })
-        self.override_other_acl({})
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -695,7 +621,7 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 {
                     'op': 'replace',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
             ]
         )
@@ -706,17 +632,16 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "This thread is closed. You can't move it."
         )
 
+    @patch_other_category_acl({'can_see': False})
+    @patch_category_acl({'can_move_threads': True})
     def test_move_thread_no_category_access(self):
         """api move thread to category with no access fails"""
-        self.override_acl({'can_move_threads': True})
-        self.override_other_acl({'can_see': False})
-
         response = self.patch(
             self.api_link, [
                 {
                     'op': 'replace',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
             ]
         )
@@ -725,22 +650,19 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], 'NOT FOUND')
 
-        self.override_other_acl({})
-
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category.pk)
 
+    @patch_other_category_acl({'can_browse': False})
+    @patch_category_acl({'can_move_threads': True})
     def test_move_thread_no_category_browse(self):
         """api move thread to category with no browsing access fails"""
-        self.override_acl({'can_move_threads': True})
-        self.override_other_acl({'can_browse': False})
-
         response = self.patch(
             self.api_link, [
                 {
                     'op': 'replace',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
             ]
         )
@@ -749,25 +671,22 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(
             response_json['detail'][0],
-            'You don\'t have permission to browse "Category B" contents.'
+            'You don\'t have permission to browse "Other category" contents.'
         )
 
-        self.override_other_acl({})
-
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category.pk)
 
+    @patch_other_category_acl({'can_start_threads': False})
+    @patch_category_acl({'can_move_threads': True})
     def test_move_thread_no_category_start_threads(self):
         """api move thread to category with no posting access fails"""
-        self.override_acl({'can_move_threads': True})
-        self.override_other_acl({'can_start_threads': False})
-
         response = self.patch(
             self.api_link, [
                 {
                     'op': 'replace',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
             ]
         )
@@ -779,16 +698,13 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
             "You don't have permission to start new threads in this category."
         )
 
-        self.override_other_acl({})
-
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category.pk)
 
+    @patch_other_category_acl({'can_start_threads': 2})
+    @patch_category_acl({'can_move_threads': True})
     def test_move_thread_same_category(self):
         """api move thread to category it's already in fails"""
-        self.override_acl({'can_move_threads': True})
-        self.override_other_acl({'can_start_threads': 2})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -805,8 +721,6 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "You can't move thread to the category it's already in."
         )
 
-        self.override_other_acl({})
-
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category.pk)
 
@@ -828,10 +742,9 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
 
 
 class ThreadCloseApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_close_threads': True})
     def test_close_thread(self):
         """api makes it possible to close thread"""
-        self.override_acl({'can_close_threads': True})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -849,6 +762,7 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_closed'])
 
+    @patch_category_acl({'can_close_threads': True})
     def test_open_thread(self):
         """api makes it possible to open thread"""
         self.thread.is_closed = True
@@ -857,8 +771,6 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_closed'])
 
-        self.override_acl({'can_close_threads': True})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -876,10 +788,9 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertFalse(thread_json['is_closed'])
 
+    @patch_category_acl({'can_close_threads': False})
     def test_close_thread_no_permission(self):
         """api close thread with no permission fails"""
-        self.override_acl({'can_close_threads': False})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -899,6 +810,7 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertFalse(thread_json['is_closed'])
 
+    @patch_category_acl({'can_close_threads': False})
     def test_open_thread_no_permission(self):
         """api open thread with no permission fails"""
         self.thread.is_closed = True
@@ -907,8 +819,6 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_closed'])
 
-        self.override_acl({'can_close_threads': False})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -930,6 +840,7 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
 
 
 class ThreadApproveApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_approve_content': True})
     def test_approve_thread(self):
         """api makes it possible to approve thread"""
         self.thread.first_post.is_unapproved = True
@@ -941,8 +852,6 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         self.assertTrue(self.thread.is_unapproved)
         self.assertTrue(self.thread.has_unapproved_posts)
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -966,6 +875,7 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         self.assertFalse(thread.is_unapproved)
         self.assertFalse(thread.has_unapproved_posts)
 
+    @patch_category_acl({'can_approve_content': True, 'can_close_threads': False})
     def test_approve_thread_category_closed_no_permission(self):
         """api checks permission for approving threads in closed categories"""
         self.thread.first_post.is_unapproved = True
@@ -980,11 +890,6 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         self.category.is_closed = True
         self.category.save()
 
-        self.override_acl({
-            'can_approve_content': 1,
-            'can_close_threads': 0,
-        })
-
         response = self.patch(
             self.api_link, [
                 {
@@ -999,6 +904,7 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "This category is closed. You can't approve threads in it.")
 
+    @patch_category_acl({'can_approve_content': True, 'can_close_threads': False})
     def test_approve_thread_closed_no_permission(self):
         """api checks permission for approving posts in closed categories"""
         self.thread.first_post.is_unapproved = True
@@ -1013,11 +919,6 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        self.override_acl({
-            'can_approve_content': 1,
-            'can_close_threads': 0,
-        })
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1032,10 +933,9 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "This thread is closed. You can't approve it.")
 
+    @patch_category_acl({'can_approve_content': True})
     def test_unapprove_thread(self):
         """api returns permission error on approval removal"""
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1052,10 +952,9 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
 
 
 class ThreadHideApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_hide_threads': True})
     def test_hide_thread(self):
         """api makes it possible to hide thread"""
-        self.override_acl({'can_hide_threads': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1070,15 +969,12 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
         reponse_json = response.json()
         self.assertTrue(reponse_json['is_hidden'])
 
-        self.override_acl({'can_hide_threads': 1})
-
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_hidden'])
 
+    @patch_category_acl({'can_hide_threads': False})
     def test_hide_thread_no_permission(self):
         """api hide thread with no permission fails"""
-        self.override_acl({'can_hide_threads': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1098,13 +994,9 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertFalse(thread_json['is_hidden'])
 
+    @patch_category_acl({'can_hide_threads': False, 'can_hide_own_threads': True})
     def test_hide_non_owned_thread(self):
         """api forbids non-moderator from hiding other users threads"""
-        self.override_acl({
-            'can_hide_own_threads': 1,
-            'can_hide_threads': 0
-        })
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1121,14 +1013,13 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "You can't hide other users theads in this category."
         )
 
+    @patch_category_acl({
+        'can_hide_threads': False,
+        'can_hide_own_threads': True,
+        'thread_edit_time': 1,
+    })
     def test_hide_owned_thread_no_time(self):
         """api forbids non-moderator from hiding other users threads"""
-        self.override_acl({
-            'can_hide_own_threads': 1,
-            'can_hide_threads': 0,
-            'thread_edit_time': 1,
-        })
-
         self.thread.started_on = timezone.now() - timedelta(minutes=5)
         self.thread.starter = self.user
         self.thread.save()
@@ -1149,13 +1040,9 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "You can't hide threads that are older than 1 minute."
         )
 
+    @patch_category_acl({'can_hide_threads': True, 'can_close_threads': False})
     def test_hide_closed_category_no_permission(self):
         """api test permission to hide thread in closed category"""
-        self.override_acl({
-            'can_hide_threads': 1,
-            'can_close_threads': 0
-        })
-
         self.category.is_closed = True
         self.category.save()
 
@@ -1175,13 +1062,9 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "This category is closed. You can't hide threads in it."
         )
 
+    @patch_category_acl({'can_hide_threads': True, 'can_close_threads': False})
     def test_hide_closed_thread_no_permission(self):
         """api test permission to hide closed thread"""
-        self.override_acl({
-            'can_hide_threads': 1,
-            'can_close_threads': 0
-        })
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -1209,10 +1092,9 @@ class ThreadUnhideApiTests(ThreadPatchApiTestCase):
         self.thread.is_hidden = True
         self.thread.save()
 
+    @patch_category_acl({'can_hide_threads': True})
     def test_unhide_thread(self):
         """api makes it possible to unhide thread"""
-        self.override_acl({'can_hide_threads': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1227,15 +1109,12 @@ class ThreadUnhideApiTests(ThreadPatchApiTestCase):
         reponse_json = response.json()
         self.assertFalse(reponse_json['is_hidden'])
 
-        self.override_acl({'can_hide_threads': 1})
-
         thread_json = self.get_thread_json()
         self.assertFalse(thread_json['is_hidden'])
 
+    @patch_category_acl({'can_hide_threads': False})
     def test_unhide_thread_no_permission(self):
         """api unhide thread with no permission fails as thread is invisible"""
-        self.override_acl({'can_hide_threads': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1247,13 +1126,9 @@ class ThreadUnhideApiTests(ThreadPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 404)
 
+    @patch_category_acl({'can_hide_threads': True, 'can_close_threads': False})
     def test_unhide_closed_category_no_permission(self):
         """api test permission to unhide thread in closed category"""
-        self.override_acl({
-            'can_hide_threads': 1,
-            'can_close_threads': 0
-        })
-
         self.category.is_closed = True
         self.category.save()
 
@@ -1273,13 +1148,9 @@ class ThreadUnhideApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "This category is closed. You can't reveal threads in it."
         )
 
+    @patch_category_acl({'can_hide_threads': True, 'can_close_threads': False})
     def test_unhide_closed_thread_no_permission(self):
         """api test permission to unhide closed thread"""
-        self.override_acl({
-            'can_hide_threads': 1,
-            'can_close_threads': 0
-        })
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -1405,10 +1276,9 @@ class ThreadSubscribeApiTests(ThreadPatchApiTestCase):
 
 
 class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer(self):
         """api makes it possible to mark best answer"""
-        self.override_acl({'can_mark_best_answers': 2})
-
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
@@ -1442,12 +1312,11 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.assertEqual(thread_json['best_answer_marked_by_name'], self.user.username)
         self.assertEqual(thread_json['best_answer_marked_by_slug'], self.user.slug)
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_anonymous(self):
         """api validates that user is authenticated before marking best answer"""
         self.logout_user()
 
-        self.override_acl({'can_mark_best_answers': 2})
-
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
@@ -1467,10 +1336,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
 
+    @patch_category_acl({'can_mark_best_answers': 0})
     def test_mark_best_answer_no_permission(self):
         """api validates permission to mark best answers"""
-        self.override_acl({'can_mark_best_answers': 0})
-
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
@@ -1493,10 +1361,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
 
+    @patch_category_acl({'can_mark_best_answers': 1})
     def test_mark_best_answer_not_thread_starter(self):
         """api validates permission to mark best answers in owned thread"""
-        self.override_acl({'can_mark_best_answers': 1})
-
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
@@ -1524,8 +1391,6 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.starter = self.user
         self.thread.save()
 
-        self.override_acl({'can_mark_best_answers': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1537,10 +1402,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-    def test_mark_best_answer_category_closed(self):
+    @patch_category_acl({'can_mark_best_answers': 2, 'can_close_threads': False})
+    def test_mark_best_answer_category_closed_no_permission(self):
         """api validates permission to mark best answers in closed category"""
-        self.override_acl({'can_mark_best_answers': 2, 'can_close_threads': 0})
-
         best_answer = testutils.reply_thread(self.thread)
 
         self.category.is_closed = True
@@ -1567,8 +1431,13 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
 
-        # passing scenario is possible
-        self.override_acl({'can_mark_best_answers': 2, 'can_close_threads': 1})
+    @patch_category_acl({'can_mark_best_answers': 2, 'can_close_threads': True})
+    def test_mark_best_answer_category_closed(self):
+        """api validates permission to mark best answers in closed category"""
+        best_answer = testutils.reply_thread(self.thread)
+
+        self.category.is_closed = True
+        self.category.save()
 
         response = self.patch(
             self.api_link, [
@@ -1581,10 +1450,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-    def test_mark_best_answer_thread_closed(self):
+    @patch_category_acl({'can_mark_best_answers': 2, 'can_close_threads': False})
+    def test_mark_best_answer_thread_closed_no_permission(self):
         """api validates permission to mark best answers in closed thread"""
-        self.override_acl({'can_mark_best_answers': 2, 'can_close_threads': 0})
-
         best_answer = testutils.reply_thread(self.thread)
 
         self.thread.is_closed = True
@@ -1611,8 +1479,13 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
 
-        # passing scenario is possible
-        self.override_acl({'can_mark_best_answers': 2, 'can_close_threads': 1})
+    @patch_category_acl({'can_mark_best_answers': 2, 'can_close_threads': True})
+    def test_mark_best_answer_thread_closed(self):
+        """api validates permission to mark best answers in closed thread"""
+        best_answer = testutils.reply_thread(self.thread)
+
+        self.thread.is_closed = True
+        self.thread.save()
 
         response = self.patch(
             self.api_link, [
@@ -1624,11 +1497,10 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
             ]
         )
         self.assertEqual(response.status_code, 200)
-
+    
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_invalid_post_id(self):
         """api validates that post id is int"""
-        self.override_acl({'can_mark_best_answers': 2})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1647,10 +1519,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_post_not_found(self):
         """api validates that post exists"""
-        self.override_acl({'can_mark_best_answers': 2})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1668,11 +1539,10 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
 
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
-        
+
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_post_invisible(self):
         """api validates post visibility to action author"""
-        self.override_acl({'can_mark_best_answers': 2})
-
         unapproved_post = testutils.reply_thread(self.thread, is_unapproved=True)
 
         response = self.patch(
@@ -1693,10 +1563,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_post_other_thread(self):
         """api validates post belongs to same thread"""
-        self.override_acl({'can_mark_best_answers': 2})
-
         other_thread = testutils.post_thread(self.category)
 
         response = self.patch(
@@ -1717,10 +1586,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_event_id(self):
         """api validates that post is not an event"""
-        self.override_acl({'can_mark_best_answers': 2})
-
         best_answer = testutils.reply_thread(self.thread)
         best_answer.is_event = True
         best_answer.save()
@@ -1743,10 +1611,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_first_post(self):
         """api validates that post is not a first post in thread"""
-        self.override_acl({'can_mark_best_answers': 2})
-        
         response = self.patch(
             self.api_link, [
                 {
@@ -1765,10 +1632,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_hidden_post(self):
         """api validates that post is not hidden"""
-        self.override_acl({'can_mark_best_answers': 2})
-        
         best_answer = testutils.reply_thread(self.thread, is_hidden=True)
 
         response = self.patch(
@@ -1789,10 +1655,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_unapproved_post(self):
         """api validates that post is not unapproved"""
-        self.override_acl({'can_mark_best_answers': 2})
-        
         best_answer = testutils.reply_thread(self.thread, poster=self.user, is_unapproved=True)
 
         response = self.patch(
@@ -1813,10 +1678,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
 
-    def test_mark_best_answer_protected_post(self):
+    @patch_category_acl({'can_mark_best_answers': 2, 'can_protect_posts': False})
+    def test_mark_best_answer_protected_post_no_permission(self):
         """api respects post protection"""
-        self.override_acl({'can_mark_best_answers': 2, 'can_protect_posts': 0})
-        
         best_answer = testutils.reply_thread(self.thread, is_protected=True)
 
         response = self.patch(
@@ -1840,8 +1704,10 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
 
-        # passing scenario is possible
-        self.override_acl({'can_mark_best_answers': 2, 'can_protect_posts': 1})
+    @patch_category_acl({'can_mark_best_answers': 2, 'can_protect_posts': True})
+    def test_mark_best_answer_protected_post(self):
+        """api respects post protection"""
+        best_answer = testutils.reply_thread(self.thread, is_protected=True)
 
         response = self.patch(
             self.api_link, [
@@ -1863,10 +1729,9 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.set_best_answer(self.user, self.best_answer)
         self.thread.save()
 
+    @patch_category_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 2})
     def test_change_best_answer(self):
         """api makes it possible to change best answer"""
-        self.override_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 2})
-
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
@@ -1900,10 +1765,9 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         self.assertEqual(thread_json['best_answer_marked_by_name'], self.user.username)
         self.assertEqual(thread_json['best_answer_marked_by_slug'], self.user.slug)
 
+    @patch_category_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 2})
     def test_change_best_answer_same_post(self):
         """api validates if new best answer is same as current one"""
-        self.override_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 2})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1922,10 +1786,9 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
+    @patch_category_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
     def test_change_best_answer_no_permission_to_mark(self):
         """api validates permission to mark best answers before allowing answer change"""
-        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
-
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
@@ -1948,10 +1811,9 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
+    @patch_category_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 0})
     def test_change_best_answer_no_permission(self):
         """api validates permission to change best answers"""
-        self.override_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 0})
-
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
@@ -1975,10 +1837,9 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
+    @patch_category_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 1})
     def test_change_best_answer_not_starter(self):
         """api validates permission to change best answers"""
-        self.override_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 1})
-
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
@@ -2003,8 +1864,6 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
         # passing scenario is possible
-        self.override_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 1})
-        
         self.thread.starter = self.user
         self.thread.save()
 
@@ -2019,14 +1878,13 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-    def test_change_best_answer_timelimit(self):
+    @patch_category_acl({
+        'can_mark_best_answers': 2,
+        'can_change_marked_answers': 1,
+        'best_answer_change_time': 5,
+    })
+    def test_change_best_answer_timelimit_out_of_time(self):
         """api validates permission for starter to change best answers within timelimit"""
-        self.override_acl({
-            'can_mark_best_answers': 2,
-            'can_change_marked_answers': 1,
-            'best_answer_change_time': 5,
-        })
-
         best_answer = testutils.reply_thread(self.thread)
 
         self.thread.best_answer_marked_on = timezone.now() - timedelta(minutes=6)
@@ -2054,13 +1912,19 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
-        # passing scenario is possible
-        self.override_acl({
-            'can_mark_best_answers': 2,
-            'can_change_marked_answers': 1,
-            'best_answer_change_time': 10,
-        })
-        
+    @patch_category_acl({
+        'can_mark_best_answers': 2,
+        'can_change_marked_answers': 1,
+        'best_answer_change_time': 5,
+    })
+    def test_change_best_answer_timelimit(self):
+        """api validates permission for starter to change best answers within timelimit"""
+        best_answer = testutils.reply_thread(self.thread)
+
+        self.thread.best_answer_marked_on = timezone.now() - timedelta(minutes=1)
+        self.thread.starter = self.user
+        self.thread.save()
+
         response = self.patch(
             self.api_link, [
                 {
@@ -2072,14 +1936,13 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-    def test_change_best_answer_protected(self):
+    @patch_category_acl({
+        'can_mark_best_answers': 2,
+        'can_change_marked_answers': 2,
+        'can_protect_posts': False,
+    })
+    def test_change_best_answer_protected_no_permission(self):
         """api validates permission to change protected best answers"""
-        self.override_acl({
-            'can_mark_best_answers': 2,
-            'can_change_marked_answers': 2,
-            'can_protect_posts': 0,
-        })
-
         best_answer = testutils.reply_thread(self.thread)
 
         self.thread.best_answer_is_protected = True
@@ -2106,13 +1969,18 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
-        # passing scenario is possible
-        self.override_acl({
-            'can_mark_best_answers': 2,
-            'can_change_marked_answers': 2,
-            'can_protect_posts': 1,
-        })
-        
+    @patch_category_acl({
+        'can_mark_best_answers': 2,
+        'can_change_marked_answers': 2,
+        'can_protect_posts': True,
+    })
+    def test_change_best_answer_protected(self):
+        """api validates permission to change protected best answers"""
+        best_answer = testutils.reply_thread(self.thread)
+
+        self.thread.best_answer_is_protected = True
+        self.thread.save()
+
         response = self.patch(
             self.api_link, [
                 {
@@ -2124,13 +1992,9 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 2})
     def test_change_best_answer_post_validation(self):
         """api validates new post'"""
-        self.override_acl({
-            'can_mark_best_answers': 2,
-            'can_change_marked_answers': 2,
-        })
-
         best_answer = testutils.reply_thread(self.thread, is_hidden=True)
 
         response = self.patch(
@@ -2156,10 +2020,9 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.set_best_answer(self.user, self.best_answer)
         self.thread.save()
 
+    @patch_category_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
     def test_unmark_best_answer(self):
         """api makes it possible to unmark best answer"""
-        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -2190,10 +2053,9 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.assertIsNone(thread_json['best_answer_marked_by_name'])
         self.assertIsNone(thread_json['best_answer_marked_by_slug'])
 
+    @patch_category_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
     def test_unmark_best_answer_invalid_post_id(self):
         """api validates that post id is int"""
-        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -2212,10 +2074,9 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
+    @patch_category_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
     def test_unmark_best_answer_post_not_found(self):
         """api validates that post to unmark exists"""
-        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -2233,11 +2094,10 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
 
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
-        
+
+    @patch_category_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
     def test_unmark_best_answer_wrong_post(self):
         """api validates if post given to unmark is best answer"""
-        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
-
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
@@ -2260,10 +2120,9 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
+    @patch_category_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 0})
     def test_unmark_best_answer_no_permission(self):
         """api validates if user has permission to unmark best answers"""
-        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -2285,10 +2144,9 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
+    @patch_category_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 1})
     def test_unmark_best_answer_not_starter(self):
         """api validates if starter has permission to unmark best answers"""
-        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -2311,8 +2169,6 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
         # passing scenario is possible
-        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 1})
-
         self.thread.starter = self.user
         self.thread.save()
 
@@ -2327,14 +2183,13 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({
+        'can_mark_best_answers': 0,
+        'can_change_marked_answers': 1,
+        'best_answer_change_time': 5,
+    })
     def test_unmark_best_answer_timelimit(self):
         """api validates if starter has permission to unmark best answer within time limit"""
-        self.override_acl({
-            'can_mark_best_answers': 0,
-            'can_change_marked_answers': 1,
-            'best_answer_change_time': 5,
-        })
-
         self.thread.best_answer_marked_on = timezone.now() - timedelta(minutes=6)
         self.thread.starter = self.user
         self.thread.save()
@@ -2361,11 +2216,8 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
         # passing scenario is possible
-        self.override_acl({
-            'can_mark_best_answers': 0,
-            'can_change_marked_answers': 1,
-            'best_answer_change_time': 10,
-        })
+        self.thread.best_answer_marked_on = timezone.now() - timedelta(minutes=2)
+        self.thread.save()
         
         response = self.patch(
             self.api_link, [
@@ -2378,14 +2230,13 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-    def test_unmark_best_answer_closed_category(self):
+    @patch_category_acl({
+        'can_mark_best_answers': 0,
+        'can_change_marked_answers': 2,
+        'can_close_threads': False,
+    })
+    def test_unmark_best_answer_closed_category_no_permission(self):
         """api validates if user has permission to unmark best answer in closed category"""
-        self.override_acl({
-            'can_mark_best_answers': 0,
-            'can_change_marked_answers': 2,
-            'can_close_threads': 0,
-        })
-
         self.category.is_closed = True
         self.category.save()
 
@@ -2410,13 +2261,16 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
-        # passing scenario is possible
-        self.override_acl({
-            'can_mark_best_answers': 0,
-            'can_change_marked_answers': 2,
-            'can_close_threads': 1,
-        })
-        
+    @patch_category_acl({
+        'can_mark_best_answers': 0,
+        'can_change_marked_answers': 2,
+        'can_close_threads': True,
+    })
+    def test_unmark_best_answer_closed_category(self):
+        """api validates if user has permission to unmark best answer in closed category"""
+        self.category.is_closed = True
+        self.category.save()
+
         response = self.patch(
             self.api_link, [
                 {
@@ -2428,14 +2282,13 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-    def test_unmark_best_answer_closed_thread(self):
+    @patch_category_acl({
+        'can_mark_best_answers': 0,
+        'can_change_marked_answers': 2,
+        'can_close_threads': False,
+    })
+    def test_unmark_best_answer_closed_thread_no_permission(self):
         """api validates if user has permission to unmark best answer in closed thread"""
-        self.override_acl({
-            'can_mark_best_answers': 0,
-            'can_change_marked_answers': 2,
-            'can_close_threads': 0,
-        })
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -2460,13 +2313,16 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
-        # passing scenario is possible
-        self.override_acl({
-            'can_mark_best_answers': 0,
-            'can_change_marked_answers': 2,
-            'can_close_threads': 1,
-        })
-        
+    @patch_category_acl({
+        'can_mark_best_answers': 0,
+        'can_change_marked_answers': 2,
+        'can_close_threads': True,
+    })
+    def test_unmark_best_answer_closed_thread(self):
+        """api validates if user has permission to unmark best answer in closed thread"""
+        self.thread.is_closed = True
+        self.thread.save()
+
         response = self.patch(
             self.api_link, [
                 {
@@ -2478,14 +2334,13 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-    def test_unmark_best_answer_protected(self):
+    @patch_category_acl({
+        'can_mark_best_answers': 0,
+        'can_change_marked_answers': 2,
+        'can_protect_posts': 0,
+    })
+    def test_unmark_best_answer_protected_no_permission(self):
         """api validates permission to unmark protected best answers"""
-        self.override_acl({
-            'can_mark_best_answers': 0,
-            'can_change_marked_answers': 2,
-            'can_protect_posts': 0,
-        })
-
         self.thread.best_answer_is_protected = True
         self.thread.save()
 
@@ -2510,13 +2365,16 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
-        # passing scenario is possible
-        self.override_acl({
-            'can_mark_best_answers': 0,
-            'can_change_marked_answers': 2,
-            'can_protect_posts': 1,
-        })
-        
+    @patch_category_acl({
+        'can_mark_best_answers': 0,
+        'can_change_marked_answers': 2,
+        'can_protect_posts': 1,
+    })
+    def test_unmark_best_answer_protected(self):
+        """api validates permission to unmark protected best answers"""
+        self.thread.best_answer_is_protected = True
+        self.thread.save()
+
         response = self.patch(
             self.api_link, [
                 {

+ 0 - 25
misago/threads/tests/test_thread_poll_api.py

@@ -2,7 +2,6 @@ import json
 
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.users.testutils import AuthenticatedUserTestCase
@@ -14,7 +13,6 @@ class ThreadPollApiTestCase(AuthenticatedUserTestCase):
 
         self.category = Category.objects.get(slug='first-category')
         self.thread = testutils.post_thread(self.category, poster=self.user)
-        self.override_acl()
 
         self.api_link = reverse(
             'misago:api:thread-poll-list', kwargs={
@@ -28,29 +26,6 @@ class ThreadPollApiTestCase(AuthenticatedUserTestCase):
     def put(self, url, data=None):
         return self.client.put(url, json.dumps(data or {}), content_type='application/json')
 
-    def override_acl(self, user=None, category=None):
-        new_acl = self.user.acl_cache
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_close_threads': 0,
-        })
-
-        new_acl.update({
-            'can_start_polls': 1,
-            'can_edit_polls': 1,
-            'can_delete_polls': 1,
-            'poll_edit_time': 0,
-            'can_always_see_poll_voters': 0,
-        })
-
-        if user:
-            new_acl.update(user)
-        if category:
-            new_acl['categories'][self.category.pk].update(category)
-
-        override_acl(self.user, new_acl)
-
     def mock_poll(self):
         self.poll = self.thread.poll = testutils.post_poll(self.thread, self.user)
 

+ 28 - 14
misago/threads/tests/test_thread_pollcreate_api.py

@@ -1,7 +1,9 @@
 from django.urls import reverse
 
+from misago.acl.test import patch_user_acl
 from misago.threads.models import Poll, Thread
 from misago.threads.serializers.poll import MAX_POLL_OPTIONS
+from misago.threads.test import patch_category_acl
 
 from .test_thread_poll_api import ThreadPollApiTestCase
 
@@ -36,20 +38,19 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         response = self.post(api_link)
         self.assertEqual(response.status_code, 404)
 
+    @patch_user_acl({"can_start_polls": 0})
     def test_no_permission(self):
         """api validates that user has permission to start poll in thread"""
-        self.override_acl({'can_start_polls': 0})
-
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't start polls."
         })
 
-    def test_no_permission_closed_thread(self):
+    @patch_user_acl({"can_start_polls": 1})
+    @patch_category_acl({"can_close_threads": False})
+    def test_closed_thread_no_permission(self):
         """api validates that user has permission to start poll in closed thread"""
-        self.override_acl(category={'can_close_threads': 0})
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -59,15 +60,20 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
             "detail": "This thread is closed. You can't start polls in it."
         })
 
-        self.override_acl(category={'can_close_threads': 1})
+    @patch_user_acl({"can_start_polls": 1})
+    @patch_category_acl({"can_close_threads": True})
+    def test_closed_thread(self):
+        """api validates that user has permission to start poll in closed thread"""
+        self.thread.is_closed = True
+        self.thread.save()
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
 
-    def test_no_permission_closed_category(self):
+    @patch_user_acl({"can_start_polls": 1})
+    @patch_category_acl({"can_close_threads": False})
+    def test_closed_category_no_permission(self):
         """api validates that user has permission to start poll in closed category"""
-        self.override_acl(category={'can_close_threads': 0})
-
         self.category.is_closed = True
         self.category.save()
 
@@ -77,15 +83,19 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
             "detail": "This category is closed. You can't start polls in it."
         })
 
-        self.override_acl(category={'can_close_threads': 1})
+    @patch_user_acl({"can_start_polls": 1})
+    @patch_category_acl({"can_close_threads": True})
+    def test_closed_category(self):
+        """api validates that user has permission to start poll in closed category"""
+        self.category.is_closed = True
+        self.category.save()
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
 
-    def test_no_permission_other_user_thread(self):
+    @patch_user_acl({"can_start_polls": 1})
+    def test_other_user_thread_no_permission(self):
         """api validates that user has permission to start poll in other user's thread"""
-        self.override_acl({'can_start_polls': 1})
-
         self.thread.starter = None
         self.thread.save()
 
@@ -95,7 +105,11 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
             "detail": "You can't start polls in other users threads."
         })
 
-        self.override_acl({'can_start_polls': 2})
+    @patch_user_acl({"can_start_polls": 2})
+    def test_other_user_thread(self):
+        """api validates that user has permission to start poll in other user's thread"""
+        self.thread.starter = None
+        self.thread.save()
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)

+ 24 - 16
misago/threads/tests/test_thread_polldelete_api.py

@@ -3,7 +3,9 @@ from datetime import timedelta
 from django.urls import reverse
 from django.utils import timezone
 
+from misago.acl.test import patch_user_acl
 from misago.threads.models import Poll, PollVote, Thread
+from misago.threads.test import patch_category_acl
 
 from .test_thread_poll_api import ThreadPollApiTestCase
 
@@ -73,20 +75,18 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         response = self.client.delete(api_link)
         self.assertEqual(response.status_code, 404)
 
+    @patch_user_acl({"can_delete_polls": 0})
     def test_no_permission(self):
         """api validates that user has permission to delete poll in thread"""
-        self.override_acl({'can_delete_polls': 0})
-
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't delete polls."
         })
 
+    @patch_user_acl({"can_delete_polls": 1, "poll_edit_time": 5})
     def test_no_permission_timeout(self):
         """api validates that user's window to delete poll in thread has closed"""
-        self.override_acl({'can_delete_polls': 1, 'poll_edit_time': 5})
-
         self.poll.posted_on = timezone.now() - timedelta(minutes=15)
         self.poll.save()
 
@@ -96,10 +96,9 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
             "detail": "You can't delete polls that are older than 5 minutes."
         })
 
+    @patch_user_acl({"can_delete_polls": 1})
     def test_no_permission_poll_closed(self):
         """api validates that user's window to delete poll in thread has closed"""
-        self.override_acl({'can_delete_polls': 1})
-
         self.poll.posted_on = timezone.now() - timedelta(days=15)
         self.poll.length = 5
         self.poll.save()
@@ -110,10 +109,9 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
             "detail": "This poll is over. You can't delete it."
         })
 
+    @patch_user_acl({"can_delete_polls": 1})
     def test_no_permission_other_user_poll(self):
         """api validates that user has permission to delete other user poll in thread"""
-        self.override_acl({'can_delete_polls': 1})
-
         self.poll.poster = None
         self.poll.save()
 
@@ -123,10 +121,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
             "detail": "You can't delete other users polls in this category."
         })
 
+    @patch_user_acl({"can_delete_polls": 1})
+    @patch_category_acl({"can_close_threads": False})
     def test_no_permission_closed_thread(self):
         """api validates that user has permission to delete poll in closed thread"""
-        self.override_acl(category={'can_close_threads': 0})
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -136,15 +134,20 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
             "detail": "This thread is closed. You can't delete polls in it."
         })
 
-        self.override_acl(category={'can_close_threads': 1})
+    @patch_user_acl({"can_delete_polls": 1})
+    @patch_category_acl({"can_close_threads": True})
+    def test_closed_thread(self):
+        """api validates that user has permission to delete poll in closed thread"""
+        self.thread.is_closed = True
+        self.thread.save()
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
 
+    @patch_user_acl({"can_delete_polls": 1})
+    @patch_category_acl({"can_close_threads": False})
     def test_no_permission_closed_category(self):
         """api validates that user has permission to delete poll in closed category"""
-        self.override_acl(category={'can_close_threads': 0})
-
         self.category.is_closed = True
         self.category.save()
 
@@ -154,11 +157,17 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
             "detail": "This category is closed. You can't delete polls in it."
         })
 
-        self.override_acl(category={'can_close_threads': 1})
+    @patch_user_acl({"can_delete_polls": 1})
+    @patch_category_acl({"can_close_threads": True})
+    def test_closed_category(self):
+        """api validates that user has permission to delete poll in closed category"""
+        self.category.is_closed = True
+        self.category.save()
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
 
+    @patch_user_acl({"can_delete_polls": 1, "poll_edit_time": 5})
     def test_poll_delete(self):
         """api deletes poll and associated votes"""
         response = self.client.delete(self.api_link)
@@ -173,10 +182,9 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         thread = Thread.objects.get(pk=self.thread.pk)
         self.assertFalse(thread.has_poll)
 
+    @patch_user_acl({"can_delete_polls": 2, "poll_edit_time": 5})
     def test_other_user_poll_delete(self):
         """api deletes other user's poll and associated votes, even if its over"""
-        self.override_acl({'can_delete_polls': 2, 'poll_edit_time': 5})
-
         self.poll.poster = None
         self.poll.posted_on = timezone.now() - timedelta(days=15)
         self.poll.length = 5

+ 23 - 16
misago/threads/tests/test_thread_polledit_api.py

@@ -3,7 +3,9 @@ from datetime import timedelta
 from django.urls import reverse
 from django.utils import timezone
 
+from misago.acl.test import patch_user_acl
 from misago.threads.serializers.poll import MAX_POLL_OPTIONS
+from misago.threads.test import patch_category_acl
 
 from .test_thread_poll_api import ThreadPollApiTestCase
 
@@ -73,20 +75,18 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         response = self.put(api_link)
         self.assertEqual(response.status_code, 404)
 
+    @patch_user_acl({"can_edit_polls": 0})
     def test_no_permission(self):
         """api validates that user has permission to edit poll in thread"""
-        self.override_acl({'can_edit_polls': 0})
-
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't edit polls.",
         })
 
+    @patch_user_acl({"can_edit_polls": 1, "poll_edit_time": 5})
     def test_no_permission_timeout(self):
         """api validates that user's window to edit poll in thread has closed"""
-        self.override_acl({'can_edit_polls': 1, 'poll_edit_time': 5})
-
         self.poll.posted_on = timezone.now() - timedelta(minutes=15)
         self.poll.save()
 
@@ -96,10 +96,9 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
             "detail": "You can't edit polls that are older than 5 minutes.",
         })
 
+    @patch_user_acl({"can_edit_polls": 1})
     def test_no_permission_poll_closed(self):
         """api validates that user's window to edit poll in thread has closed"""
-        self.override_acl({'can_edit_polls': 1})
-
         self.poll.posted_on = timezone.now() - timedelta(days=15)
         self.poll.length = 5
         self.poll.save()
@@ -110,10 +109,9 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
             "detail": "This poll is over. You can't edit it.",
         })
 
+    @patch_user_acl({"can_edit_polls": 1})
     def test_no_permission_other_user_poll(self):
         """api validates that user has permission to edit other user poll in thread"""
-        self.override_acl({'can_edit_polls': 1})
-
         self.poll.poster = None
         self.poll.save()
 
@@ -123,10 +121,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
             "detail": "You can't edit other users polls in this category.",
         })
 
+    @patch_user_acl({"can_edit_polls": 1})
+    @patch_category_acl({"can_close_threads": False})
     def test_no_permission_closed_thread(self):
         """api validates that user has permission to edit poll in closed thread"""
-        self.override_acl(category={'can_close_threads': 0})
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -136,15 +134,20 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
             "detail": "This thread is closed. You can't edit polls in it.",
         })
 
-        self.override_acl(category={'can_close_threads': 1})
+    @patch_user_acl({"can_edit_polls": 1})
+    @patch_category_acl({"can_close_threads": True})
+    def test_closed_thread(self):
+        """api validates that user has permission to edit poll in closed thread"""
+        self.thread.is_closed = True
+        self.thread.save()
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
 
+    @patch_user_acl({"can_edit_polls": 1})
+    @patch_category_acl({"can_close_threads": False})
     def test_no_permission_closed_category(self):
         """api validates that user has permission to edit poll in closed category"""
-        self.override_acl(category={'can_close_threads': 0})
-
         self.category.is_closed = True
         self.category.save()
 
@@ -154,7 +157,12 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
             "detail": "This category is closed. You can't edit polls in it.",
         })
 
-        self.override_acl(category={'can_close_threads': 1})
+    @patch_user_acl({"can_edit_polls": 1})
+    @patch_category_acl({"can_close_threads": True})
+    def test_closed_category(self):
+        """api validates that user has permission to edit poll in closed category"""
+        self.category.is_closed = True
+        self.category.save()
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
@@ -513,10 +521,9 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
         self.assertEqual(self.user.audittrail_set.count(), 1)
 
+    @patch_user_acl({"can_edit_polls": 2, "poll_edit_time": 5})
     def test_moderate_user_poll(self):
         """api edits all poll choices out in other users poll, even if its over"""
-        self.override_acl({'can_edit_polls': 2, 'poll_edit_time': 5})
-
         self.poll.poster = None
         self.poll.posted_on = timezone.now() - timedelta(days=15)
         self.poll.length = 5

+ 22 - 18
misago/threads/tests/test_thread_pollvotes_api.py

@@ -4,7 +4,9 @@ from django.contrib.auth import get_user_model
 from django.urls import reverse
 from django.utils import timezone
 
+from misago.acl.test import patch_user_acl
 from misago.threads.models import Poll
+from misago.threads.test import patch_category_acl
 
 from .test_thread_poll_api import ThreadPollApiTestCase
 
@@ -88,10 +90,9 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
         response = self.client.get(api_link)
         self.assertEqual(response.status_code, 404)
 
+    @patch_user_acl({"can_always_see_poll_voters": False})
     def test_no_permission(self):
         """api chcecks permission to see poll voters"""
-        self.override_acl({'can_always_see_poll_voters': False})
-
         self.poll.is_public = False
         self.poll.save()
 
@@ -128,10 +129,9 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
         self.assertEqual([[v['url'] for v in c['voters']] for c in response_json][0][0],
                          user.get_absolute_url())
 
+    @patch_user_acl({"can_always_see_poll_voters": True})
     def test_get_votes_private_poll(self):
         """api returns list of voters on private poll for user with permission"""
-        self.override_acl({'can_always_see_poll_voters': True})
-
         self.poll.is_public = False
         self.poll.save()
 
@@ -271,10 +271,9 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
             "detail": 'Expected a list of items but got type "dict".',
         })
 
-    def test_vote_in_closed_thread(self):
+    @patch_category_acl({"can_close_threads": False})
+    def test_vote_in_closed_thread_no_permission(self):
         """api validates is user has permission to vote poll in closed thread"""
-        self.override_acl(category={'can_close_threads': 0})
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -286,18 +285,20 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
             "detail": "This thread is closed. You can't vote in it.",
         })
 
-        self.override_acl(category={'can_close_threads': 1})
+    @patch_category_acl({"can_close_threads": True})
+    def test_vote_in_closed_thread(self):
+        """api validates is user has permission to vote poll in closed thread"""
+        self.thread.is_closed = True
+        self.thread.save()
+
+        self.delete_user_votes()
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "dict".',
-        })
 
-    def test_vote_in_closed_category(self):
+    @patch_category_acl({"can_close_threads": False})
+    def test_vote_in_closed_category_no_permission(self):
         """api validates is user has permission to vote poll in closed category"""
-        self.override_acl(category={'can_close_threads': 0})
-
         self.category.is_closed = True
         self.category.save()
 
@@ -309,13 +310,16 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
             "detail": "This category is closed. You can't vote in it.",
         })
 
-        self.override_acl(category={'can_close_threads': 1})
+    @patch_category_acl({"can_close_threads": True})
+    def test_vote_in_closed_category(self):
+        """api validates is user has permission to vote poll in closed category"""
+        self.category.is_closed = True
+        self.category.save()
+
+        self.delete_user_votes()
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "dict".',
-        })
 
     def test_vote_in_finished_poll(self):
         """api valdiates if poll has finished before letting user to vote in it"""

+ 45 - 82
misago/threads/tests/test_thread_postbulkdelete_api.py

@@ -6,6 +6,7 @@ from django.utils import timezone
 
 from misago.threads import testutils
 from misago.threads.models import Post, Thread
+from misago.threads.test import patch_category_acl
 
 from .test_threads_api import ThreadsApiTestCase
 
@@ -64,13 +65,9 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "You have to specify at least one post to delete.",
         })
 
+    @patch_category_acl({'can_hide_posts': 2})
     def test_validate_ids(self):
         """api validates that ids are list of ints"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 2,
-        })
-
         response = self.delete(self.api_link, True)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
@@ -89,39 +86,27 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "One or more post ids received were invalid.",
         })
 
+    @patch_category_acl({'can_hide_posts': 2})
     def test_validate_ids_length(self):
         """api validates that ids are list of ints"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 2,
-        })
-
         response = self.delete(self.api_link, list(range(100)))
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "detail": "No more than 24 posts can be deleted at single time.",
         })
 
+    @patch_category_acl({'can_hide_posts': 2})
     def test_validate_posts_exist(self):
         """api validates that ids are visible posts"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         response = self.delete(self.api_link, [p.id * 10 for p in self.posts])
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "One or more posts to delete could not be found.",
         })
 
+    @patch_category_acl({'can_hide_posts': 2})
     def test_validate_posts_visibility(self):
         """api validates that ids are visible posts"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         self.posts[1].is_unapproved = True
         self.posts[1].save()
 
@@ -131,13 +116,9 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "One or more posts to delete could not be found.",
         })
 
+    @patch_category_acl({'can_hide_posts': 2})
     def test_validate_posts_same_thread(self):
         """api validates that ids are same thread posts"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 2,
-        })
-
         other_thread = testutils.post_thread(category=self.category)
         self.posts.append(testutils.reply_thread(other_thread, poster=self.user))
 
@@ -147,41 +128,35 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "One or more posts to delete could not be found.",
         })
 
+    @patch_category_acl({'can_hide_posts': 1, 'can_hide_own_posts': 1})
     def test_no_permission(self):
         """api validates permission to delete"""
-        self.override_acl({
-            'can_hide_own_posts': 1,
-            'can_hide_posts': 1,
-        })
-
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't delete posts in this category.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 0,
+        'can_hide_own_posts': 2,
+        'post_edit_time': 10,
+    })
     def test_delete_other_user_post_no_permission(self):
         """api valdiates if user can delete other users posts"""
-        self.override_acl({
-            'post_edit_time': 0,
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't delete other users posts in this category.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 0,
+        'can_hide_own_posts': 2,
+        'can_protect_posts': False,
+    })
     def test_delete_protected_post_no_permission(self):
         """api validates if user can delete protected post"""
-        self.override_acl({
-            'can_protect_posts': 0,
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         self.posts[0].is_protected = True
         self.posts[0].save()
 
@@ -191,14 +166,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "This post is protected. You can't delete it.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 0,
+        'can_hide_own_posts': 2,
+        'post_edit_time': 1,
+    })
     def test_delete_protected_post_after_edit_time(self):
         """api validates if user can delete delete post after edit time"""
-        self.override_acl({
-            'post_edit_time': 1,
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         self.posts[0].posted_on = timezone.now() - timedelta(minutes=10)
         self.posts[0].save()
 
@@ -208,13 +182,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete posts that are older than 1 minute.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 2,
+        'can_hide_own_posts': 2,
+        'can_close_threads': False,
+    })
     def test_delete_post_closed_thread_no_permission(self):
         """api valdiates if user can delete posts in closed threads"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -224,13 +198,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "This thread is closed. You can't delete posts in it.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 2,
+        'can_hide_own_posts': 2,
+        'can_close_threads': False,
+    })
     def test_delete_post_closed_category_no_permission(self):
         """api valdiates if user can delete posts in closed categories"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         self.category.is_closed = True
         self.category.save()
 
@@ -240,13 +214,9 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "This category is closed. You can't delete posts in it.",
         })
 
+    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 2})
     def test_delete_first_post(self):
         """api disallows first post's deletion"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 2,
-        })
-
         ids = [p.id for p in self.posts]
         ids.append(self.thread.first_post_id)
 
@@ -256,10 +226,9 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete thread's first post.",
         })
 
+    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 2})
     def test_delete_best_answer(self):
         """api disallows best answer deletion"""
-        self.override_acl({'can_hide_own_posts': 2, 'can_hide_posts': 2})
-
         self.thread.set_best_answer(self.user, self.posts[0])
         self.thread.save()
 
@@ -269,14 +238,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete this post because its marked as best answer.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 2, 
+        'can_hide_own_posts': 2, 
+        'can_hide_events': 0,
+    })
     def test_delete_event(self):
         """api differs posts from events"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 2,
-            'can_hide_events': 0,
-        })
-
         self.posts[1].is_event = True
         self.posts[1].save()
 
@@ -286,14 +254,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete events in this category.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 0, 
+        'can_hide_own_posts': 2, 
+        'post_edit_time': 10,
+    })
     def test_delete_owned_posts(self):
         """api deletes owned thread posts"""
-        self.override_acl({
-            'post_edit_time': 0,
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         ids = [self.posts[0].id, self.posts[-1].id]
 
         response = self.delete(self.api_link, ids)
@@ -304,13 +271,9 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             with self.assertRaises(Post.DoesNotExist):
                 self.thread.post_set.get(pk=post)
 
+    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 0})
     def test_delete_posts(self):
         """api deletes thread posts"""
-        self.override_acl({
-            'can_hide_own_posts': 0,
-            'can_hide_posts': 2,
-        })
-
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 200)
 

+ 4 - 25
misago/threads/tests/test_thread_postbulkpatch_api.py

@@ -4,10 +4,10 @@ from datetime import timedelta
 from django.urls import reverse
 from django.utils import timezone
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads.models import Post, Thread
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -35,21 +35,6 @@ class ThreadPostBulkPatchApiTestCase(AuthenticatedUserTestCase):
     def patch(self, api_link, ops):
         return self.client.patch(api_link, json.dumps(ops), content_type="application/json")
 
-    def override_acl(self, extra_acl=None):
-        new_acl = self.user.acl_cache
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 0,
-            'can_reply_threads': 0,
-            'can_edit_posts': 1,
-        })
-
-        if extra_acl:
-            new_acl['categories'][self.category.pk].update(extra_acl)
-
-        override_acl(self.user, new_acl)
-
 
 class BulkPatchSerializerTests(ThreadPostBulkPatchApiTestCase):
     def test_invalid_input_type(self):
@@ -220,13 +205,9 @@ class PostsAddAclApiTests(ThreadPostBulkPatchApiTestCase):
 
 
 class BulkPostProtectApiTests(ThreadPostBulkPatchApiTestCase):
+    @patch_category_acl({"can_protect_posts": True, "can_edit_posts": 2})
     def test_protect_post(self):
         """api makes it possible to protect posts"""
-        self.override_acl({
-            'can_protect_posts': 1,
-            'can_edit_posts': 2,
-        })
-
         response = self.patch(
             self.api_link, {
                 'ids': self.ids,
@@ -249,10 +230,9 @@ class BulkPostProtectApiTests(ThreadPostBulkPatchApiTestCase):
         for post in Post.objects.filter(id__in=self.ids):
             self.assertTrue(post.is_protected)
 
+    @patch_category_acl({"can_protect_posts": False})
     def test_protect_post_no_permission(self):
         """api validates permission to protect posts and returns errors"""
-        self.override_acl({'can_protect_posts': 0})
-
         response = self.patch(
             self.api_link, {
                 'ids': self.ids,
@@ -280,6 +260,7 @@ class BulkPostProtectApiTests(ThreadPostBulkPatchApiTestCase):
 
 
 class BulkPostsApproveApiTests(ThreadPostBulkPatchApiTestCase):
+    @patch_category_acl({"can_approve_content": True})
     def test_approve_post(self):
         """api resyncs thread and categories on posts approval"""
         for post in self.posts:
@@ -291,8 +272,6 @@ class BulkPostsApproveApiTests(ThreadPostBulkPatchApiTestCase):
 
         self.assertNotIn(self.thread.last_post_id, self.ids)
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
             self.api_link, {
                 'ids': self.ids,

+ 54 - 64
misago/threads/tests/test_thread_postdelete_api.py

@@ -5,6 +5,7 @@ from django.utils import timezone
 
 from misago.threads import testutils
 from misago.threads.models import Post, Thread
+from misago.threads.test import patch_category_acl
 
 from .test_threads_api import ThreadsApiTestCase
 
@@ -33,24 +34,22 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "This action is not available to guests.",
         })
 
+    @patch_category_acl({'can_hide_posts': 1, 'can_hide_own_posts': 1})
     def test_no_permission(self):
         """api validates permission to delete post"""
-        self.override_acl({'can_hide_own_posts': 1, 'can_hide_posts': 1})
-
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't delete posts in this category.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 1, 
+        'can_hide_own_posts': 2,
+        'post_edit_time': 0,
+    })
     def test_delete_other_user_post_no_permission(self):
         """api valdiates if user can delete other users posts"""
-        self.override_acl({
-            'post_edit_time': 0,
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         self.post.poster = None
         self.post.save()
 
@@ -60,14 +59,13 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete other users posts in this category.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 1, 
+        'can_hide_own_posts': 2,
+        'post_edit_time': 0,
+    })
     def test_delete_protected_post_no_permission(self):
         """api validates if user can delete protected post"""
-        self.override_acl({
-            'can_protect_posts': 0,
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         self.post.is_protected = True
         self.post.save()
 
@@ -77,14 +75,13 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "This post is protected. You can't delete it.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 1, 
+        'can_hide_own_posts': 2,
+        'post_edit_time': 1,
+    })
     def test_delete_protected_post_after_edit_time(self):
         """api validates if user can delete delete post after edit time"""
-        self.override_acl({
-            'post_edit_time': 1,
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         self.post.posted_on = timezone.now() - timedelta(minutes=10)
         self.post.save()
 
@@ -94,13 +91,14 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete posts that are older than 1 minute.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 0, 
+        'can_hide_own_posts': 2,
+        'post_edit_time': 0,
+        'can_close_threads': False,
+    })
     def test_delete_post_closed_thread_no_permission(self):
         """api valdiates if user can delete posts in closed threads"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -110,13 +108,14 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "This thread is closed. You can't delete posts in it.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 0, 
+        'can_hide_own_posts': 2,
+        'post_edit_time': 0,
+        'can_close_threads': False,
+    })
     def test_delete_post_closed_category_no_permission(self):
         """api valdiates if user can delete posts in closed categories"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         self.category.is_closed = True
         self.category.save()
 
@@ -126,10 +125,9 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "This category is closed. You can't delete posts in it.",
         })
 
+    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 2})
     def test_delete_first_post(self):
         """api disallows first post deletion"""
-        self.override_acl({'can_hide_own_posts': 2, 'can_hide_posts': 2})
-
         api_link = reverse(
             'misago:api:thread-post-detail',
             kwargs={
@@ -144,10 +142,9 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete thread's first post.",
         })
 
+    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 2})
     def test_delete_best_answer(self):
         """api disallows best answer deletion"""
-        self.override_acl({'can_hide_own_posts': 2, 'can_hide_posts': 2})
-
         self.thread.set_best_answer(self.user, self.post)
         self.thread.save()
 
@@ -157,14 +154,13 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             'detail': "You can't delete this post because its marked as best answer.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 0,
+        'can_hide_own_posts': 2,
+        'post_edit_time': 0
+    })
     def test_delete_owned_post(self):
         """api deletes owned thread post"""
-        self.override_acl({
-            'post_edit_time': 0,
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 0,
-        })
-
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -174,10 +170,9 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         with self.assertRaises(Post.DoesNotExist):
             self.thread.post_set.get(pk=self.post.pk)
 
+    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 0})
     def test_delete_post(self):
         """api deletes thread post"""
-        self.override_acl({'can_hide_own_posts': 0, 'can_hide_posts': 2})
-
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -212,27 +207,27 @@ class EventDeleteApiTests(ThreadsApiTestCase):
             "detail": "This action is not available to guests.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 2,
+        'can_hide_own_posts': 0,
+        'can_hide_events': 0,
+    })
     def test_no_permission(self):
         """api validates permission to delete event"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 2,
-            'can_hide_events': 0,
-        })
-
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't delete events in this category.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 2,
+        'can_hide_own_posts': 0,
+        'can_hide_events': 2,
+        'can_close_threads': False,
+    })
     def test_delete_event_closed_thread_no_permission(self):
         """api valdiates if user can delete events in closed threads"""
-        self.override_acl({
-            'can_hide_events': 2,
-            'can_close_threads': 0,
-        })
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -242,13 +237,13 @@ class EventDeleteApiTests(ThreadsApiTestCase):
             "detail": "This thread is closed. You can't delete events in it.",
         })
 
+    @patch_category_acl({
+        'can_hide_posts': 2,
+        'can_hide_events': 2,
+        'can_close_threads': False,
+    })
     def test_delete_event_closed_category_no_permission(self):
         """api valdiates if user can delete events in closed categories"""
-        self.override_acl({
-            'can_hide_events': 2,
-            'can_close_threads': 0,
-        })
-
         self.category.is_closed = True
         self.category.save()
 
@@ -258,14 +253,9 @@ class EventDeleteApiTests(ThreadsApiTestCase):
             "detail": "This category is closed. You can't delete events in it.",
         })
 
+    @patch_category_acl({'can_hide_posts': 0, 'can_hide_events': 2})
     def test_delete_event(self):
         """api differs posts from events"""
-        self.override_acl({
-            'can_hide_own_posts': 0,
-            'can_hide_posts': 0,
-            'can_hide_events': 2,
-        })
-
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
 

+ 6 - 6
misago/threads/tests/test_thread_postedits_api.py

@@ -1,6 +1,7 @@
 from django.urls import reverse
 
 from misago.threads import testutils
+from misago.threads.test import patch_category_acl
 
 from .test_threads_api import ThreadsApiTestCase
 
@@ -19,8 +20,6 @@ class ThreadPostEditsApiTestCase(ThreadsApiTestCase):
             }
         )
 
-        self.override_acl()
-
     def mock_edit_record(self):
         edits_record = [
             self.post.edits_record.create(
@@ -135,18 +134,19 @@ class ThreadPostPostEditTests(ThreadPostEditsApiTestCase):
         super().setUp()
         self.edits = self.mock_edit_record()
 
-        self.override_acl({'can_edit_posts': 2})
-
+    @patch_category_acl({"can_edit_posts": 2})
     def test_empty_edit_id(self):
         """api handles empty edit in querystring"""
         response = self.client.post('%s?edit=' % self.api_link)
         self.assertEqual(response.status_code, 404)
 
+    @patch_category_acl({"can_edit_posts": 2})
     def test_invalid_edit_id(self):
         """api handles invalid edit in querystring"""
         response = self.client.post('%s?edit=dsa67d8sa68' % self.api_link)
         self.assertEqual(response.status_code, 404)
 
+    @patch_category_acl({"can_edit_posts": 2})
     def test_nonexistant_edit_id(self):
         """api handles nonexistant edit in querystring"""
         response = self.client.post('%s?edit=1321' % self.api_link)
@@ -159,13 +159,13 @@ class ThreadPostPostEditTests(ThreadPostEditsApiTestCase):
         response = self.client.post('%s?edit=%s' % (self.api_link, self.edits[0].id))
         self.assertEqual(response.status_code, 403)
 
+    @patch_category_acl({"can_edit_posts": 0})
     def test_no_permission(self):
         """api validates permission to revert post"""
-        self.override_acl({'can_edit_posts': 0})
-
         response = self.client.post('%s?edit=1321' % self.api_link)
         self.assertEqual(response.status_code, 403)
 
+    @patch_category_acl({"can_edit_posts": 2})
     def test_revert_post(self):
         """api reverts post to version from before specified edit"""
         response = self.client.post('%s?edit=%s' % (self.api_link, self.edits[0].id))

+ 5 - 4
misago/threads/tests/test_thread_postlikes_api.py

@@ -1,6 +1,7 @@
 from django.urls import reverse
 
 from misago.threads import testutils
+from misago.threads.test import patch_category_acl
 from misago.threads.serializers import PostLikeSerializer
 
 from .test_threads_api import ThreadsApiTestCase
@@ -20,32 +21,32 @@ class ThreadPostLikesApiTestCase(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_see_posts_likes": 0})
     def test_no_permission(self):
         """api errors if user has no permission to see likes"""
-        self.override_acl({'can_see_posts_likes': 0})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEquals(response.json(), {
             "detail": "You can't see who liked this post."
         })
 
+    @patch_category_acl({"can_see_posts_likes": 1})
     def test_no_permission_to_list(self):
         """api errors if user has no permission to see likes, but can see likes count"""
-        self.override_acl({'can_see_posts_likes': 1})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEquals(response.json(), {
             "detail": "You can't see who liked this post."
         })
 
+    @patch_category_acl({"can_see_posts_likes": 2})
     def test_no_likes(self):
         """api returns empty list if post has no likes"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), [])
 
+    @patch_category_acl({"can_see_posts_likes": 2})
     def test_likes(self):
         """api returns list of likes"""
         like = testutils.like_post(self.post, self.user)

+ 59 - 49
misago/threads/tests/test_thread_postmerge_api.py

@@ -2,12 +2,12 @@ import json
 
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.readtracker import poststracker
 from misago.threads import testutils
 from misago.threads.models import Post, Thread
 from misago.threads.serializers.moderation import POSTS_LIMIT
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -25,28 +25,6 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
-        self.override_acl()
-
-    def refresh_thread(self):
-        self.thread = Thread.objects.get(pk=self.thread.pk)
-
-    def override_acl(self, extra_acl=None):
-        new_acl = self.user.acl_cache
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 0,
-            'can_reply_threads': 0,
-            'can_edit_posts': 1,
-            'can_approve_content': 0,
-            'can_merge_posts': 1,
-        })
-
-        if extra_acl:
-            new_acl['categories'][self.category.pk].update(extra_acl)
-
-        override_acl(self.user, new_acl)
-
     def test_anonymous_user(self):
         """you need to authenticate to merge posts"""
         self.logout_user()
@@ -61,10 +39,9 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "This action is not available to guests.",
         })
 
+    @patch_category_acl({"can_merge_posts": False})
     def test_no_permission(self):
         """api validates permission to merge"""
-        self.override_acl({'can_merge_posts': 0})
-
         response = self.client.post(
             self.api_link,
             json.dumps({}),
@@ -75,6 +52,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "You can't merge posts in this thread.",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_empty_data_json(self):
         """api handles empty json data"""
         response = self.client.post(
@@ -85,6 +63,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to select at least two posts to merge.",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_empty_data_form(self):
         """api handles empty form data"""
         response = self.client.post(self.api_link, {})
@@ -93,36 +72,34 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to select at least two posts to merge.",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_invalid_data(self):
         """api handles post that is invalid type"""
-        self.override_acl()
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "detail": "Invalid data. Expected a dictionary, but got list.",
         })
 
-        self.override_acl()
         response = self.client.post(self.api_link, '123', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "detail": "Invalid data. Expected a dictionary, but got int.",
         })
 
-        self.override_acl()
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "detail": "Invalid data. Expected a dictionary, but got str.",
         })
 
-        self.override_acl()
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_no_posts_ids(self):
         """api rejects no posts ids"""
         response = self.client.post(
@@ -137,6 +114,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to select at least two posts to merge.",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_invalid_posts_data(self):
         """api handles invalid data"""
         response = self.client.post(
@@ -151,6 +129,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": 'Expected a list of items but got type "str".',
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
         response = self.client.post(
@@ -165,6 +144,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more post ids received were invalid.",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_one_post_id(self):
         """api rejects one post id"""
         response = self.client.post(
@@ -179,6 +159,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to select at least two posts to merge.",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_limit(self):
         """api rejects more posts than merge limit"""
         response = self.client.post(
@@ -193,6 +174,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "No more than %s posts can be merged at single time." % POSTS_LIMIT,
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_event(self):
         """api recjects events"""
         event = testutils.reply_thread(self.thread, is_event=True, poster=self.user)
@@ -209,6 +191,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "Events can't be merged.",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_notfound_pk(self):
         """api recjects nonexistant pk's"""
         response = self.client.post(
@@ -223,6 +206,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more posts to merge could not be found.",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_cross_threads(self):
         """api recjects attempt to merge with post made in other thread"""
         other_thread = testutils.post_thread(category=self.category)
@@ -240,6 +224,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more posts to merge could not be found.",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_authenticated_with_guest_post(self):
         """api recjects attempt to merge with post made by deleted user"""
         other_post = testutils.reply_thread(self.thread)
@@ -256,6 +241,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "Posts made by different users can't be merged.",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_guest_with_authenticated_post(self):
         """api recjects attempt to merge with post made by deleted user"""
         other_post = testutils.reply_thread(self.thread)
@@ -272,6 +258,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "Posts made by different users can't be merged.",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_guest_posts_different_usernames(self):
         """api recjects attempt to merge posts made by different guests"""
         response = self.client.post(
@@ -289,10 +276,9 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "Posts made by different users can't be merged.",
         })
 
+    @patch_category_acl({"can_merge_posts": True, "can_hide_posts": 1})
     def test_merge_different_visibility(self):
         """api recjects attempt to merge posts with different visibility"""
-        self.override_acl({'can_hide_posts': 1})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -308,10 +294,9 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "Posts with different visibility can't be merged.",
         })
 
+    @patch_category_acl({"can_merge_posts": True, "can_approve_content": True})
     def test_merge_different_approval(self):
         """api recjects attempt to merge posts with different approval"""
-        self.override_acl({'can_approve_content': 1})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -327,7 +312,8 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "Posts with different visibility can't be merged.",
         })
 
-    def test_closed_thread(self):
+    @patch_category_acl({"can_merge_posts": True, "can_close_threads": False})
+    def test_closed_thread_no_permission(self):
         """api validates permission to merge in closed thread"""
         self.thread.is_closed = True
         self.thread.save()
@@ -347,8 +333,16 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "This thread is closed. You can't merge posts in it.",
         })
 
-        # allow closing threads
-        self.override_acl({'can_close_threads': 1})
+    @patch_category_acl({"can_merge_posts": True, "can_close_threads": True})
+    def test_closed_thread(self):
+        """api validates permission to merge in closed thread"""
+        self.thread.is_closed = True
+        self.thread.save()
+
+        posts = [
+            testutils.reply_thread(self.thread, poster=self.user).pk,
+            testutils.reply_thread(self.thread, poster=self.user).pk,
+        ]
 
         response = self.client.post(
             self.api_link,
@@ -357,7 +351,8 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-    def test_closed_category(self):
+    @patch_category_acl({"can_merge_posts": True, "can_close_threads": False})
+    def test_closed_category_no_permission(self):
         """api validates permission to merge in closed category"""
         self.category.is_closed = True
         self.category.save()
@@ -377,8 +372,16 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "This category is closed. You can't merge posts in it.",
         })
 
-        # allow closing threads
-        self.override_acl({'can_close_threads': 1})
+    @patch_category_acl({"can_merge_posts": True, "can_close_threads": True})
+    def test_closed_category(self):
+        """api validates permission to merge in closed category"""
+        self.category.is_closed = True
+        self.category.save()
+
+        posts = [
+            testutils.reply_thread(self.thread, poster=self.user).pk,
+            testutils.reply_thread(self.thread, poster=self.user).pk,
+        ]
 
         response = self.client.post(
             self.api_link,
@@ -387,6 +390,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_best_answer_first_post(self):
         """api recjects attempt to merge best_answer with first post"""
         self.thread.first_post.poster = self.user
@@ -413,6 +417,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "Post marked as best answer can't be merged with thread's first post.",
         })
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_posts(self):
         """api merges two posts"""
         post_a = testutils.reply_thread(self.thread, poster=self.user, message="Battęry")
@@ -429,7 +434,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, thread_replies - 1)
 
         with self.assertRaises(Post.DoesNotExist):
@@ -438,6 +443,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         merged_post = Post.objects.get(pk=post_a.pk)
         self.assertEqual(merged_post.parsed, '%s\n%s' % (post_a.parsed, post_b.parsed))
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_guest_posts(self):
         """api recjects attempt to merge posts made by same guest"""
         response = self.client.post(
@@ -452,10 +458,9 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({"can_merge_posts": True, 'can_hide_posts': 1})
     def test_merge_hidden_posts(self):
         """api merges two hidden posts"""
-        self.override_acl({'can_hide_posts': 1})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -468,10 +473,9 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({"can_merge_posts": True, 'can_approve_content': True})
     def test_merge_unapproved_posts(self):
         """api merges two unapproved posts"""
-        self.override_acl({'can_approve_content': 1})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -484,6 +488,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({"can_merge_posts": True, 'can_hide_threads': True})
     def test_merge_with_hidden_thread(self):
         """api excludes thread's first post from visibility checks"""
         self.thread.first_post.is_hidden = True
@@ -492,8 +497,6 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
 
         post_visible = testutils.reply_thread(self.thread, poster=self.user, is_hidden=False)
 
-        self.override_acl({'can_hide_threads': 1})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -503,6 +506,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_protected(self):
         """api preserves protected status after merge"""
         response = self.client.post(
@@ -520,6 +524,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         merged_post = self.thread.post_set.order_by('-id')[0]
         self.assertTrue(merged_post.is_protected)
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_best_answer(self):
         """api merges best answer with other post"""
         best_answer = testutils.reply_thread(self.thread, poster="Bob")
@@ -539,9 +544,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.best_answer, best_answer)
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_best_answer_in(self):
         """api merges best answer into other post"""
         other_post = testutils.reply_thread(self.thread, poster="Bob")
@@ -562,9 +568,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.best_answer, other_post)
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_best_answer_in_protected(self):
         """api merges best answer into protected post"""
         best_answer = testutils.reply_thread(self.thread, poster="Bob")
@@ -584,11 +591,14 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.best_answer, best_answer)
+
+        self.thread.best_answer.refresh_from_db()
         self.assertTrue(self.thread.best_answer.is_protected)
         self.assertTrue(self.thread.best_answer_is_protected)
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_remove_reads(self):
         """two posts merge removes read tracker from post"""
         post_a = testutils.reply_thread(self.thread, poster=self.user, message="Battęry")

+ 50 - 94
misago/threads/tests/test_thread_postmove_api.py

@@ -2,12 +2,12 @@ import json
 
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.readtracker import poststracker
 from misago.threads import testutils
 from misago.threads.models import Thread
 from misago.threads.serializers.moderation import POSTS_LIMIT
+from misago.threads.test import patch_category_acl, patch_other_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -25,66 +25,14 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         )
 
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other category',
+            slug='other-category',
         ).insert_at(
             self.category,
             position='last-child',
             save=True,
         )
-        self.category_b = Category.objects.get(slug='category-b')
-
-        self.override_acl()
-        self.override_other_acl()
-
-    def refresh_thread(self):
-        self.thread = Thread.objects.get(pk=self.thread.pk)
-
-    def override_acl(self, extra_acl=None):
-        new_acl = self.user.acl_cache
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 1,
-            'can_reply_threads': 1,
-            'can_edit_posts': 1,
-            'can_approve_content': 0,
-            'can_move_posts': 1,
-        })
-
-        if extra_acl:
-            new_acl['categories'][self.category.pk].update(extra_acl)
-
-        override_acl(self.user, new_acl)
-
-    def override_other_acl(self, extra_acl=None):
-        other_category_acl = self.user.acl_cache['categories'][self.category.pk].copy()
-        other_category_acl.update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 0,
-            'can_reply_threads': 0,
-            'can_edit_posts': 1,
-            'can_approve_content': 0,
-            'can_move_posts': 1,
-        })
-
-        if extra_acl:
-            other_category_acl.update(extra_acl)
-
-        categories_acl = self.user.acl_cache['categories']
-        categories_acl[self.category_b.pk] = other_category_acl
-
-        visible_categories = [self.category.pk]
-        if other_category_acl['can_see']:
-            visible_categories.append(self.category_b.pk)
-
-        override_acl(
-            self.user, {
-                'visible_categories': visible_categories,
-                'categories': categories_acl,
-            }
-        )
+        self.other_category = Category.objects.get(slug='other-category')
 
     def test_anonymous_user(self):
         """you need to authenticate to move posts"""
@@ -96,46 +44,43 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "This action is not available to guests.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_data(self):
         """api handles post that is invalid type"""
-        self.override_acl()
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "detail": "Invalid data. Expected a dictionary, but got list.",
         })
 
-        self.override_acl()
         response = self.client.post(self.api_link, '123', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "detail": "Invalid data. Expected a dictionary, but got int.",
         })
 
-        self.override_acl()
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "detail": "Invalid data. Expected a dictionary, but got str.",
         })
 
-        self.override_acl()
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
         })
 
+    @patch_category_acl({"can_move_posts": False})
     def test_no_permission(self):
         """api validates permission to move"""
-        self.override_acl({'can_move_posts': 0})
-
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't move posts in this thread.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_move_no_new_thread_url(self):
         """api validates if new thread url was given"""
         response = self.client.post(self.api_link)
@@ -144,6 +89,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "Enter link to new thread.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_new_thread_url(self):
         """api validates new thread url"""
         response = self.client.post(self.api_link, {
@@ -154,6 +100,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "This is not a valid thread link.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_current_new_thread_url(self):
         """api validates if new thread url points to current thread"""
         response = self.client.post(
@@ -166,16 +113,14 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "Thread to move posts to is same as current one.",
         })
 
+    @patch_other_category_acl({"can_see": False})
+    @patch_category_acl({"can_move_posts": True})
     def test_other_thread_exists(self):
         """api validates if other thread exists"""
-        self.override_other_acl()
-
-        other_thread = testutils.post_thread(self.category_b)
-        other_new_thread = other_thread.get_absolute_url()
-        other_thread.delete()
+        other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(self.api_link, {
-            'new_thread': other_new_thread,
+            'new_thread': other_thread.get_absolute_url(),
         })
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
@@ -185,11 +130,11 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             ),
         })
 
+    @patch_other_category_acl({"can_browse": False})
+    @patch_category_acl({"can_move_posts": True})
     def test_other_thread_is_invisible(self):
         """api validates if other thread is visible"""
-        self.override_other_acl({'can_see': 0})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(
             self.api_link, {
@@ -204,11 +149,11 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             ),
         })
 
+    @patch_other_category_acl({"can_reply_threads": False})
+    @patch_category_acl({"can_move_posts": True})
     def test_other_thread_isnt_replyable(self):
         """api validates if other thread can be replied"""
-        self.override_other_acl({'can_reply_threads': 0})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(
             self.api_link, {
@@ -220,6 +165,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "You can't move posts to threads you can't reply.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_empty_data(self):
         """api handles empty data"""
         other_thread = testutils.post_thread(self.category)
@@ -230,6 +176,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "Enter link to new thread.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_empty_posts_data_json(self):
         """api handles empty json data"""
         other_thread = testutils.post_thread(self.category)
@@ -246,6 +193,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to specify at least one post to move.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_empty_posts_data_form(self):
         """api handles empty form data"""
         other_thread = testutils.post_thread(self.category)
@@ -261,6 +209,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to specify at least one post to move.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_no_posts_ids(self):
         """api rejects no posts ids"""
         other_thread = testutils.post_thread(self.category)
@@ -278,6 +227,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to specify at least one post to move.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_posts_data(self):
         """api handles invalid data"""
         other_thread = testutils.post_thread(self.category)
@@ -295,6 +245,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": 'Expected a list of items but got type "str".',
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
         other_thread = testutils.post_thread(self.category)
@@ -312,6 +263,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more post ids received were invalid.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_move_limit(self):
         """api rejects more posts than move limit"""
         other_thread = testutils.post_thread(self.category)
@@ -329,6 +281,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "No more than %s posts can be moved at single time." % POSTS_LIMIT,
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_move_invisible(self):
         """api validates posts visibility"""
         other_thread = testutils.post_thread(self.category)
@@ -346,6 +299,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more posts to move could not be found.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_move_other_thread_posts(self):
         """api recjects attempt to move other thread's post"""
         other_thread = testutils.post_thread(self.category)
@@ -363,6 +317,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more posts to move could not be found.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_move_event(self):
         """api rejects events move"""
         other_thread = testutils.post_thread(self.category)
@@ -380,6 +335,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "Events can't be moved.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_move_first_post(self):
         """api rejects first post move"""
         other_thread = testutils.post_thread(self.category)
@@ -397,6 +353,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "You can't move thread's first post.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_move_hidden_posts(self):
         """api recjects attempt to move urneadable hidden post"""
         other_thread = testutils.post_thread(self.category)
@@ -414,6 +371,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "You can't move posts the content you can't see.",
         })
 
+    @patch_category_acl({"can_move_posts": True, "can_close_threads": False})
     def test_move_posts_closed_thread_no_permission(self):
         """api recjects attempt to move posts from closed thread"""
         other_thread = testutils.post_thread(self.category)
@@ -421,8 +379,6 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        self.override_acl({'can_close_threads': 0})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -436,16 +392,15 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "This thread is closed. You can't move posts in it.",
         })
 
+    @patch_other_category_acl({"can_reply_threads": True, "can_close_threads": False})
+    @patch_category_acl({"can_move_posts": True})
     def test_move_posts_closed_category_no_permission(self):
         """api recjects attempt to move posts from closed thread"""
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         self.category.is_closed = True
         self.category.save()
 
-        self.override_acl({'can_close_threads': 0})
-        self.override_other_acl({'can_reply_threads': 1})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -459,11 +414,11 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "This category is closed. You can't move posts in it.",
         })
 
+    @patch_other_category_acl({"can_reply_threads": True})
+    @patch_category_acl({"can_move_posts": True})
     def test_move_posts(self):
         """api moves posts to other thread"""
-        self.override_other_acl({'can_reply_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         posts = (
             testutils.reply_thread(self.thread).pk,
@@ -472,7 +427,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             testutils.reply_thread(self.thread).pk,
         )
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 4)
 
         response = self.client.post(
@@ -486,25 +441,25 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
 
         # replies were moved
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 0)
 
         other_thread = Thread.objects.get(pk=other_thread.pk)
         self.assertEqual(other_thread.post_set.filter(pk__in=posts).count(), 4)
         self.assertEqual(other_thread.replies, 4)
 
+    @patch_other_category_acl({"can_reply_threads": True})
+    @patch_category_acl({"can_move_posts": True})
     def test_move_best_answer(self):
         """api moves best answer to other thread"""
-        self.override_other_acl({'can_reply_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
         best_answer = testutils.reply_thread(self.thread)
 
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.synchronize()
         self.thread.save()
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.best_answer, best_answer)
         self.assertEqual(self.thread.replies, 1)
 
@@ -519,7 +474,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
 
         # best_answer was moved and unmarked
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 0)
         self.assertIsNone(self.thread.best_answer)
 
@@ -527,18 +482,19 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(other_thread.replies, 1)
         self.assertIsNone(other_thread.best_answer)
 
+
+    @patch_other_category_acl({"can_reply_threads": True})
+    @patch_category_acl({"can_move_posts": True})
     def test_move_posts_reads(self):
         """api moves posts reads together with posts"""
-        self.override_other_acl({'can_reply_threads': 1})
-
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
         posts = (
             testutils.reply_thread(self.thread),
             testutils.reply_thread(self.thread),
         )
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 2)
 
         poststracker.save_read(self.user, self.thread.first_post)

+ 90 - 173
misago/threads/tests/test_thread_postpatch_api.py

@@ -4,10 +4,10 @@ from datetime import timedelta
 from django.urls import reverse
 from django.utils import timezone
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads.models import Thread, Post
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -30,27 +30,6 @@ class ThreadPostPatchApiTestCase(AuthenticatedUserTestCase):
     def patch(self, api_link, ops):
         return self.client.patch(api_link, json.dumps(ops), content_type="application/json")
 
-    def refresh_post(self):
-        self.post = self.thread.post_set.get(pk=self.post.pk)
-
-    def refresh_thread(self):
-        self.thread = Thread.objects.get(pk=self.thread.pk)
-
-    def override_acl(self, extra_acl=None):
-        new_acl = self.user.acl_cache
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 0,
-            'can_reply_threads': 0,
-            'can_edit_posts': 1,
-        })
-
-        if extra_acl:
-            new_acl['categories'][self.category.pk].update(extra_acl)
-
-        override_acl(self.user, new_acl)
-
 
 class PostAddAclApiTests(ThreadPostPatchApiTestCase):
     def test_add_acl_true(self):
@@ -83,10 +62,9 @@ class PostAddAclApiTests(ThreadPostPatchApiTestCase):
 
 
 class PostProtectApiTests(ThreadPostPatchApiTestCase):
+    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': True})
     def test_protect_post(self):
         """api makes it possible to protect post"""
-        self.override_acl({'can_protect_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -101,16 +79,15 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         self.assertTrue(reponse_json['is_protected'])
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_protected)
 
+    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': True})
     def test_unprotect_post(self):
         """api makes it possible to unprotect protected post"""
         self.post.is_protected = True
         self.post.save()
 
-        self.override_acl({'can_protect_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -125,17 +102,16 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         self.assertFalse(reponse_json['is_protected'])
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_protected)
 
+    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': True})
     def test_protect_best_answer(self):
         """api makes it possible to protect post"""
         self.thread.set_best_answer(self.user, self.post)
         self.thread.save()
 
         self.assertFalse(self.thread.best_answer_is_protected)
-        
-        self.override_acl({'can_protect_posts': 1})
 
         response = self.patch(
             self.api_link, [
@@ -151,12 +127,13 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         self.assertTrue(reponse_json['is_protected'])
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_protected)
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertTrue(self.thread.best_answer_is_protected)
 
+    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': True})
     def test_unprotect_best_answer(self):
         """api makes it possible to unprotect protected post"""
         self.post.is_protected = True
@@ -167,8 +144,6 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
 
         self.assertTrue(self.thread.best_answer_is_protected)
 
-        self.override_acl({'can_protect_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -183,16 +158,15 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         self.assertFalse(reponse_json['is_protected'])
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_protected)
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertFalse(self.thread.best_answer_is_protected)
 
+    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': False})
     def test_protect_post_no_permission(self):
         """api validates permission to protect post"""
-        self.override_acl({'can_protect_posts': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -207,16 +181,15 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't protect posts in this category.")
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_protected)
 
+    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': False})
     def test_unprotect_post_no_permission(self):
         """api validates permission to unprotect post"""
         self.post.is_protected = True
         self.post.save()
 
-        self.override_acl({'can_protect_posts': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -231,13 +204,12 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't protect posts in this category.")
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_protected)
 
+    @patch_category_acl({'can_edit_posts': 0, 'can_protect_posts': True})
     def test_protect_post_not_editable(self):
         """api validates if we can edit post we want to protect"""
-        self.override_acl({'can_edit_posts': 0, 'can_protect_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -252,16 +224,15 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't protect posts you can't edit.")
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_protected)
 
+    @patch_category_acl({'can_edit_posts': 0, 'can_protect_posts': True})
     def test_unprotect_post_not_editable(self):
         """api validates if we can edit post we want to protect"""
         self.post.is_protected = True
         self.post.save()
 
-        self.override_acl({'can_edit_posts': 0, 'can_protect_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -276,18 +247,17 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't protect posts you can't edit.")
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_protected)
 
 
 class PostApproveApiTests(ThreadPostPatchApiTestCase):
+    @patch_category_acl({'can_approve_content': True})
     def test_approve_post(self):
         """api makes it possible to approve post"""
         self.post.is_unapproved = True
         self.post.save()
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -302,13 +272,12 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         self.assertFalse(reponse_json['is_unapproved'])
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_unapproved)
 
+    @patch_category_acl({'can_approve_content': True})
     def test_unapprove_post(self):
         """unapproving posts is not supported by api"""
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -323,16 +292,15 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "Content approval can't be reversed.")
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_unapproved)
 
+    @patch_category_acl({'can_approve_content': False})
     def test_approve_post_no_permission(self):
         """api validates approval permission"""
         self.post.is_unapproved = True
         self.post.save()
 
-        self.override_acl({'can_approve_content': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -347,9 +315,10 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't approve posts in this category.")
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_unapproved)
 
+    @patch_category_acl({'can_approve_content': True, 'can_close_threads': False})
     def test_approve_post_closed_thread_no_permission(self):
         """api validates approval permission in closed threads"""
         self.post.is_unapproved = True
@@ -358,11 +327,6 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        self.override_acl({
-            'can_approve_content': 1,
-            'can_close_threads': 0,
-        })
-
         response = self.patch(
             self.api_link, [
                 {
@@ -380,9 +344,10 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
             "This thread is closed. You can't approve posts in it.",
         )
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_unapproved)
 
+    @patch_category_acl({'can_approve_content': True, 'can_close_threads': False})
     def test_approve_post_closed_category_no_permission(self):
         """api validates approval permission in closed categories"""
         self.post.is_unapproved = True
@@ -391,11 +356,6 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.category.is_closed = True
         self.category.save()
 
-        self.override_acl({
-            'can_approve_content': 1,
-            'can_close_threads': 0,
-        })
-
         response = self.patch(
             self.api_link, [
                 {
@@ -413,9 +373,10 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
             "This category is closed. You can't approve posts in it.",
         )
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_unapproved)
 
+    @patch_category_acl({'can_approve_content': True})
     def test_approve_first_post(self):
         """api approve first post fails"""
         self.post.is_unapproved = True
@@ -424,8 +385,6 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.thread.set_first_post(self.post)
         self.thread.save()
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -440,17 +399,16 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't approve thread's first post.")
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_unapproved)
 
+    @patch_category_acl({'can_approve_content': True})
     def test_approve_hidden_post(self):
         """api approve hidden post fails"""
         self.post.is_unapproved = True
         self.post.is_hidden = True
         self.post.save()
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -467,15 +425,14 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "You can't approve posts the content you can't see."
         )
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_unapproved)
 
 
 class PostHideApiTests(ThreadPostPatchApiTestCase):
+    @patch_category_acl({'can_hide_posts': 1})
     def test_hide_post(self):
         """api makes it possible to hide post"""
-        self.override_acl({'can_hide_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -490,13 +447,12 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         self.assertTrue(reponse_json['is_hidden'])
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
+    @patch_category_acl({'can_hide_posts': 1})
     def test_hide_own_post(self):
         """api makes it possible to hide owned post"""
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -511,13 +467,12 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         self.assertTrue(reponse_json['is_hidden'])
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
+    @patch_category_acl({'can_hide_posts': 0})
     def test_hide_post_no_permission(self):
         """api hide post with no permission fails"""
-        self.override_acl({'can_hide_posts': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -532,16 +487,15 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't hide posts in this category.")
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
+    @patch_category_acl({'can_hide_own_posts': 1, 'can_protect_posts': False})
     def test_hide_own_protected_post(self):
         """api validates if we are trying to hide protected post"""
         self.post.is_protected = True
         self.post.save()
 
-        self.override_acl({'can_protect_posts': 0, 'can_hide_own_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -556,16 +510,15 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "This post is protected. You can't hide it.")
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
+    @patch_category_acl({'can_hide_own_posts': True})
     def test_hide_other_user_post(self):
         """api validates post ownership when hiding"""
         self.post.poster = None
         self.post.save()
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -582,16 +535,15 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "You can't hide other users posts in this category."
         )
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
+    @patch_category_acl({'post_edit_time': 1, 'can_hide_own_posts': True})
     def test_hide_own_post_after_edit_time(self):
         """api validates if we are trying to hide post after edit time"""
         self.post.posted_on = timezone.now() - timedelta(minutes=10)
         self.post.save()
 
-        self.override_acl({'post_edit_time': 1, 'can_hide_own_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -608,16 +560,15 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "You can't hide posts that are older than 1 minute."
         )
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
+    @patch_category_acl({'can_close_threads': False, 'can_hide_own_posts': True})
     def test_hide_post_in_closed_thread(self):
         """api validates if we are trying to hide post in closed thread"""
         self.thread.is_closed = True
         self.thread.save()
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -634,16 +585,15 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "This thread is closed. You can't hide posts in it."
         )
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
+    @patch_category_acl({'can_close_threads': False, 'can_hide_own_posts': True})
     def test_hide_post_in_closed_category(self):
         """api validates if we are trying to hide post in closed category"""
         self.category.is_closed = True
         self.category.save()
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -660,16 +610,15 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "This category is closed. You can't hide posts in it."
         )
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
+    @patch_category_acl({'can_hide_posts': 1})
     def test_hide_first_post(self):
         """api hide first post fails"""
         self.thread.set_first_post(self.post)
         self.thread.save()
 
-        self.override_acl({'can_hide_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -684,13 +633,12 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't hide thread's first post.")
 
+    @patch_category_acl({'can_hide_posts': 1})
     def test_hide_best_answer(self):
         """api hide first post fails"""
         self.thread.set_best_answer(self.user, self.post)
         self.thread.save()
 
-        self.override_acl({'can_hide_posts': 2})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -708,16 +656,15 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
 
 
 class PostUnhideApiTests(ThreadPostPatchApiTestCase):
+    @patch_category_acl({'can_hide_posts': 1})
     def test_show_post(self):
         """api makes it possible to unhide post"""
         self.post.is_hidden = True
         self.post.save()
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
-        self.override_acl({'can_hide_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -732,19 +679,18 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         self.assertFalse(reponse_json['is_hidden'])
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
+    @patch_category_acl({'can_hide_own_posts': 1})
     def test_show_own_post(self):
         """api makes it possible to unhide owned post"""
         self.post.is_hidden = True
         self.post.save()
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -759,19 +705,18 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         self.assertFalse(reponse_json['is_hidden'])
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
+    @patch_category_acl({'can_hide_posts': 0})
     def test_show_post_no_permission(self):
         """api unhide post with no permission fails"""
         self.post.is_hidden = True
         self.post.save()
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
-        self.override_acl({'can_hide_posts': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -786,16 +731,15 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't reveal posts in this category.")
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
+    @patch_category_acl({'can_protect_posts': 0, 'can_hide_own_posts': 1})
     def test_show_own_protected_post(self):
         """api validates if we are trying to reveal protected post"""
         self.post.is_hidden = True
         self.post.save()
 
-        self.override_acl({'can_protect_posts': 0, 'can_hide_own_posts': 1})
-
         self.post.is_protected = True
         self.post.save()
 
@@ -815,17 +759,16 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "This post is protected. You can't reveal it."
         )
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
+    @patch_category_acl({'can_hide_own_posts': 1})
     def test_show_other_user_post(self):
         """api validates post ownership when revealing"""
         self.post.is_hidden = True
         self.post.poster = None
         self.post.save()
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -842,17 +785,16 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "You can't reveal other users posts in this category."
         )
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
+    @patch_category_acl({'post_edit_time': 1, 'can_hide_own_posts': 1})
     def test_show_own_post_after_edit_time(self):
         """api validates if we are trying to reveal post after edit time"""
         self.post.is_hidden = True
         self.post.posted_on = timezone.now() - timedelta(minutes=10)
         self.post.save()
 
-        self.override_acl({'post_edit_time': 1, 'can_hide_own_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -869,9 +811,10 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "You can't reveal posts that are older than 1 minute."
         )
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
+    @patch_category_acl({'can_close_threads': False, 'can_hide_own_posts': 1})
     def test_show_post_in_closed_thread(self):
         """api validates if we are trying to reveal post in closed thread"""
         self.thread.is_closed = True
@@ -880,8 +823,6 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         self.post.is_hidden = True
         self.post.save()
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -898,9 +839,10 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "This thread is closed. You can't reveal posts in it."
         )
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
+    @patch_category_acl({'can_close_threads': False, 'can_hide_own_posts': 1})
     def test_show_post_in_closed_category(self):
         """api validates if we are trying to reveal post in closed category"""
         self.category.is_closed = True
@@ -909,8 +851,6 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         self.post.is_hidden = True
         self.post.save()
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -927,16 +867,15 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "This category is closed. You can't reveal posts in it."
         )
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
+    @patch_category_acl({'can_hide_posts': 1})
     def test_show_first_post(self):
         """api unhide first post fails"""
         self.thread.set_first_post(self.post)
         self.thread.save()
 
-        self.override_acl({'can_hide_posts': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -953,10 +892,9 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
 
 
 class PostLikeApiTests(ThreadPostPatchApiTestCase):
+    @patch_category_acl({'can_see_posts_likes': 0})
     def test_like_no_see_permission(self):
         """api validates user's permission to see posts likes"""
-        self.override_acl({'can_see_posts_likes': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -972,10 +910,9 @@ class PostLikeApiTests(ThreadPostPatchApiTestCase):
             "detail": ["You can't like posts in this category."],
         })
 
+    @patch_category_acl({'can_like_posts': False})
     def test_like_no_like_permission(self):
         """api validates user's permission to see posts likes"""
-        self.override_acl({'can_like_posts': False})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1213,10 +1150,9 @@ class EventAddAclApiTests(ThreadEventPatchApiTestCase):
 
 
 class EventHideApiTests(ThreadEventPatchApiTestCase):
+    @patch_category_acl({'can_hide_events': 1})
     def test_hide_event(self):
         """api makes it possible to hide event"""
-        self.override_acl({'can_hide_events': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1228,19 +1164,18 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertTrue(self.event.is_hidden)
 
+    @patch_category_acl({'can_hide_events': 1})
     def test_show_event(self):
         """api makes it possible to unhide event"""
         self.event.is_hidden = True
         self.event.save()
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertTrue(self.event.is_hidden)
 
-        self.override_acl({'can_hide_events': 1})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1252,13 +1187,12 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertFalse(self.event.is_hidden)
 
+    @patch_category_acl({'can_hide_events': 0})
     def test_hide_event_no_permission(self):
         """api hide event with no permission fails"""
-        self.override_acl({'can_hide_events': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1275,16 +1209,12 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
             response_json['detail'][0], "You can't hide events in this category."
         )
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertFalse(self.event.is_hidden)
 
+    @patch_category_acl({'can_close_threads': False, 'can_hide_events': 1})
     def test_hide_event_closed_thread_no_permission(self):
         """api hide event in closed thread with no permission fails"""
-        self.override_acl({
-            'can_hide_events': 1,
-            'can_close_threads': 0,
-        })
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -1304,16 +1234,12 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
             response_json['detail'][0], "This thread is closed. You can't hide events in it."
         )
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertFalse(self.event.is_hidden)
 
+    @patch_category_acl({'can_close_threads': False, 'can_hide_events': 1})
     def test_hide_event_closed_category_no_permission(self):
         """api hide event in closed category with no permission fails"""
-        self.override_acl({
-            'can_hide_events': 1,
-            'can_close_threads': 0,
-        })
-
         self.category.is_closed = True
         self.category.save()
 
@@ -1333,19 +1259,18 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
             response_json['detail'][0], "This category is closed. You can't hide events in it."
         )
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertFalse(self.event.is_hidden)
 
+    @patch_category_acl({'can_hide_events': 0})
     def test_show_event_no_permission(self):
         """api unhide event with no permission fails"""
         self.event.is_hidden = True
         self.event.save()
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertTrue(self.event.is_hidden)
 
-        self.override_acl({'can_hide_events': 0})
-
         response = self.patch(
             self.api_link, [
                 {
@@ -1357,16 +1282,12 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         )
         self.assertEqual(response.status_code, 404)
 
+    @patch_category_acl({'can_close_threads': False, 'can_hide_events': 1})
     def test_show_event_closed_thread_no_permission(self):
         """api show event in closed thread with no permission fails"""
         self.event.is_hidden = True
         self.event.save()
 
-        self.override_acl({
-            'can_hide_events': 1,
-            'can_close_threads': 0,
-        })
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -1386,19 +1307,15 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
             response_json['detail'][0], "This thread is closed. You can't reveal events in it."
         )
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertTrue(self.event.is_hidden)
 
+    @patch_category_acl({'can_close_threads': False, 'can_hide_events': 1})
     def test_show_event_closed_category_no_permission(self):
         """api show event in closed category with no permission fails"""
         self.event.is_hidden = True
         self.event.save()
 
-        self.override_acl({
-            'can_hide_events': 1,
-            'can_close_threads': 0,
-        })
-
         self.category.is_closed = True
         self.category.save()
 
@@ -1418,5 +1335,5 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
             response_json['detail'][0], "This category is closed. You can't reveal events in it."
         )
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertTrue(self.event.is_hidden)

+ 52 - 94
misago/threads/tests/test_thread_postsplit_api.py

@@ -2,12 +2,12 @@ import json
 
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.readtracker import poststracker
 from misago.threads import testutils
 from misago.threads.models import Post, Thread
 from misago.threads.serializers.moderation import POSTS_LIMIT
+from misago.threads.test import patch_category_acl, patch_other_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -29,66 +29,14 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         )
 
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other category',
+            slug='other-category',
         ).insert_at(
             self.category,
             position='last-child',
             save=True,
         )
-        self.category_b = Category.objects.get(slug='category-b')
-
-        self.override_acl()
-        self.override_other_acl()
-
-    def refresh_thread(self):
-        self.thread = Thread.objects.get(pk=self.thread.pk)
-
-    def override_acl(self, extra_acl=None):
-        new_acl = self.user.acl_cache
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 1,
-            'can_reply_threads': 1,
-            'can_edit_posts': 1,
-            'can_approve_content': 0,
-            'can_move_posts': 1,
-        })
-
-        if extra_acl:
-            new_acl['categories'][self.category.pk].update(extra_acl)
-
-        override_acl(self.user, new_acl)
-
-    def override_other_acl(self, acl=None):
-        other_category_acl = self.user.acl_cache['categories'][self.category.pk].copy()
-        other_category_acl.update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 0,
-            'can_reply_threads': 0,
-            'can_edit_posts': 1,
-            'can_approve_content': 0,
-            'can_move_posts': 1,
-        })
-
-        if acl:
-            other_category_acl.update(acl)
-
-        categories_acl = self.user.acl_cache['categories']
-        categories_acl[self.category_b.pk] = other_category_acl
-
-        visible_categories = [self.category.pk]
-        if other_category_acl['can_see']:
-            visible_categories.append(self.category_b.pk)
-
-        override_acl(
-            self.user, {
-                'visible_categories': visible_categories,
-                'categories': categories_acl,
-            }
-        )
+        self.other_category = Category.objects.get(slug='other-category')
 
     def test_anonymous_user(self):
         """you need to authenticate to split posts"""
@@ -100,16 +48,16 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "This action is not available to guests.",
         })
 
+    @patch_category_acl({"can_move_posts": False})
     def test_no_permission(self):
         """api validates permission to split"""
-        self.override_acl({'can_move_posts': 0})
-
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't split posts from this thread.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_empty_data(self):
         """api handles empty data"""
         response = self.client.post(self.api_link)
@@ -118,36 +66,34 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to specify at least one post to split.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_data(self):
         """api handles post that is invalid type"""
-        self.override_acl()
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "non_field_errors": ["Invalid data. Expected a dictionary, but got list."],
         })
 
-        self.override_acl()
         response = self.client.post(self.api_link, '123', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "non_field_errors": ["Invalid data. Expected a dictionary, but got int."],
         })
 
-        self.override_acl()
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "non_field_errors": ["Invalid data. Expected a dictionary, but got str."],
         })
 
-        self.override_acl()
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_no_posts_ids(self):
         """api rejects no posts ids"""
         response = self.client.post(
@@ -159,6 +105,8 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(response.json(), {
             "detail": "You have to specify at least one post to split.",
         })
+
+    @patch_category_acl({"can_move_posts": True})
     def test_empty_posts_ids(self):
         """api rejects empty posts ids list"""
         response = self.client.post(
@@ -173,6 +121,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to specify at least one post to split.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_posts_data(self):
         """api handles invalid data"""
         response = self.client.post(
@@ -187,6 +136,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": 'Expected a list of items but got type "str".',
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
         response = self.client.post(
@@ -201,6 +151,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more post ids received were invalid.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_limit(self):
         """api rejects more posts than split limit"""
         response = self.client.post(
@@ -215,6 +166,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "No more than %s posts can be split at single time." % POSTS_LIMIT,
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_invisible(self):
         """api validates posts visibility"""
         response = self.client.post(
@@ -229,6 +181,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more posts to split could not be found.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_event(self):
         """api rejects events split"""
         response = self.client.post(
@@ -243,6 +196,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "Events can't be split.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_first_post(self):
         """api rejects first post split"""
         response = self.client.post(
@@ -257,6 +211,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "You can't split thread's first post.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_hidden_posts(self):
         """api recjects attempt to split urneadable hidden post"""
         response = self.client.post(
@@ -271,13 +226,12 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "You can't split posts the content you can't see.",
         })
 
+    @patch_category_acl({"can_move_posts": True, "can_close_threads": False})
     def test_split_posts_closed_thread_no_permission(self):
         """api recjects attempt to split posts from closed thread"""
         self.thread.is_closed = True
         self.thread.save()
 
-        self.override_acl({'can_close_threads': 0})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -290,13 +244,12 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "This thread is closed. You can't split posts in it.",
         })
 
+    @patch_category_acl({"can_move_posts": True, "can_close_threads": False})
     def test_split_posts_closed_category_no_permission(self):
         """api recjects attempt to split posts from closed thread"""
         self.category.is_closed = True
         self.category.save()
 
-        self.override_acl({'can_close_threads': 0})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -309,6 +262,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "This category is closed. You can't split posts in it.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_other_thread_posts(self):
         """api recjects attempt to split other thread's post"""
         other_thread = testutils.post_thread(self.category)
@@ -325,6 +279,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more posts to split could not be found.",
         })
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_empty_new_thread_data(self):
         """api handles empty form data"""
         response = self.client.post(
@@ -344,6 +299,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_invalid_final_title(self):
         """api rejects split because final thread title was invalid"""
         response = self.client.post(
@@ -364,16 +320,16 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_other_category_acl({"can_see": False})
+    @patch_category_acl({"can_move_posts": True})
     def test_split_invalid_category(self):
         """api rejects split because final category was invalid"""
-        self.override_other_acl({'can_see': 0})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
                 'posts': self.posts,
                 'title': 'Valid thread title',
-                'category': self.category_b.id,
+                'category': self.other_category.id,
             }),
             content_type="application/json",
         )
@@ -386,10 +342,9 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_move_posts": True, "can_start_threads": False})
     def test_split_unallowed_start_thread(self):
         """api rejects split because category isn't allowing starting threads"""
-        self.override_acl({'can_start_threads': 0})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -408,6 +363,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_invalid_weight(self):
         """api rejects split because final weight was invalid"""
         response = self.client.post(
@@ -429,6 +385,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_unallowed_global_weight(self):
         """api rejects split because global weight was unallowed"""
         response = self.client.post(
@@ -450,6 +407,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_move_posts": True, "can_pin_threads": 0})
     def test_split_unallowed_local_weight(self):
         """api rejects split because local weight was unallowed"""
         response = self.client.post(
@@ -471,10 +429,9 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_move_posts": True, "can_pin_threads": 1})
     def test_split_allowed_local_weight(self):
         """api allows local weight"""
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -494,10 +451,9 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_move_posts": True, "can_pin_threads": 2})
     def test_split_allowed_global_weight(self):
         """api allows global weight"""
-        self.override_acl({'can_pin_threads': 2})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -517,6 +473,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_move_posts": True, "can_close_threads": False})
     def test_split_unallowed_close(self):
         """api rejects split because closing thread was unallowed"""
         response = self.client.post(
@@ -538,10 +495,9 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_move_posts": True, "can_close_threads": True})
     def test_split_with_close(self):
         """api allows for closing thread"""
-        self.override_acl({'can_close_threads': True})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -562,6 +518,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_move_posts": True, "can_hide_threads": 0})
     def test_split_unallowed_hidden(self):
         """api rejects split because hidden thread was unallowed"""
         response = self.client.post(
@@ -583,10 +540,9 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_move_posts": True, "can_hide_threads": 1})
     def test_split_with_hide(self):
         """api allows for hiding thread"""
-        self.override_acl({'can_hide_threads': True})
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -607,9 +563,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split(self):
         """api splits posts to new thread"""
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 2)
 
         response = self.client.post(
@@ -628,12 +585,13 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(split_thread.replies, 1)
 
         # posts were removed from old thread
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 0)
 
         # posts were moved to new thread
         self.assertEqual(split_thread.post_set.filter(pk__in=self.posts).count(), 2)
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_best_answer(self):
         """api splits best answer to new thread"""
         best_answer = testutils.reply_thread(self.thread)
@@ -642,7 +600,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.thread.synchronize()
         self.thread.save()
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.best_answer, best_answer)
         self.assertEqual(self.thread.replies, 3)
 
@@ -658,7 +616,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
 
         # best_answer was moved and unmarked
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 2)
         self.assertIsNone(self.thread.best_answer)
 
@@ -666,18 +624,18 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(split_thread.replies, 0)
         self.assertIsNone(split_thread.best_answer)
 
+    @patch_other_category_acl({
+        'can_start_threads': True,
+        'can_close_threads': True,
+        'can_hide_threads': True,
+        'can_pin_threads': 2,
+    })
+    @patch_category_acl({"can_move_posts": True})
     def test_split_kitchensink(self):
         """api splits posts with kitchensink"""
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 2)
 
-        self.override_other_acl({
-            'can_start_threads': 2,
-            'can_close_threads': True,
-            'can_hide_threads': True,
-            'can_pin_threads': 2,
-        })
-
         poststracker.save_read(self.user, self.thread.first_post)
         for post in self.posts:
             poststracker.save_read(self.user, Post.objects.select_related().get(pk=post))
@@ -687,7 +645,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             json.dumps({
                 'posts': self.posts,
                 'title': 'Split thread',
-                'category': self.category_b.id,
+                'category': self.other_category.id,
                 'weight': 2,
                 'is_closed': 1,
                 'is_hidden': 1,
@@ -697,14 +655,14 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
 
         # thread was created
-        split_thread = self.category_b.thread_set.get(slug='split-thread')
+        split_thread = self.other_category.thread_set.get(slug='split-thread')
         self.assertEqual(split_thread.replies, 1)
         self.assertEqual(split_thread.weight, 2)
         self.assertTrue(split_thread.is_closed)
         self.assertTrue(split_thread.is_hidden)
 
         # posts were removed from old thread
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 0)
 
         # posts were moved to new thread

+ 42 - 59
misago/threads/tests/test_thread_reply_api.py

@@ -1,9 +1,10 @@
 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.threads import testutils
 from misago.threads.models import Thread
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -20,20 +21,6 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
             }
         )
 
-    def override_acl(self, extra_acl=None):
-        new_acl = self.user.acl_cache
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 0,
-            'can_reply_threads': 1,
-        })
-
-        if extra_acl:
-            new_acl['categories'][self.category.pk].update(extra_acl)
-
-        override_acl(self.user, new_acl)
-
     def test_cant_reply_thread_as_guest(self):
         """user has to be authenticated to be able to post reply"""
         self.logout_user()
@@ -43,32 +30,30 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
 
     def test_thread_visibility(self):
         """thread's visibility is validated"""
-        self.override_acl({'can_see': 0})
-        response = self.client.post(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_see': 0}):
+            response = self.client.post(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
-        self.override_acl({'can_browse': 0})
-        response = self.client.post(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_browse': 0}):
+            response = self.client.post(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
-        self.override_acl({'can_see_all_threads': 0})
-        response = self.client.post(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_see_all_threads': 0}):
+            response = self.client.post(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
+    @patch_category_acl({"can_reply_threads": False})
     def test_cant_reply_thread(self):
         """permission to reply thread is validated"""
-        self.override_acl({'can_reply_threads': 0})
-
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't reply to threads in this category.",
         })
 
-    def test_closed_category(self):
+    @patch_category_acl({"can_reply_threads": True, "can_close_threads": False})
+    def test_closed_category_no_permission(self):
         """permssion to reply in closed category is validated"""
-        self.override_acl({'can_close_threads': 0})
-
         self.category.is_closed = True
         self.category.save()
 
@@ -78,16 +63,18 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
             "detail": "This category is closed. You can't reply to threads in it.",
         })
 
-        # allow to post in closed category
-        self.override_acl({'can_close_threads': 1})
+    @patch_category_acl({"can_reply_threads": True, "can_close_threads": True})
+    def test_closed_category(self):
+        """permssion to reply in closed category is validated"""
+        self.category.is_closed = True
+        self.category.save()
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
 
-    def test_closed_thread(self):
+    @patch_category_acl({"can_reply_threads": True, "can_close_threads": False})
+    def test_closed_thread_no_permission(self):
         """permssion to reply in closed thread is validated"""
-        self.override_acl({'can_close_threads': 0})
-
         self.thread.is_closed = True
         self.thread.save()
 
@@ -97,26 +84,27 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
             "detail": "You can't reply to closed threads in this category.",
         })
 
-        # allow to post in closed thread
-        self.override_acl({'can_close_threads': 1})
+    @patch_category_acl({"can_reply_threads": True, "can_close_threads": True})
+    def test_closed_thread(self):
+        """permssion to reply in closed thread is validated"""
+        self.thread.is_closed = True
+        self.thread.save()
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_empty_data(self):
         """no data sent handling has no showstoppers"""
-        self.override_acl()
-
         response = self.client.post(self.api_link, data={})
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
             "post": ["You have to enter a message."],
         })
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_invalid_data(self):
         """api errors for invalid request data"""
-        self.override_acl()
-
         response = self.client.post(
             self.api_link,
             'false',
@@ -127,10 +115,9 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
             'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
         })
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_post_is_validated(self):
         """post is validated"""
-        self.override_acl()
-
         response = self.client.post(
             self.api_link, data={
                 'post': "a",
@@ -144,9 +131,9 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_can_reply_thread(self):
         """endpoint creates new reply"""
-        self.override_acl()
         response = self.client.post(
             self.api_link, data={
                 'post': "This is test response!",
@@ -156,7 +143,6 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
 
         thread = Thread.objects.get(pk=self.thread.pk)
 
-        self.override_acl()
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, "<p>This is test response!</p>")
 
@@ -187,10 +173,9 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.last_poster_name, self.user.username)
         self.assertEqual(category.last_poster_slug, self.user.slug)
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_post_unicode(self):
         """unicode characters can be posted"""
-        self.override_acl()
-
         response = self.client.post(
             self.api_link, data={
                 'post': "Chrzążczyżewoszyce, powiat Łękółody.",
@@ -198,6 +183,7 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_category_moderation_queue(self):
         """reply thread in category that requires approval"""
         self.category.require_replies_approval = True
@@ -222,10 +208,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts)
 
+    @patch_category_acl({"can_reply_threads": True})
+    @patch_user_acl({"can_approve_content": True})
     def test_category_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
-        override_acl(self.user, {'can_approve_content': 1})
-
         self.category.require_replies_approval = True
         self.category.save()
 
@@ -248,10 +234,9 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts + 1)
 
+    @patch_category_acl({"can_reply_threads": True, "require_replies_approval": True})
     def test_user_moderation_queue(self):
         """reply thread by user that requires approval"""
-        self.override_acl({'require_replies_approval': 1})
-
         response = self.client.post(
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
@@ -271,12 +256,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts)
 
+    @patch_category_acl({"can_reply_threads": True, "require_replies_approval": True})
+    @patch_user_acl({"can_approve_content": True})
     def test_user_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
-        override_acl(self.user, {'can_approve_content': 1})
-
-        self.override_acl({'require_replies_approval': 1})
-
         response = self.client.post(
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
@@ -296,17 +279,17 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts + 1)
 
+    @patch_category_acl({
+        "can_reply_threads": True,
+        "require_threads_approval": True,
+        "require_edits_approval": True,
+    })
     def test_omit_other_moderation_queues(self):
         """other queues are omitted"""
         self.category.require_threads_approval = True
         self.category.require_edits_approval = True
         self.category.save()
 
-        self.override_acl({
-            'require_threads_approval': 1,
-            'require_edits_approval': 1,
-        })
-
         response = self.client.post(
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",

+ 32 - 81
misago/threads/tests/test_thread_start_api.py

@@ -1,7 +1,8 @@
 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.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -12,30 +13,6 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.category = Category.objects.get(slug='first-category')
         self.api_link = reverse('misago:api:thread-list')
 
-    def override_acl(self, extra_acl=None):
-        new_acl = self.user.acl_cache
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 1,
-            'can_pin_threads': 0,
-            'can_close_threads': 0,
-            'can_hide_threads': 0,
-            'can_hide_own_threads': 0,
-        })
-
-        if extra_acl:
-            new_acl['categories'][self.category.pk].update(extra_acl)
-
-            if 'can_see' in extra_acl and not extra_acl['can_see']:
-                new_acl['visible_categories'].remove(self.category.pk)
-                new_acl['browseable_categories'].remove(self.category.pk)
-
-            if 'can_browse' in extra_acl and not extra_acl['can_browse']:
-                new_acl['browseable_categories'].remove(self.category.pk)
-
-        override_acl(self.user, new_acl)
-
     def test_cant_start_thread_as_guest(self):
         """user has to be authenticated to be able to post thread"""
         self.logout_user()
@@ -43,10 +20,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
 
+    @patch_category_acl({"can_see": False})
     def test_cant_see(self):
         """has no permission to see selected category"""
-        self.override_acl({'can_see': 0})
-
         response = self.client.post(self.api_link, {
             'category': self.category.pk,
         })
@@ -57,10 +33,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'title': ['You have to enter thread title.'],
         })
 
+    @patch_category_acl({"can_browse": False})
     def test_cant_browse(self):
         """has no permission to browse selected category"""
-        self.override_acl({'can_browse': 0})
-
         response = self.client.post(self.api_link, {
             'category': self.category.pk,
         })
@@ -71,10 +46,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'title': ['You have to enter thread title.'],
         })
 
+    @patch_category_acl({"can_start_threads": False})
     def test_cant_start_thread(self):
         """permission to start thread in category is validated"""
-        self.override_acl({'can_start_threads': 0})
-
         response = self.client.post(self.api_link, {
             'category': self.category.pk,
         })
@@ -85,13 +59,12 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'title': ['You have to enter thread title.'],
         })
 
+    @patch_category_acl({"can_start_threads": True, "can_close_threads": False})
     def test_cant_start_thread_in_locked_category(self):
         """can't post in closed category"""
         self.category.is_closed = True
         self.category.save()
 
-        self.override_acl({'can_close_threads': 0})
-
         response = self.client.post(self.api_link, {
             'category': self.category.pk,
         })
@@ -104,11 +77,6 @@ class StartThreadTests(AuthenticatedUserTestCase):
 
     def test_cant_start_thread_in_invalid_category(self):
         """can't post in invalid category"""
-        self.category.is_closed = True
-        self.category.save()
-
-        self.override_acl({'can_close_threads': 0})
-
         response = self.client.post(self.api_link, {'category': self.category.pk * 100000})
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
@@ -120,10 +88,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'title': ['You have to enter thread title.'],
         })
 
+    @patch_category_acl({"can_start_threads": True})
     def test_empty_data(self):
         """no data sent handling has no showstoppers"""
-        self.override_acl()
-
         response = self.client.post(self.api_link, data={})
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
@@ -134,10 +101,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_start_threads": True})
     def test_invalid_data(self):
         """api errors for invalid request data"""
-        self.override_acl()
-
         response = self.client.post(
             self.api_link,
             'false',
@@ -148,10 +114,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
         })
 
+    @patch_category_acl({"can_start_threads": True})
     def test_title_is_validated(self):
         """title is validated"""
-        self.override_acl()
-
         response = self.client.post(
             self.api_link,
             data={
@@ -168,10 +133,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_start_threads": True})
     def test_post_is_validated(self):
         """post is validated"""
-        self.override_acl()
-
         response = self.client.post(
             self.api_link,
             data={
@@ -188,9 +152,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             }
         )
 
+    @patch_category_acl({"can_start_threads": True})
     def test_can_start_thread(self):
         """endpoint creates new thread"""
-        self.override_acl()
         response = self.client.post(
             self.api_link,
             data={
@@ -206,7 +170,6 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response_json = response.json()
         self.assertEqual(response_json['url'], thread.get_absolute_url())
 
-        self.override_acl()
         response = self.client.get(thread.get_absolute_url())
         self.assertContains(response, self.category.name)
         self.assertContains(response, thread.title)
@@ -245,10 +208,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.last_poster_name, self.user.username)
         self.assertEqual(category.last_poster_slug, self.user.slug)
 
+    @patch_category_acl({"can_start_threads": True, "can_close_threads": False})
     def test_start_closed_thread_no_permission(self):
         """permission is checked before thread is closed"""
-        self.override_acl({'can_close_threads': 0})
-
         response = self.client.post(
             self.api_link,
             data={
@@ -263,10 +225,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         self.assertFalse(thread.is_closed)
 
+    @patch_category_acl({"can_start_threads": True, "can_close_threads": True})
     def test_start_closed_thread(self):
         """can post closed thread"""
-        self.override_acl({'can_close_threads': 1})
-
         response = self.client.post(
             self.api_link,
             data={
@@ -281,10 +242,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         self.assertTrue(thread.is_closed)
 
+    @patch_category_acl({"can_start_threads": True, "can_pin_threads": 1})
     def test_start_unpinned_thread(self):
         """can post unpinned thread"""
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.client.post(
             self.api_link,
             data={
@@ -299,10 +259,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         self.assertEqual(thread.weight, 0)
 
+    @patch_category_acl({"can_start_threads": True, "can_pin_threads": 1})
     def test_start_locally_pinned_thread(self):
         """can post locally pinned thread"""
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.client.post(
             self.api_link,
             data={
@@ -317,10 +276,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         self.assertEqual(thread.weight, 1)
 
+    @patch_category_acl({"can_start_threads": True, "can_pin_threads": 2})
     def test_start_globally_pinned_thread(self):
         """can post globally pinned thread"""
-        self.override_acl({'can_pin_threads': 2})
-
         response = self.client.post(
             self.api_link,
             data={
@@ -335,10 +293,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         self.assertEqual(thread.weight, 2)
 
+    @patch_category_acl({"can_start_threads": True, "can_pin_threads": 1})
     def test_start_globally_pinned_thread_no_permission(self):
         """cant post globally pinned thread without permission"""
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.client.post(
             self.api_link,
             data={
@@ -353,10 +310,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         self.assertEqual(thread.weight, 0)
 
+    @patch_category_acl({"can_start_threads": True, "can_pin_threads": 0})
     def test_start_locally_pinned_thread_no_permission(self):
         """cant post locally pinned thread without permission"""
-        self.override_acl({'can_pin_threads': 0})
-
         response = self.client.post(
             self.api_link,
             data={
@@ -371,10 +327,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         self.assertEqual(thread.weight, 0)
 
+    @patch_category_acl({"can_start_threads": True, "can_hide_threads": 1})
     def test_start_hidden_thread(self):
         """can post hidden thread"""
-        self.override_acl({'can_hide_threads': 1})
-
         response = self.client.post(
             self.api_link,
             data={
@@ -392,10 +347,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         category = Category.objects.get(pk=self.category.pk)
         self.assertNotEqual(category.last_thread_id, thread.id)
 
+    @patch_category_acl({"can_start_threads": True, "can_hide_threads": 0})
     def test_start_hidden_thread_no_permission(self):
         """cant post hidden thread without permission"""
-        self.override_acl({'can_hide_threads': 0})
-
         response = self.client.post(
             self.api_link,
             data={
@@ -410,10 +364,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         self.assertFalse(thread.is_hidden)
 
+    @patch_category_acl({"can_start_threads": True})
     def test_post_unicode(self):
         """unicode characters can be posted"""
-        self.override_acl()
-
         response = self.client.post(
             self.api_link,
             data={
@@ -424,6 +377,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({"can_start_threads": True})
     def test_category_moderation_queue(self):
         """start unapproved thread in category that requires approval"""
         self.category.require_threads_approval = True
@@ -451,10 +405,10 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.posts, self.category.posts)
         self.assertFalse(category.last_thread_id == thread.id)
 
+    @patch_category_acl({"can_start_threads": True})
+    @patch_user_acl({"can_approve_content": True})
     def test_category_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
-        override_acl(self.user, {'can_approve_content': 1})
-
         self.category.require_threads_approval = True
         self.category.save()
 
@@ -480,10 +434,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.posts, self.category.posts + 1)
         self.assertEqual(category.last_thread_id, thread.id)
 
+    @patch_category_acl({"can_start_threads": True, "require_threads_approval": True})
     def test_user_moderation_queue(self):
         """start unapproved thread in category that requires approval"""
-        self.override_acl({'require_threads_approval': 1})
-
         response = self.client.post(
             self.api_link,
             data={
@@ -506,12 +459,10 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.posts, self.category.posts)
         self.assertFalse(category.last_thread_id == thread.id)
 
+    @patch_category_acl({"can_start_threads": True, "require_threads_approval": True})
+    @patch_user_acl({"can_approve_content": True})
     def test_user_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
-        override_acl(self.user, {'can_approve_content': 1})
-
-        self.override_acl({'require_threads_approval': 1})
-
         response = self.client.post(
             self.api_link,
             data={
@@ -534,17 +485,17 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.posts, self.category.posts + 1)
         self.assertEqual(category.last_thread_id, thread.id)
 
+    @patch_category_acl({
+        "can_start_threads": True,
+        "require_replies_approval": True,
+        "require_edits_approval": True,
+    })
     def test_omit_other_moderation_queues(self):
         """other queues are omitted"""
         self.category.require_replies_approval = True
         self.category.require_edits_approval = True
         self.category.save()
 
-        self.override_acl({
-            'require_replies_approval': 1,
-            'require_edits_approval': 1,
-        })
-
         response = self.client.post(
             self.api_link,
             data={

+ 71 - 140
misago/threads/tests/test_threads_api.py

@@ -3,11 +3,11 @@ from datetime import timedelta
 from django.utils import timezone
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads.models import Thread
+from misago.threads.test import patch_category_acl
 from misago.threads.threadtypes import trees_map
 from misago.users.testutils import AuthenticatedUserTestCase
 
@@ -24,45 +24,6 @@ class ThreadsApiTestCase(AuthenticatedUserTestCase):
         self.thread = testutils.post_thread(category=self.category)
         self.api_link = self.thread.get_api_url()
 
-    def override_acl(self, acl=None):
-        final_acl = self.user.acl_cache['categories'][self.category.pk]
-        final_acl.update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_see_all_threads': 1,
-            'can_see_own_threads': 0,
-            'can_hide_threads': 0,
-            'can_approve_content': 0,
-            'can_edit_posts': 0,
-            'can_hide_posts': 0,
-            'can_hide_own_posts': 0,
-            'can_merge_threads': 0,
-            'can_close_threads': 0,
-        })
-
-        if acl:
-            final_acl.update(acl)
-
-        visible_categories = self.user.acl_cache['visible_categories']
-        browseable_categories = self.user.acl_cache['browseable_categories']
-
-        if not final_acl['can_see'] and self.category.pk in visible_categories:
-            visible_categories.remove(self.category.pk)
-            browseable_categories.remove(self.category.pk)
-
-        if not final_acl['can_browse'] and self.category.pk in browseable_categories:
-            browseable_categories.remove(self.category.pk)
-
-        override_acl(
-            self.user, {
-                'visible_categories': visible_categories,
-                'browseable_categories': browseable_categories,
-                'categories': {
-                    self.category.pk: final_acl,
-                },
-            }
-        )
-
     def get_thread_json(self):
         response = self.client.get(self.thread.get_api_url())
         self.assertEqual(response.status_code, 200)
@@ -80,11 +41,10 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
             '%sposts/?page=1' % self.api_link,
         ]
 
+    @patch_category_acl()
     def test_api_returns_thread(self):
         """api has no showstoppers"""
         for link in self.tested_links:
-            self.override_acl()
-
             response = self.client.get(link)
             self.assertEqual(response.status_code, 200)
 
@@ -95,11 +55,10 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
             if 'posts' in link:
                 self.assertIn('post_set', response_json)
 
+    @patch_category_acl({"can_see_all_threads": False})
     def test_api_shows_owned_thread(self):
         """api handles "owned threads only"""
         for link in self.tested_links:
-            self.override_acl({'can_see_all_threads': 0})
-
             response = self.client.get(link)
             self.assertEqual(response.status_code, 404)
 
@@ -107,49 +66,41 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
         self.thread.save()
 
         for link in self.tested_links:
-            self.override_acl({'can_see_all_threads': 0})
-
             response = self.client.get(link)
             self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({"can_see": False})
     def test_api_validates_category_see_permission(self):
         """api validates category visiblity"""
         for link in self.tested_links:
-            self.override_acl({'can_see': 0})
-
             response = self.client.get(link)
             self.assertEqual(response.status_code, 404)
 
+    @patch_category_acl({"can_browse": False})
     def test_api_validates_category_browse_permission(self):
         """api validates category browsability"""
         for link in self.tested_links:
-            self.override_acl({'can_browse': 0})
-
             response = self.client.get(link)
             self.assertEqual(response.status_code, 404)
 
     def test_api_validates_posts_visibility(self):
         """api validates posts visiblity"""
-        self.override_acl({'can_hide_posts': 0})
-
         hidden_post = testutils.reply_thread(
             self.thread,
             is_hidden=True,
             message="I'am hidden test message!",
         )
 
-        response = self.client.get(self.tested_links[1])
-        self.assertNotContains(response, hidden_post.parsed)  # post's body is hidden
+        with patch_category_acl({"can_hide_posts": 0}):
+            response = self.client.get(self.tested_links[1])
+            self.assertNotContains(response, hidden_post.parsed)  # post's body is hidden
 
         # add permission to see hidden posts
-        self.override_acl({'can_hide_posts': 1})
-
-        response = self.client.get(self.tested_links[1])
-        self.assertContains(
-            response, hidden_post.parsed
-        )  # hidden post's body is visible with permission
-
-        self.override_acl({'can_approve_content': 0})
+        with patch_category_acl({"can_hide_posts": 1}):
+            response = self.client.get(self.tested_links[1])
+            self.assertContains(
+                response, hidden_post.parsed
+            )  # hidden post's body is visible with permission
 
         # unapproved posts shouldn't show at all
         unapproved_post = testutils.reply_thread(
@@ -157,41 +108,39 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
             is_unapproved=True,
         )
 
-        response = self.client.get(self.tested_links[1])
-        self.assertNotContains(response, unapproved_post.get_absolute_url())
+        with patch_category_acl({"can_approve_content": False}):
+            response = self.client.get(self.tested_links[1])
+            self.assertNotContains(response, unapproved_post.get_absolute_url())
 
         # add permission to see unapproved posts
-        self.override_acl({'can_approve_content': 1})
-
-        response = self.client.get(self.tested_links[1])
-        self.assertContains(response, unapproved_post.get_absolute_url())
+        with patch_category_acl({"can_approve_content": True}):
+            response = self.client.get(self.tested_links[1])
+            self.assertContains(response, unapproved_post.get_absolute_url())
 
     def test_api_validates_has_unapproved_posts_visibility(self):
         """api checks acl before exposing unapproved flag"""
         self.thread.has_unapproved_posts = True
         self.thread.save()
 
-        for link in self.tested_links:
-            self.override_acl()
+        with patch_category_acl({"can_approve_content": False}):
+            for link in self.tested_links:
+                response = self.client.get(link)
+                self.assertEqual(response.status_code, 200)
 
-            response = self.client.get(link)
-            self.assertEqual(response.status_code, 200)
-
-            response_json = response.json()
-            self.assertEqual(response_json['id'], self.thread.pk)
-            self.assertEqual(response_json['title'], self.thread.title)
-            self.assertFalse(response_json['has_unapproved_posts'])
-
-        for link in self.tested_links:
-            self.override_acl({'can_approve_content': 1})
+                response_json = response.json()
+                self.assertEqual(response_json['id'], self.thread.pk)
+                self.assertEqual(response_json['title'], self.thread.title)
+                self.assertFalse(response_json['has_unapproved_posts'])
 
-            response = self.client.get(link)
-            self.assertEqual(response.status_code, 200)
+        with patch_category_acl({"can_approve_content": True}):
+            for link in self.tested_links:
+                response = self.client.get(link)
+                self.assertEqual(response.status_code, 200)
 
-            response_json = response.json()
-            self.assertEqual(response_json['id'], self.thread.pk)
-            self.assertEqual(response_json['title'], self.thread.title)
-            self.assertTrue(response_json['has_unapproved_posts'])
+                response_json = response.json()
+                self.assertEqual(response_json['id'], self.thread.pk)
+                self.assertEqual(response_json['title'], self.thread.title)
+                self.assertTrue(response_json['has_unapproved_posts'])
 
 
 class ThreadDeleteApiTests(ThreadsApiTestCase):
@@ -203,82 +152,68 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
 
     def test_delete_thread_no_permission(self):
         """api tests permission to delete threads"""
-        self.override_acl({'can_hide_threads': 0})
-
-        response = self.client.delete(self.api_link)
-        self.assertEqual(response.status_code, 403)
-
-        self.assertEqual(
-            response.json()['detail'], "You can't delete threads in this category."
-        )
-
-        self.override_acl({'can_hide_threads': 1})
-
-        response = self.client.delete(self.api_link)
-        self.assertEqual(response.status_code, 403)
-
-        self.assertEqual(
-            response.json()['detail'], "You can't delete threads in this category."
-        )
-
+        with patch_category_acl({"can_hide_threads": 0}):
+            response = self.client.delete(self.api_link)
+            self.assertEqual(response.status_code, 403)
+            self.assertEqual(
+                response.json()['detail'], "You can't delete threads in this category."
+            )
+
+        with patch_category_acl({"can_hide_threads": 1}):
+            response = self.client.delete(self.api_link)
+            self.assertEqual(response.status_code, 403)
+            self.assertEqual(
+                response.json()['detail'], "You can't delete threads in this category."
+            )
+
+    @patch_category_acl({'can_hide_threads': 1, 'can_hide_own_threads': 2})
     def test_delete_other_user_thread_no_permission(self):
         """api tests thread owner when deleting own thread"""
-        self.override_acl({
-            'can_hide_threads': 1,
-            'can_hide_own_threads': 2,
-        })
-
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-
         self.assertEqual(
             response.json()['detail'], "You can't delete other users theads in this category."
         )
 
+    @patch_category_acl({
+        'can_hide_threads': 2,
+        'can_hide_own_threads': 2,
+        'can_close_threads': False,
+    })
     def test_delete_thread_closed_category_no_permission(self):
         """api tests category's closed state"""
         self.category.is_closed = True
         self.category.save()
 
-        self.override_acl({
-            'can_hide_threads': 2,
-            'can_hide_own_threads': 2,
-            'can_close_threads': False,
-        })
-
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-
         self.assertEqual(
             response.json()['detail'], "This category is closed. You can't delete threads in it."
         )
 
+    @patch_category_acl({
+        'can_hide_threads': 2,
+        'can_hide_own_threads': 2,
+        'can_close_threads': False,
+    })
     def test_delete_thread_closed_no_permission(self):
         """api tests thread's closed state"""
         self.last_thread.is_closed = True
         self.last_thread.save()
 
-        self.override_acl({
-            'can_hide_threads': 2,
-            'can_hide_own_threads': 2,
-            'can_close_threads': False,
-        })
-
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-
         self.assertEqual(
             response.json()['detail'], "This thread is closed. You can't delete it."
         )
 
+    @patch_category_acl({
+        'can_hide_threads': 1,
+        'can_hide_own_threads': 2,
+        'thread_edit_time': 1
+    })
     def test_delete_owned_thread_no_time(self):
         """api tests permission to delete owned thread within time limit"""
-        self.override_acl({
-            'can_hide_threads': 1,
-            'can_hide_own_threads': 2,
-            'thread_edit_time': 1
-        })
-
         self.last_thread.starter = self.user
         self.last_thread.started_on = timezone.now() - timedelta(minutes=10)
         self.last_thread.save()
@@ -289,10 +224,9 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
             response.json()['detail'], "You can't delete threads that are older than 1 minute."
         )
 
+    @patch_category_acl({'can_hide_threads': 2})
     def test_delete_thread(self):
         """DELETE to API link with permission deletes thread"""
-        self.override_acl({'can_hide_threads': 2})
-
         category = Category.objects.get(slug='first-category')
         self.assertEqual(category.last_thread_id, self.last_thread.pk)
 
@@ -307,8 +241,6 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
         self.assertEqual(category.last_thread_id, self.thread.pk)
 
         # test that last thread's deletion triggers category sync
-        self.override_acl({'can_hide_threads': 2})
-
         response = self.client.delete(self.thread.get_api_url())
         self.assertEqual(response.status_code, 200)
 
@@ -318,14 +250,13 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
         category = Category.objects.get(slug='first-category')
         self.assertIsNone(category.last_thread_id)
 
+    @patch_category_acl({
+        'can_hide_threads': 1,
+        'can_hide_own_threads': 2,
+        'thread_edit_time': 30
+    })
     def test_delete_owned_thread(self):
         """api lets owner to delete owned thread within time limit"""
-        self.override_acl({
-            'can_hide_threads': 1,
-            'can_hide_own_threads': 2,
-            'thread_edit_time': 30
-        })
-
         self.last_thread.starter = self.user
         self.last_thread.started_on = timezone.now() - timedelta(minutes=10)
         self.last_thread.save()

+ 17 - 45
misago/threads/tests/test_threads_bulkdelete_api.py

@@ -2,12 +2,12 @@ import json
 
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
 from misago.categories import PRIVATE_THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads.models import Thread
 from misago.threads.serializers.moderation import THREADS_LIMIT
+from misago.threads.test import patch_category_acl
 from misago.threads.threadtypes import trees_map
 
 from .test_threads_api import ThreadsApiTestCase
@@ -44,26 +44,17 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "This action is not available to guests.",
         })
 
+    @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     def test_delete_no_ids(self):
         """api requires ids to delete"""
-        self.override_acl({
-            'can_hide_own_threads': 0,
-            'can_hide_threads': 0,
-        })
-
         response = self.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You have to specify at least one thread to delete.",
         })
 
+    @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     def test_validate_ids(self):
-        """api validates that ids are list of ints"""
-        self.override_acl({
-            'can_hide_own_threads': 2,
-            'can_hide_threads': 2,
-        })
-
         response = self.delete(self.api_link, True)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
@@ -82,26 +73,18 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "One or more thread ids received were invalid.",
         })
 
+    @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     def test_validate_ids_length(self):
         """api validates that ids are list of ints"""
-        self.override_acl({
-            'can_hide_own_threads': 2,
-            'can_hide_threads': 2,
-        })
-
         response = self.delete(self.api_link, list(range(THREADS_LIMIT + 1)))
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "No more than %s threads can be deleted at single time." % THREADS_LIMIT,
         })
 
+    @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     def test_validate_thread_visibility(self):
         """api valdiates if user can see deleted thread"""
-        self.override_acl({
-            'can_hide_own_threads': 2,
-            'can_hide_threads': 2,
-        })
-
         unapproved_thread = self.threads[1]
 
         unapproved_thread.is_unapproved = True
@@ -119,17 +102,12 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         for thread in self.threads:
             Thread.objects.get(pk=thread.pk)
 
+    @patch_category_acl({"can_hide_threads": 0, "can_hide_own_threads": 2})
     def test_delete_other_user_thread_no_permission(self):
         """api valdiates if user can delete other users threads"""
-        self.override_acl({
-            'can_hide_own_threads': 2,
-            'can_hide_threads': 0,
-        })
-
         other_thread = self.threads[1]
 
         response = self.delete(self.api_link, [p.id for p in self.threads])
-
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), [
             {
@@ -145,17 +123,16 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         for thread in self.threads:
             Thread.objects.get(pk=thread.pk)
 
+    @patch_category_acl({
+        "can_hide_threads": 2, 
+        "can_hide_own_threads": 2,
+        "can_close_threads": False,
+    })
     def test_delete_thread_closed_category_no_permission(self):
         """api tests category's closed state"""
         self.category.is_closed = True
         self.category.save()
 
-        self.override_acl({
-            'can_hide_threads': 2,
-            'can_hide_own_threads': 2,
-            'can_close_threads': False,
-        })
-
         response = self.delete(self.api_link, [p.id for p in self.threads])
 
         self.assertEqual(response.status_code, 400)
@@ -169,18 +146,17 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
             } for thread in sorted(self.threads, key=lambda i: i.pk)
         ])
 
+    @patch_category_acl({
+        "can_hide_threads": 2, 
+        "can_hide_own_threads": 2,
+        "can_close_threads": False,
+    })
     def test_delete_thread_closed_no_permission(self):
         """api tests thread's closed state"""
         closed_thread = self.threads[1]
         closed_thread.is_closed = True
         closed_thread.save()
 
-        self.override_acl({
-            'can_hide_threads': 2,
-            'can_hide_own_threads': 2,
-            'can_close_threads': False,
-        })
-
         response = self.delete(self.api_link, [p.id for p in self.threads])
 
         self.assertEqual(response.status_code, 400)
@@ -194,6 +170,7 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
             }
         ])
 
+    @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     def test_delete_private_thread(self):
         """attempt to delete private thread fails"""
         private_thread = self.threads[0]
@@ -208,11 +185,6 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
             is_owner=True,
         )
 
-        self.override_acl({
-            'can_hide_own_threads': 2,
-            'can_hide_threads': 2,
-        })
-
         threads_ids = [p.id for p in self.threads]
 
         response = self.delete(self.api_link, threads_ids)

+ 138 - 221
misago/threads/tests/test_threads_editor_api.py

@@ -2,18 +2,20 @@ import os
 
 from django.urls import reverse
 
-from misago.acl import add_acl
-from misago.acl.testutils import override_acl
+from misago.acl import useracl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads.models import Attachment
 from misago.threads.serializers import AttachmentSerializer
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
-
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
 
+cache_versions = {"acl": "abcdefgh"}
+
 
 class EditorApiTestCase(AuthenticatedUserTestCase):
     def setUp(self):
@@ -21,53 +23,6 @@ class EditorApiTestCase(AuthenticatedUserTestCase):
 
         self.category = Category.objects.get(slug='first-category')
 
-    def override_acl(self, acl=None):
-        final_acl = self.user.acl_cache['categories'][self.category.pk]
-        final_acl.update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_see_all_threads': 1,
-            'can_start_threads': 0,
-            'can_reply_threads': 0,
-            'can_edit_threads': 0,
-            'can_edit_posts': 0,
-            'can_hide_own_threads': 0,
-            'can_hide_own_posts': 0,
-            'thread_edit_time': 0,
-            'post_edit_time': 0,
-            'can_hide_threads': 0,
-            'can_hide_posts': 0,
-            'can_protect_posts': 0,
-            'can_move_posts': 0,
-            'can_merge_posts': 0,
-            'can_pin_threads': 0,
-            'can_close_threads': 0,
-            'can_move_threads': 0,
-            'can_merge_threads': 0,
-            'can_approve_content': 0,
-            'can_report_content': 0,
-            'can_see_reports': 0,
-            'can_see_posts_likes': 0,
-            'can_like_posts': 0,
-            'can_hide_events': 0,
-        })
-
-        if acl:
-            final_acl.update(acl)
-
-        browseable_categories = []
-        if final_acl['can_browse']:
-            browseable_categories.append(self.category.pk)
-
-        override_acl(
-            self.user, {
-                'browseable_categories': browseable_categories,
-                'categories': {
-                    self.category.pk: final_acl,
-                },
-            }
-        )
-
 
 class ThreadPostEditorApiTests(EditorApiTestCase):
     def setUp(self):
@@ -85,30 +40,27 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
             "detail": "You need to be signed in to start threads.",
         })
 
+    @patch_category_acl({'can_browse': False})
     def test_category_visibility_validation(self):
         """endpoint omits non-browseable categories"""
-        self.override_acl({'can_browse': 0})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "No categories that allow new threads are available to you at the moment.",
         })
 
+    @patch_category_acl({'can_start_threads': False})
     def test_category_disallowing_new_threads(self):
         """endpoint omits category disallowing starting threads"""
-        self.override_acl({'can_start_threads': 0})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "No categories that allow new threads are available to you at the moment.",
         })
 
+    @patch_category_acl({'can_close_threads': False, 'can_start_threads': True})
     def test_category_closed_disallowing_new_threads(self):
         """endpoint omits closed category"""
-        self.override_acl({'can_start_threads': 2, 'can_close_threads': 0})
-
         self.category.is_closed = True
         self.category.save()
 
@@ -118,10 +70,9 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
             "detail": "No categories that allow new threads are available to you at the moment.",
         })
 
+    @patch_category_acl({'can_close_threads': True, 'can_start_threads': True})
     def test_category_closed_allowing_new_threads(self):
         """endpoint adds closed category that allows new threads"""
-        self.override_acl({'can_start_threads': 2, 'can_close_threads': 1})
-
         self.category.is_closed = True
         self.category.save()
 
@@ -142,10 +93,9 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
             }
         )
 
+    @patch_category_acl({'can_start_threads': True})
     def test_category_allowing_new_threads(self):
         """endpoint adds category that allows new threads"""
-        self.override_acl({'can_start_threads': 2})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -163,10 +113,9 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
             }
         )
 
+    @patch_category_acl({'can_close_threads': True, 'can_start_threads': True})
     def test_category_allowing_closing_threads(self):
         """endpoint adds category that allows new closed threads"""
-        self.override_acl({'can_start_threads': 2, 'can_close_threads': 1})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -184,10 +133,9 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
             }
         )
 
+    @patch_category_acl({'can_start_threads': True, 'can_pin_threads': 1})
     def test_category_allowing_locally_pinned_threads(self):
         """endpoint adds category that allows locally pinned threads"""
-        self.override_acl({'can_start_threads': 2, 'can_pin_threads': 1})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -205,10 +153,9 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
             }
         )
 
+    @patch_category_acl({'can_start_threads': True, 'can_pin_threads': 2})
     def test_category_allowing_globally_pinned_threads(self):
         """endpoint adds category that allows globally pinned threads"""
-        self.override_acl({'can_start_threads': 2, 'can_pin_threads': 2})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -226,10 +173,9 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
             }
         )
 
-    def test_category_allowing_hidden_threads(self):
-        """endpoint adds category that allows globally pinned threads"""
-        self.override_acl({'can_start_threads': 2, 'can_hide_threads': 1})
-
+    @patch_category_acl({'can_start_threads': True, 'can_hide_threads': 1})
+    def test_category_allowing_hidding_threads(self):
+        """endpoint adds category that allows hiding threads"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -247,8 +193,9 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
             }
         )
 
-        self.override_acl({'can_start_threads': 2, 'can_hide_threads': 2})
-
+    @patch_category_acl({'can_start_threads': True, 'can_hide_threads': 2})
+    def test_category_allowing_hidding_and_deleting_threads(self):
+        """endpoint adds category that allows hiding and deleting threads"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -260,7 +207,7 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
                 'level': 0,
                 'post': {
                     'close': False,
-                    'hide': True,
+                    'hide': 1,
                     'pin': 0,
                 },
             }
@@ -290,22 +237,21 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
 
     def test_thread_visibility(self):
         """thread's visibility is validated"""
-        self.override_acl({'can_see': 0})
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_see': False}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
-        self.override_acl({'can_browse': 0})
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_browse': False}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
-        self.override_acl({'can_see_all_threads': 0})
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_see_all_threads': False}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
+    @patch_category_acl({'can_reply_threads': False})
     def test_no_reply_permission(self):
         """permssion to reply is validated"""
-        self.override_acl({'can_reply_threads': 0})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
@@ -314,72 +260,63 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
 
     def test_closed_category(self):
         """permssion to reply in closed category is validated"""
-        self.override_acl({'can_reply_threads': 1, 'can_close_threads': 0})
-
         self.category.is_closed = True
         self.category.save()
 
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't reply to threads in it.",
-        })
+        with patch_category_acl({'can_reply_threads': True, 'can_close_threads': False}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 403)
+            self.assertEqual(response.json(), {
+                "detail": "This category is closed. You can't reply to threads in it.",
+            })
 
         # allow to post in closed category
-        self.override_acl({'can_reply_threads': 1, 'can_close_threads': 1})
-
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 200)
+        with patch_category_acl({'can_reply_threads': True, 'can_close_threads': True}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 200)
 
     def test_closed_thread(self):
         """permssion to reply in closed thread is validated"""
-        self.override_acl({'can_reply_threads': 1, 'can_close_threads': 0})
-
         self.thread.is_closed = True
         self.thread.save()
 
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't reply to closed threads in this category.",
-        })
+        with patch_category_acl({'can_reply_threads': True, 'can_close_threads': False}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 403)
+            self.assertEqual(response.json(), {
+                "detail": "You can't reply to closed threads in this category.",
+            })
 
         # allow to post in closed thread
-        self.override_acl({'can_reply_threads': 1, 'can_close_threads': 1})
-
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 200)
+        with patch_category_acl({'can_reply_threads': True, 'can_close_threads': True}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({'can_reply_threads': True})
     def test_allow_reply_thread(self):
         """api returns 200 code if thread reply is allowed"""
-        self.override_acl({'can_reply_threads': 1})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
     def test_reply_to_visibility(self):
         """api validates replied post visibility"""
-        self.override_acl({'can_reply_threads': 1})
 
         # unapproved reply can't be replied to
-        unapproved_reply = testutils.reply_thread(
-            self.thread,
-            is_unapproved=True,
-        )
+        unapproved_reply = testutils.reply_thread(self.thread, is_unapproved=True)
 
-        response = self.client.get('%s?reply=%s' % (self.api_link, unapproved_reply.pk))
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_reply_threads': True}):
+            response = self.client.get('%s?reply=%s' % (self.api_link, unapproved_reply.pk))
+            self.assertEqual(response.status_code, 404)
 
         # hidden reply can't be replied to
-        self.override_acl({'can_reply_threads': 1})
-
         hidden_reply = testutils.reply_thread(self.thread, is_hidden=True)
 
-        response = self.client.get('%s?reply=%s' % (self.api_link, hidden_reply.pk))
-        self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't reply to hidden posts.",
-        })
+        with patch_category_acl({'can_reply_threads': True}):
+            response = self.client.get('%s?reply=%s' % (self.api_link, hidden_reply.pk))
+            self.assertEqual(response.status_code, 403)
+            self.assertEqual(response.json(), {
+                "detail": "You can't reply to hidden posts.",
+            })
 
     def test_reply_to_other_thread_post(self):
         """api validates is replied post belongs to same thread"""
@@ -389,10 +326,9 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         response = self.client.get('%s?reply=%s' % (self.api_link, reply_to.pk))
         self.assertEqual(response.status_code, 404)
 
+    @patch_category_acl({'can_reply_threads': True})
     def test_reply_to_event(self):
-        """events can't be edited"""
-        self.override_acl({'can_reply_threads': 1})
-
+        """events can't be replied to"""
         reply_to = testutils.reply_thread(self.thread, is_event=True)
 
         response = self.client.get('%s?reply=%s' % (self.api_link, reply_to.pk))
@@ -401,10 +337,9 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
             "detail": "You can't reply to events.",
         })
 
+    @patch_category_acl({'can_reply_threads': True})
     def test_reply_to(self):
         """api includes replied to post details in response"""
-        self.override_acl({'can_reply_threads': 1})
-
         reply_to = testutils.reply_thread(self.thread)
 
         response = self.client.get('%s?reply=%s' % (self.api_link, reply_to.pk))
@@ -446,22 +381,21 @@ class EditReplyEditorApiTests(EditorApiTestCase):
 
     def test_thread_visibility(self):
         """thread's visibility is validated"""
-        self.override_acl({'can_see': 0})
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_see': False}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
-        self.override_acl({'can_browse': 0})
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_browse': False}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
-        self.override_acl({'can_see_all_threads': 0})
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_see_all_threads': False}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
+    @patch_category_acl({'can_edit_posts': 0})
     def test_no_edit_permission(self):
         """permssion to edit is validated"""
-        self.override_acl({'can_edit_posts': 0})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
@@ -470,103 +404,90 @@ class EditReplyEditorApiTests(EditorApiTestCase):
 
     def test_closed_category(self):
         """permssion to edit in closed category is validated"""
-        self.override_acl({'can_edit_posts': 1, 'can_close_threads': 0})
-
         self.category.is_closed = True
         self.category.save()
 
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't edit posts in it.",
-        })
+        with patch_category_acl({'can_edit_posts': 1, 'can_close_threads': False}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 403)
+            self.assertEqual(response.json(), {
+                "detail": "This category is closed. You can't edit posts in it.",
+            })
 
         # allow to edit in closed category
-        self.override_acl({'can_edit_posts': 1, 'can_close_threads': 1})
-
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 200)
+        with patch_category_acl({'can_edit_posts': 1, 'can_close_threads': True}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 200)
 
     def test_closed_thread(self):
         """permssion to edit in closed thread is validated"""
-        self.override_acl({'can_edit_posts': 1, 'can_close_threads': 0})
-
         self.thread.is_closed = True
         self.thread.save()
 
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This thread is closed. You can't edit posts in it.",
-        })
+        with patch_category_acl({'can_edit_posts': 1, 'can_close_threads': False}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 403)
+            self.assertEqual(response.json(), {
+                "detail": "This thread is closed. You can't edit posts in it.",
+            })
 
         # allow to edit in closed thread
-        self.override_acl({'can_edit_posts': 1, 'can_close_threads': 1})
-
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 200)
+        with patch_category_acl({'can_edit_posts': 1, 'can_close_threads': True}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 200)
 
     def test_protected_post(self):
         """permssion to edit protected post is validated"""
-        self.override_acl({'can_edit_posts': 1, 'can_protect_posts': 0})
-
         self.post.is_protected = True
         self.post.save()
 
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This post is protected. You can't edit it.",
-        })
+        with patch_category_acl({'can_edit_posts': 1, 'can_protect_posts': False}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 403)
+            self.assertEqual(response.json(), {
+                "detail": "This post is protected. You can't edit it.",
+            })
 
         # allow to post in closed thread
-        self.override_acl({'can_edit_posts': 1, 'can_protect_posts': 1})
-
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 200)
+        with patch_category_acl({'can_edit_posts': 1, 'can_protect_posts': True}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 200)
 
     def test_post_visibility(self):
         """edited posts visibility is validated"""
-        self.override_acl({'can_edit_posts': 1})
-
         self.post.is_hidden = True
         self.post.save()
 
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This post is hidden, you can't edit it.",
-        })
+        with patch_category_acl({'can_edit_posts': 1}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 403)
+            self.assertEqual(response.json(), {
+                "detail": "This post is hidden, you can't edit it.",
+            })
 
         # allow hidden edition
-        self.override_acl({'can_edit_posts': 1, 'can_hide_posts': 1})
-
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 200)
+        with patch_category_acl({'can_edit_posts': 1, 'can_hide_posts': 1}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 200)
 
         # test unapproved post
+        self.post.is_unapproved = True
         self.post.is_hidden = False
         self.post.poster = None
         self.post.save()
 
-        self.override_acl({'can_edit_posts': 2, 'can_approve_content': 0})
-
-        self.post.is_unapproved = True
-        self.post.save()
-
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_edit_posts': 2, 'can_approve_content': 0}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 404)
 
         # allow unapproved edition
-        self.override_acl({'can_edit_posts': 2, 'can_approve_content': 1})
-
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 200)
+        with patch_category_acl({'can_edit_posts': 2, 'can_approve_content': 1}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({'can_edit_posts': 2})
     def test_post_is_event(self):
         """events can't be edited"""
-        self.override_acl()
-
         self.post.is_event = True
         self.post.save()
 
@@ -578,27 +499,24 @@ class EditReplyEditorApiTests(EditorApiTestCase):
 
     def test_other_user_post(self):
         """api validates if other user's post can be edited"""
-        self.override_acl({'can_edit_posts': 1})
-
         self.post.poster = None
         self.post.save()
 
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't edit other users posts in this category.",
-        })
+        with patch_category_acl({'can_edit_posts': 1}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 403)
+            self.assertEqual(response.json(), {
+                "detail": "You can't edit other users posts in this category.",
+            })
 
         # allow other users post edition
-        self.override_acl({'can_edit_posts': 2})
-
-        response = self.client.get(self.api_link)
-        self.assertEqual(response.status_code, 200)
+        with patch_category_acl({'can_edit_posts': 2}):
+            response = self.client.get(self.api_link)
+            self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({'can_hide_threads': 1, 'can_edit_posts': 2})
     def test_edit_first_post_hidden(self):
         """endpoint returns valid configuration for editor of hidden thread's first post"""
-        self.override_acl({'can_hide_threads': 1, 'can_edit_posts': 2})
-
         self.thread.is_hidden = True
         self.thread.save()
         self.thread.first_post.is_hidden = True
@@ -615,18 +533,18 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         response = self.client.get(api_link)
         self.assertEqual(response.status_code, 200)
 
+    @patch_category_acl({'can_edit_posts': 1})
     def test_edit(self):
         """endpoint returns valid configuration for editor"""
-        for _ in range(3):
-            self.override_acl({'max_attachment_size': 1000})
-
-            with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-                response = self.client.post(
-                    reverse('misago:api:attachment-list'), data={
-                        'upload': upload,
-                    }
-                )
-            self.assertEqual(response.status_code, 200)
+        with patch_category_acl({'max_attachment_size': 1000}):
+            for _ in range(3):
+                with open(TEST_DOCUMENT_PATH, 'rb') as upload:
+                    response = self.client.post(
+                        reverse('misago:api:attachment-list'), data={
+                            'upload': upload,
+                        }
+                    )
+                self.assertEqual(response.status_code, 200)
 
         attachments = list(Attachment.objects.order_by('id'))
 
@@ -637,11 +555,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
             attachment.post = self.post
             attachment.save()
 
-        self.override_acl({'can_edit_posts': 1})
         response = self.client.get(self.api_link)
-
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         for attachment in attachments:
-            add_acl(self.user, attachment)
+            add_acl_to_obj(user_acl, attachment)
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(

+ 58 - 198
misago/threads/tests/test_threads_merge_api.py

@@ -2,17 +2,20 @@ import json
 
 from django.urls import reverse
 
-from misago.acl import add_acl
-from misago.acl.testutils import override_acl
+from misago.acl import useracl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.readtracker import poststracker
 from misago.threads import testutils
-from misago.threads.serializers.moderation import THREADS_LIMIT
 from misago.threads.models import Poll, PollVote, Post, Thread
 from misago.threads.serializers import ThreadsListSerializer
+from misago.threads.serializers.moderation import THREADS_LIMIT
+from misago.threads.test import patch_category_acl, patch_other_category_acl
 
 from .test_threads_api import ThreadsApiTestCase
 
+cache_versions = {"acl": "abcdefgh"}
+
 
 class ThreadsMergeApiTests(ThreadsApiTestCase):
     def setUp(self):
@@ -20,40 +23,14 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.api_link = reverse('misago:api:thread-merge')
 
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other Category',
+            slug='other-category',
         ).insert_at(
             self.category,
             position='last-child',
             save=True,
         )
-        self.category_b = Category.objects.get(slug='category-b')
-
-    def override_other_category(self):
-        categories =  self.user.acl_cache['categories']
-
-        visible_categories = self.user.acl_cache['visible_categories']
-        browseable_categories = self.user.acl_cache['browseable_categories']
-
-        visible_categories.append(self.category_b.pk)
-        browseable_categories.append(self.category_b.pk)
-
-        override_acl(
-            self.user, {
-                'visible_categories': visible_categories,
-                'browseable_categories': browseable_categories,
-                'categories': {
-                    self.category.pk: categories[self.category.pk],
-                    self.category_b.pk: {
-                        'can_see': 1,
-                        'can_browse': 1,
-                        'can_see_all_threads': 1,
-                        'can_see_own_threads': 0,
-                        'can_start_threads': 2,
-                    },
-                },
-            }
-        )
+        self.other_category = Category.objects.get(slug='other-category')
 
     def test_merge_no_threads(self):
         """api validates if we are trying to merge no threads"""
@@ -143,7 +120,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
     def test_merge_with_invisible_thread(self):
         """api validates if we are trying to merge with inaccesible thread"""
-        unaccesible_thread = testutils.post_thread(category=self.category_b)
+        unaccesible_thread = testutils.post_thread(category=self.other_category)
 
         response = self.client.post(
             self.api_link,
@@ -166,7 +143,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(
             self.api_link,
             json.dumps({
-                'category': self.category.pk,
+                'category': self.category.id,
                 'title': 'Lorem ipsum dolor',
                 'threads': [self.thread.id, thread.id],
             }),
@@ -188,14 +165,10 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             ]
         )
 
+    @patch_other_category_acl()
+    @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
     def test_thread_category_is_closed(self):
         """api validates if thread's category is open"""
-        self.override_acl({
-            'can_merge_threads': 1,
-            'can_close_threads': 0,
-        })
-        self.override_other_category()
-
         other_thread = testutils.post_thread(self.category)
 
         self.category.is_closed = True
@@ -204,7 +177,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(
             self.api_link,
             json.dumps({
-                'category': self.category_b.pk,
+                'category': self.other_category.id,
                 'title': 'Lorem ipsum dolor',
                 'threads': [self.thread.id, other_thread.id],
             }),
@@ -224,14 +197,10 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             },
         ])
 
+    @patch_other_category_acl()
+    @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
     def test_thread_is_closed(self):
         """api validates if thread is open"""
-        self.override_acl({
-            'can_merge_threads': 1,
-            'can_close_threads': 0,
-        })
-        self.override_other_category()
-
         other_thread = testutils.post_thread(self.category)
 
         other_thread.is_closed = True
@@ -240,7 +209,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(
             self.api_link,
             json.dumps({
-                'category': self.category_b.pk,
+                'category': self.other_category.id,
                 'title': 'Lorem ipsum dolor',
                 'threads': [self.thread.id, other_thread.id],
             }),
@@ -255,19 +224,13 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             },
         ])
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_too_many_threads(self):
         """api rejects too many threads to merge"""
         threads = []
         for _ in range(THREADS_LIMIT + 1):
             threads.append(testutils.post_thread(category=self.category).pk)
 
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -282,15 +245,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_no_final_thread(self):
         """api rejects merge because no data to merge threads was specified"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -308,15 +265,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_invalid_final_title(self):
         """api rejects merge because final thread title was invalid"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -335,15 +286,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_invalid_category(self):
         """api rejects merge because final category was invalid"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -351,7 +296,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             json.dumps({
                 'threads': [self.thread.id, thread.id],
                 'title': 'Valid thread title',
-                'category': self.category_b.id,
+                'category': self.other_category.id,
             }),
             content_type="application/json",
         )
@@ -362,16 +307,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_start_threads": False})
     def test_merge_unallowed_start_thread(self):
         """api rejects merge because category isn't allowing starting threads"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-            'can_start_threads': 0,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -390,15 +328,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_invalid_weight(self):
         """api rejects merge because final weight was invalid"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -418,15 +350,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_unallowed_global_weight(self):
         """api rejects merge because global weight was unallowed"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -446,15 +372,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_unallowed_local_weight(self):
         """api rejects merge because local weight was unallowed"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -474,16 +394,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_pin_threads": 1})
     def test_merge_allowed_local_weight(self):
         """api allows local weight"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-            'can_pin_threads': 1,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -503,16 +416,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_pin_threads": 2})
     def test_merge_allowed_global_weight(self):
         """api allows global weight"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-            'can_pin_threads': 2,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -532,15 +438,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
     def test_merge_unallowed_close(self):
         """api rejects merge because closing thread was unallowed"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -560,15 +460,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_close_threads": True})
     def test_merge_with_close(self):
         """api allows for closing thread"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-            'can_close_threads': True,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -589,16 +483,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_hide_threads": 0})
     def test_merge_unallowed_hidden(self):
         """api rejects merge because hidden thread was unallowed"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-            'can_hide_threads': 0,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -618,16 +505,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_hide_threads": 1})
     def test_merge_with_hide(self):
         """api allows for hiding thread"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-            'can_hide_threads': 1,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -648,17 +528,10 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge(self):
         """api performs basic merge"""
         posts_ids = [p.id for p in Post.objects.all()]
-
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -679,8 +552,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread.is_read = False
         new_thread.subscription = None
 
-        add_acl(self.user, new_thread.category)
-        add_acl(self.user, new_thread)
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
+        add_acl_to_obj(user_acl, new_thread.category)
+        add_acl_to_obj(user_acl, new_thread)
 
         self.assertEqual(response_json, ThreadsListSerializer(new_thread).data)
 
@@ -691,17 +565,15 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         # are old threads gone?
         self.assertEqual([t.pk for t in Thread.objects.all()], [new_thread.pk])
 
+    @patch_category_acl({
+        "can_merge_threads": True,
+        "can_close_threads": True,
+        "can_hide_threads": 1,
+        "can_pin_threads": 2,
+    })
     def test_merge_kitchensink(self):
         """api performs merge"""
         posts_ids = [p.id for p in Post.objects.all()]
-
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': True,
-            'can_hide_threads': 1,
-            'can_pin_threads': 2,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         poststracker.save_read(self.user, self.thread.first_post)
@@ -745,8 +617,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertTrue(new_thread.is_closed)
         self.assertTrue(new_thread.is_hidden)
 
-        add_acl(self.user, new_thread.category)
-        add_acl(self.user, new_thread)
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
+        add_acl_to_obj(user_acl, new_thread.category)
+        add_acl_to_obj(user_acl, new_thread)
 
         self.assertEqual(response_json, ThreadsListSerializer(new_thread).data)
 
@@ -772,10 +645,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.user.subscription_set.get(thread=new_thread)
         self.user.subscription_set.get(category=self.category)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_merged_best_answer(self):
         """api merges two threads successfully, moving best answer to old thread"""
-        self.override_acl({'can_merge_threads': 1})
-
         other_thread = testutils.post_thread(self.category)
 
         best_answer = testutils.reply_thread(self.thread)
@@ -797,10 +669,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread = Thread.objects.get(pk=response.json()['id'])
         self.assertEqual(new_thread.best_answer_id, best_answer.id)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_merge_conflict_best_answer(self):
         """api errors on merge conflict, returning list of available best answers"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
@@ -835,10 +706,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(
             Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_best_answer_invalid_resolution(self):
         """api errors on invalid merge conflict resolution"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
@@ -868,10 +738,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(
             Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_unmark_all_best_answers(self):
         """api unmarks all best answers when unmark all choice is selected"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
@@ -898,10 +767,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertFalse(new_thread.has_best_answer)
         self.assertIsNone(new_thread.best_answer_id)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_first_best_answer(self):
         """api unmarks other best answer on merge"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
@@ -927,10 +795,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread = Thread.objects.get(pk=response.json()['id'])
         self.assertEqual(new_thread.best_answer_id, best_answer.id)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_other_best_answer(self):
         """api unmarks first best answer on merge"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
@@ -956,10 +823,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread = Thread.objects.get(pk=response.json()['id'])
         self.assertEqual(new_thread.best_answer_id, other_best_answer.id)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_kept_poll(self):
         """api merges two threads successfully, keeping poll from other thread"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(other_thread, self.user)
 
@@ -983,10 +849,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 1)
         self.assertEqual(PollVote.objects.count(), 4)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_moved_poll(self):
         """api merges two threads successfully, moving poll from old thread"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
 
@@ -1010,10 +875,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 1)
         self.assertEqual(PollVote.objects.count(), 4)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_poll(self):
         """api errors on merge conflict, returning list of available polls"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
@@ -1049,10 +913,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 2)
         self.assertEqual(PollVote.objects.count(), 8)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_poll_invalid_resolution(self):
         """api errors on invalid merge conflict resolution"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
 
         testutils.post_poll(self.thread, self.user)
@@ -1078,10 +941,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 2)
         self.assertEqual(PollVote.objects.count(), 8)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_delete_all_polls(self):
         """api deletes all polls when delete all choice is selected"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
 
         testutils.post_poll(self.thread, self.user)
@@ -1103,10 +965,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 0)
         self.assertEqual(PollVote.objects.count(), 0)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_first_poll(self):
         """api deletes other poll on merge"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
@@ -1131,10 +992,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         with self.assertRaises(Poll.DoesNotExist):
             Poll.objects.get(pk=other_poll.pk)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_other_poll(self):
         """api deletes first poll on merge"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)

+ 124 - 288
misago/threads/tests/test_threadslists.py

@@ -4,7 +4,7 @@ from django.urls import reverse
 from django.utils import timezone
 from django.utils.encoding import smart_str
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.readtracker import poststracker
@@ -12,10 +12,48 @@ from misago.threads import testutils
 from misago.users.models import AnonymousUser
 from misago.users.testutils import AuthenticatedUserTestCase
 
-
 LISTS_URLS = ('', 'my/', 'new/', 'unread/', 'subscribed/', )
 
 
+def patch_categories_acl(category_acl=None, base_acl=None):
+    def patch_acl(_, user_acl):
+        first_category = Category.objects.get(slug='first-category')
+        first_category_acl = user_acl['categories'][first_category.id].copy()
+
+        user_acl.update({
+            'categories': {},
+            'visible_categories': [],
+            'browseable_categories': [],
+            'can_approve_content': [],
+        })
+
+        # copy first category's acl to other categories to make base for overrides
+        for category in Category.objects.all_categories():
+            user_acl['categories'][category.id] = first_category_acl
+
+        if base_acl:
+            user_acl.update(base_acl)
+
+        for category in Category.objects.all_categories():
+            user_acl['visible_categories'].append(category.id)
+            user_acl['browseable_categories'].append(category.id)
+            user_acl['categories'][category.id].update({
+                'can_see': 1,
+                'can_browse': 1,
+                'can_see_all_threads': 1,
+                'can_see_own_threads': 0,
+                'can_hide_threads': 0,
+                'can_approve_content': 0,
+            })
+
+            if category_acl:
+                user_acl['categories'][category.id].update(category_acl)
+                if category_acl.get('can_approve_content'):
+                    user_acl['can_approve_content'].append(category.id)
+
+    return patch_user_acl(patch_acl)
+
+
 class ThreadsListTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         """
@@ -120,46 +158,6 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
         self.category_e = Category.objects.get(slug='category-e')
         self.category_f = Category.objects.get(slug='category-f')
 
-        self.access_all_categories()
-
-    def access_all_categories(self, category_acl=None, base_acl=None):
-        self.clear_state()
-
-        categories_acl = {
-            'categories': {},
-            'visible_categories': [],
-            'browseable_categories': [],
-            'can_approve_content': [],
-        }
-
-        # copy first category's acl to other categories to make base for overrides
-        first_category_acl = self.user.acl_cache['categories'][self.first_category.pk].copy()
-        for category in Category.objects.all_categories():
-            categories_acl['categories'][category.pk] = first_category_acl
-
-        if base_acl:
-            categories_acl.update(base_acl)
-
-        for category in Category.objects.all_categories():
-            categories_acl['visible_categories'].append(category.pk)
-            categories_acl['browseable_categories'].append(category.pk)
-            categories_acl['categories'][category.pk].update({
-                'can_see': 1,
-                'can_browse': 1,
-                'can_see_all_threads': 1,
-                'can_see_own_threads': 0,
-                'can_hide_threads': 0,
-                'can_approve_content': 0,
-            })
-
-            if category_acl:
-                categories_acl['categories'][category.pk].update(category_acl)
-                if category_acl.get('can_approve_content'):
-                    categories_acl['can_approve_content'].append(category.pk)
-
-        override_acl(self.user, categories_acl)
-        return categories_acl
-
     def assertContainsThread(self, response, thread):
         self.assertContains(response, ' href="%s"' % thread.get_absolute_url())
 
@@ -185,11 +183,10 @@ class ApiTests(ThreadsListTestCase):
 
 
 class AllThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_empty(self):
         """empty threads list renders"""
         for url in LISTS_URLS:
-            self.access_all_categories()
-
             response = self.client.get('/' + url)
             self.assertEqual(response.status_code, 200)
             self.assertContains(response, "empty-message")
@@ -198,8 +195,6 @@ class AllThreadsListTests(ThreadsListTestCase):
             else:
                 self.assertContains(response, "There are no threads on this forum")
 
-            self.access_all_categories()
-
             response = self.client.get(self.category_b.get_absolute_url() + url)
             self.assertEqual(response.status_code, 200)
             self.assertContains(response, self.category_b.name)
@@ -209,8 +204,6 @@ class AllThreadsListTests(ThreadsListTestCase):
             else:
                 self.assertContains(response, "There are no threads in this category")
 
-            self.access_all_categories()
-
             response = self.client.get('%s?list=%s' % (self.api_link, url.strip('/') or 'all'))
             self.assertEqual(response.status_code, 200)
 
@@ -221,46 +214,34 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.logout_user()
         self.user = self.get_anonymous_user()
 
-        self.access_all_categories()
-
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "There are no threads on this forum")
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_b.get_absolute_url())
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.category_b.name)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "There are no threads in this category")
 
-        self.access_all_categories()
-
         response = self.client.get('%s?list=all' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_authenticated_only_views(self):
         """authenticated only views return 403 for guests"""
         for url in LISTS_URLS:
-            self.access_all_categories()
-
             response = self.client.get('/' + url)
             self.assertEqual(response.status_code, 200)
 
-            self.access_all_categories()
-
             response = self.client.get(self.category_b.get_absolute_url() + url)
             self.assertEqual(response.status_code, 200)
             self.assertContains(response, self.category_b.name)
 
-            self.access_all_categories()
-
-            self.access_all_categories()
             response = self.client.get(
                 '%s?category=%s&list=%s' %
                 (self.api_link, self.category_b.pk, url.strip('/') or 'all', )
@@ -270,22 +251,19 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.logout_user()
         self.user = self.get_anonymous_user()
         for url in LISTS_URLS[1:]:
-            self.access_all_categories()
-
             response = self.client.get('/' + url)
             self.assertEqual(response.status_code, 403)
 
-            self.access_all_categories()
             response = self.client.get(self.category_b.get_absolute_url() + url)
             self.assertEqual(response.status_code, 403)
 
-            self.access_all_categories()
             response = self.client.get(
                 '%s?category=%s&list=%s' %
                 (self.api_link, self.category_b.pk, url.strip('/') or 'all', )
             )
             self.assertEqual(response.status_code, 403)
 
+    @patch_categories_acl()
     def test_list_renders_categories_picker(self):
         """categories picker renders valid categories"""
         Category(
@@ -316,7 +294,6 @@ class AllThreadsListTests(ThreadsListTestCase):
         # hidden category
         self.assertNotContains(response, 'subcategory-%s' % test_category.css_class)
 
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -325,11 +302,8 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.assertNotIn(self.category_b.pk, response_json['subcategories'])
 
         # test category view
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url())
         self.assertEqual(response.status_code, 200)
-
         self.assertContains(response, 'subcategory-%s' % self.category_b.css_class)
 
         # readable categories, but non-accessible directly
@@ -337,7 +311,6 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.assertNotContains(response, 'subcategory-%s' % self.category_d.css_class)
         self.assertNotContains(response, 'subcategory-%s' % self.category_f.css_class)
 
-        self.access_all_categories()
         response = self.client.get('%s?category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
@@ -462,7 +435,7 @@ class CategoryThreadsListTests(ThreadsListTestCase):
             response = self.client.get(test_category.get_absolute_url() + url)
             self.assertEqual(response.status_code, 404)
 
-            response = self.client.get('%s?category=%s' % (self.api_link, test_category.pk))
+            response = self.client.get('%s?category=%s' % (self.api_link, test_category.id))
             self.assertEqual(response.status_code, 404)
 
     def test_access_protected_category(self):
@@ -478,37 +451,23 @@ class CategoryThreadsListTests(ThreadsListTestCase):
         test_category = Category.objects.get(slug='hidden-category')
 
         for url in LISTS_URLS:
-            override_acl(
-                self.user, {
-                    'visible_categories': [test_category.pk],
-                    'browseable_categories': [],
-                    'categories': {
-                        test_category.pk: {
-                            'can_see': 1,
-                            'can_browse': 0,
-                        },
+            with patch_user_acl({
+                'visible_categories': [test_category.id],
+                'browseable_categories': [],
+                'categories': {
+                    test_category.id: {
+                        'can_see': 1,
+                        'can_browse': 0,
                     },
-                }
-            )
-            response = self.client.get(test_category.get_absolute_url() + url)
-            self.assertEqual(response.status_code, 403)
+                },
+            }):
+                response = self.client.get(test_category.get_absolute_url() + url)
+                self.assertEqual(response.status_code, 403)
 
-            override_acl(
-                self.user, {
-                    'visible_categories': [test_category.pk],
-                    'browseable_categories': [],
-                    'categories': {
-                        test_category.pk: {
-                            'can_see': 1,
-                            'can_browse': 0,
-                        },
-                    },
-                }
-            )
-            response = self.client.get(
-                '%s?category=%s&list=%s' % (self.api_link, test_category.pk, url.strip('/'), )
-            )
-            self.assertEqual(response.status_code, 403)
+                response = self.client.get(
+                    '%s?category=%s&list=%s' % (self.api_link, test_category.id, url.strip('/'))
+                )
+                self.assertEqual(response.status_code, 403)
 
     def test_display_pinned_threads(self):
         """
@@ -550,7 +509,7 @@ class CategoryThreadsListTests(ThreadsListTestCase):
         self.assertTrue(positions['s'] > positions['g'])
 
         # API behaviour is identic
-        response = self.client.get('/api/threads/?category=%s' % self.first_category.pk)
+        response = self.client.get('/api/threads/?category=%s' % self.first_category.id)
         self.assertEqual(response.status_code, 200)
 
         content = smart_str(response.content)
@@ -574,6 +533,7 @@ class CategoryThreadsListTests(ThreadsListTestCase):
 
 
 class ThreadsVisibilityTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_test_thread(self):
         """list renders test thread with valid top category"""
         test_thread = testutils.post_thread(
@@ -592,7 +552,6 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertContains(response, 'thread-detail-category-%s' % self.category_c.css_class)
 
         # api displays same data
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -602,7 +561,6 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertIn(self.category_a.pk, response_json['subcategories'])
 
         # test category view
-        self.access_all_categories()
         response = self.client.get(self.category_b.get_absolute_url())
         self.assertEqual(response.status_code, 200)
 
@@ -613,7 +571,6 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertContains(response, 'thread-detail-category-%s' % self.category_c.css_class)
 
         # api displays same data
-        self.access_all_categories()
         response = self.client.get('%s?category=%s' % (self.api_link, self.category_b.pk))
         self.assertEqual(response.status_code, 200)
 
@@ -664,6 +621,7 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_user_see_own_unapproved_thread(self):
         """list renders unapproved thread that belongs to viewer"""
         test_thread = testutils.post_thread(
@@ -677,13 +635,13 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
+    @patch_categories_acl()
     def test_list_user_cant_see_unapproved_thread(self):
         """list hides unapproved thread that belongs to other user"""
         test_thread = testutils.post_thread(
@@ -696,13 +654,13 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_user_cant_see_hidden_thread(self):
         """list hides hidden thread that belongs to other user"""
         test_thread = testutils.post_thread(
@@ -715,13 +673,13 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_user_cant_see_own_hidden_thread(self):
         """list hides hidden thread that belongs to viewer"""
         test_thread = testutils.post_thread(
@@ -735,13 +693,13 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl({'can_hide_threads': 1})
     def test_list_user_can_see_own_hidden_thread(self):
         """list shows hidden thread that belongs to viewer due to permission"""
         test_thread = testutils.post_thread(
@@ -750,21 +708,18 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
             is_hidden=True,
         )
 
-        self.access_all_categories({'can_hide_threads': 1})
-
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories({'can_hide_threads': 1})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
+    @patch_categories_acl({'can_hide_threads': 1})
     def test_list_user_can_see_hidden_thread(self):
         """list shows hidden thread that belongs to other user due to permission"""
         test_thread = testutils.post_thread(
@@ -772,21 +727,18 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
             is_hidden=True,
         )
 
-        self.access_all_categories({'can_hide_threads': 1})
-
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories({'can_hide_threads': 1})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
+    @patch_categories_acl({'can_approve_content': 1})
     def test_list_user_can_see_unapproved_thread(self):
         """list shows hidden thread that belongs to other user due to permission"""
         test_thread = testutils.post_thread(
@@ -794,15 +746,11 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
             is_unapproved=True,
         )
 
-        self.access_all_categories({'can_approve_content': 1})
-
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories({'can_approve_content': 1})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -811,34 +759,30 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
 
 
 class MyThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_empty(self):
         """list renders empty"""
-        self.access_all_categories()
-
         response = self.client.get('/my/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'my/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=my' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=my&category=%s' % (self.api_link, self.category_a.pk))
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_renders_test_thread(self):
         """list renders only threads posted by user"""
         test_thread = testutils.post_thread(
@@ -848,22 +792,17 @@ class MyThreadsListTests(ThreadsListTestCase):
 
         other_thread = testutils.post_thread(category=self.category_a)
 
-        self.access_all_categories()
-
         response = self.client.get('/my/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertNotContainsThread(response, other_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'my/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertNotContainsThread(response, other_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=my' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -871,7 +810,6 @@ class MyThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=my&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
@@ -881,52 +819,43 @@ class MyThreadsListTests(ThreadsListTestCase):
 
 
 class NewThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_empty(self):
         """list renders empty"""
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_renders_new_thread(self):
         """list renders new thread"""
         test_thread = testutils.post_thread(category=self.category_a)
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -934,7 +863,6 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
@@ -942,6 +870,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
+    @patch_categories_acl()
     def test_list_renders_thread_bumped_after_user_cutoff(self):
         """list renders new thread bumped after user cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=10)
@@ -957,20 +886,15 @@ class NewThreadsListTests(ThreadsListTestCase):
             posted_on=self.user.joined_on + timedelta(days=4),
         )
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -978,7 +902,6 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
@@ -986,6 +909,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
+    @patch_categories_acl()
     def test_list_hides_global_cutoff_thread(self):
         """list hides thread started before global cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=10)
@@ -996,33 +920,28 @@ class NewThreadsListTests(ThreadsListTestCase):
             started_on=timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF + 1),
         )
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_hides_user_cutoff_thread(self):
         """list hides thread started before users cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
@@ -1033,63 +952,51 @@ class NewThreadsListTests(ThreadsListTestCase):
             started_on=self.user.joined_on - timedelta(minutes=1),
         )
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_hides_user_read_thread(self):
         """list hides thread already read by user"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
 
         test_thread = testutils.post_thread(category=self.category_a)
-
         poststracker.save_read(self.user, test_thread.first_post)
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
@@ -1098,29 +1005,24 @@ class NewThreadsListTests(ThreadsListTestCase):
 
 
 class UnreadThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_empty(self):
         """list renders empty"""
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1129,31 +1031,25 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_renders_unread_thread(self):
         """list renders thread with unread posts"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
 
         test_thread = testutils.post_thread(category=self.category_a)
-
         poststracker.save_read(self.user, test_thread.first_post)
-
         testutils.reply_thread(test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -1161,7 +1057,6 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1171,6 +1066,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
+    @patch_categories_acl()
     def test_list_hides_never_read_thread(self):
         """list hides never read thread"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
@@ -1178,27 +1074,21 @@ class UnreadThreadsListTests(ThreadsListTestCase):
 
         test_thread = testutils.post_thread(category=self.category_a)
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1207,36 +1097,30 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_hides_read_thread(self):
         """list hides read thread"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
 
         test_thread = testutils.post_thread(category=self.category_a)
-
         poststracker.save_read(self.user, test_thread.first_post)
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1245,6 +1129,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_hides_global_cutoff_thread(self):
         """list hides thread replied before global cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=10)
@@ -1256,30 +1141,23 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         )
 
         poststracker.save_read(self.user, test_thread.first_post)
-
         testutils.reply_thread(test_thread, posted_on=test_thread.started_on + timedelta(days=1))
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1288,6 +1166,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_hides_user_cutoff_thread(self):
         """list hides thread replied before user cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=10)
@@ -1305,27 +1184,21 @@ class UnreadThreadsListTests(ThreadsListTestCase):
             posted_on=test_thread.started_on + timedelta(days=1),
         )
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1336,6 +1209,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
 
 
 class SubscribedThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_shows_subscribed_thread(self):
         """list shows subscribed thread"""
         test_thread = testutils.post_thread(category=self.category_a)
@@ -1345,20 +1219,15 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
             last_read_on=test_thread.last_post_on,
         )
 
-        self.access_all_categories()
-
         response = self.client.get('/subscribed/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'subscribed/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=subscribed' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -1366,7 +1235,6 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertContains(response, test_thread.get_absolute_url())
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=subscribed&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1376,24 +1244,20 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertContains(response, test_thread.get_absolute_url())
 
+    @patch_categories_acl()
     def test_list_hides_unsubscribed_thread(self):
         """list shows subscribed thread"""
         test_thread = testutils.post_thread(category=self.category_a)
 
-        self.access_all_categories()
-
         response = self.client.get('/subscribed/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'subscribed/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=subscribed' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -1401,7 +1265,6 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 0)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=subscribed&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1420,29 +1283,29 @@ class UnapprovedListTests(ThreadsListTestCase):
             '%s?list=unapproved' % self.api_link,
         )
 
-        for test_url in TEST_URLS:
-            self.access_all_categories()
-            response = self.client.get(test_url)
-            self.assertEqual(response.status_code, 403)
+        with patch_categories_acl():
+            for test_url in TEST_URLS:
+                response = self.client.get(test_url)
+                self.assertEqual(response.status_code, 403)
 
         # approval perm has no influence on visibility
-        for test_url in TEST_URLS:
-            self.access_all_categories({'can_approve_content': True})
-
-            self.access_all_categories()
-            response = self.client.get(test_url)
-            self.assertEqual(response.status_code, 403)
+        with patch_categories_acl({'can_approve_content': True}):
+            for test_url in TEST_URLS:
+                response = self.client.get(test_url)
+                self.assertEqual(response.status_code, 403)
 
         # approval perm has no influence on visibility
-        for test_url in TEST_URLS:
-            self.access_all_categories(base_acl={
-                'can_see_unapproved_content_lists': True,
-            })
-
-            self.access_all_categories()
-            response = self.client.get(test_url)
-            self.assertEqual(response.status_code, 200)
-
+        with patch_categories_acl(base_acl={
+            'can_see_unapproved_content_lists': True,
+        }):
+            for test_url in TEST_URLS:
+                response = self.client.get(test_url)
+                self.assertEqual(response.status_code, 200)
+
+    @patch_categories_acl(
+        {'can_approve_content': True},
+        {'can_see_unapproved_content_lists': True},
+    )
     def test_list_shows_all_threads_for_approving_user(self):
         """list shows all threads with unapproved posts when user has perm"""
         visible_thread = testutils.post_thread(
@@ -1455,40 +1318,23 @@ class UnapprovedListTests(ThreadsListTestCase):
             is_unapproved=False,
         )
 
-        self.access_all_categories({
-            'can_approve_content': True,
-        }, {
-            'can_see_unapproved_content_lists': True,
-        })
-
         response = self.client.get('/unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
-        self.access_all_categories({
-            'can_approve_content': True
-        }, {
-            'can_see_unapproved_content_lists': True,
-        })
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
         # test api
-        self.access_all_categories({
-            'can_approve_content': True
-        }, {
-            'can_see_unapproved_content_lists': True,
-        })
-
         response = self.client.get('%s?list=unapproved' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, visible_thread.get_absolute_url())
         self.assertNotContains(response, hidden_thread.get_absolute_url())
 
+    @patch_categories_acl(base_acl={'can_see_unapproved_content_lists': True})
     def test_list_shows_owned_threads_for_unapproving_user(self):
         """list shows owned threads with unapproved posts for user without perm"""
         visible_thread = testutils.post_thread(
@@ -1502,49 +1348,41 @@ class UnapprovedListTests(ThreadsListTestCase):
             is_unapproved=True,
         )
 
-        self.access_all_categories(base_acl={
-            'can_see_unapproved_content_lists': True,
-        })
         response = self.client.get('/unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
-        self.access_all_categories(base_acl={
-            'can_see_unapproved_content_lists': True,
-        })
         response = self.client.get(self.category_a.get_absolute_url() + 'unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
         # test api
-        self.access_all_categories(base_acl={
-            'can_see_unapproved_content_lists': True,
-        })
         response = self.client.get('%s?list=unapproved' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, visible_thread.get_absolute_url())
         self.assertNotContains(response, hidden_thread.get_absolute_url())
 
 
+def patch_category_see_all_threads_acl():
+    def patch_acl(_, user_acl):
+        category = Category.objects.get(slug='first-category')
+        category_acl = user_acl['categories'][category.id].copy()
+        category_acl.update({'can_see_all_threads': 0})
+        user_acl['categories'][category.id] = category_acl
+
+    return patch_user_acl(patch_acl)
+
+
 class OwnerOnlyThreadsVisibilityTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
         self.category = Category.objects.get(slug='first-category')
 
-    def override_acl(self, user):
-        category_acl = user.acl_cache['categories'][self.category.pk].copy()
-        category_acl.update({'can_see_all_threads': 0})
-        user.acl_cache['categories'][self.category.pk] = category_acl
-
-        override_acl(user, user.acl_cache)
-
     def test_owned_threads_visibility(self):
         """only user-posted threads are visible in category"""
-        self.override_acl(self.user)
-
         visible_thread = testutils.post_thread(
             poster=self.user,
             category=self.category,
@@ -1556,18 +1394,16 @@ class OwnerOnlyThreadsVisibilityTests(AuthenticatedUserTestCase):
             is_unapproved=True,
         )
 
-        response = self.client.get(self.category.get_absolute_url())
-
-        self.assertEqual(response.status_code, 200)
-        self.assertContains(response, visible_thread.get_absolute_url())
-        self.assertNotContains(response, hidden_thread.get_absolute_url())
+        with patch_category_see_all_threads_acl():
+            response = self.client.get(self.category.get_absolute_url())
+            self.assertEqual(response.status_code, 200)
+            self.assertContains(response, visible_thread.get_absolute_url())
+            self.assertNotContains(response, hidden_thread.get_absolute_url())
 
     def test_owned_threads_visibility_anonymous(self):
         """anons can't see any threads in limited visibility category"""
         self.logout_user()
 
-        self.override_acl(AnonymousUser())
-
         user_thread = testutils.post_thread(
             poster=self.user,
             category=self.category,
@@ -1579,8 +1415,8 @@ class OwnerOnlyThreadsVisibilityTests(AuthenticatedUserTestCase):
             is_unapproved=True,
         )
 
-        response = self.client.get(self.category.get_absolute_url())
-
-        self.assertEqual(response.status_code, 200)
-        self.assertNotContains(response, user_thread.get_absolute_url())
-        self.assertNotContains(response, guest_thread.get_absolute_url())
+        with patch_category_see_all_threads_acl():
+            response = self.client.get(self.category.get_absolute_url())
+            self.assertEqual(response.status_code, 200)
+            self.assertNotContains(response, user_thread.get_absolute_url())
+            self.assertNotContains(response, guest_thread.get_absolute_url())

+ 143 - 160
misago/threads/tests/test_threadview.py

@@ -1,4 +1,7 @@
-from misago.acl.testutils import override_acl
+from unittest.mock import Mock
+
+from misago.acl import useracl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.threads import testutils
@@ -8,22 +11,15 @@ from misago.threads.moderation import threads as threads_moderation
 from misago.threads.moderation import hide_post
 from misago.users.testutils import AuthenticatedUserTestCase
 
+cache_versions = {"acl": "abcdefgh"}
 
-class MockRequest(object):
-    def __init__(self, user):
-        self.user = user
-        self.user_ip = '127.0.0.1'
-
-
-class ThreadViewTestCase(AuthenticatedUserTestCase):
-    def setUp(self):
-        super().setUp()
 
-        self.category = Category.objects.get(slug='first-category')
-        self.thread = testutils.post_thread(category=self.category)
+def patch_category_acl(new_acl=None):
+    def patch_acl(_, user_acl):
+        category = Category.objects.get(slug='first-category')
+        category_acl = user_acl['categories'][category.id]
 
-    def override_acl(self, acl=None):
-        category_acl = self.user.acl_cache['categories'][self.category.pk]
+        # reset category ACL to single predictable state
         category_acl.update({
             'can_see': 1,
             'can_browse': 1,
@@ -39,14 +35,18 @@ class ThreadViewTestCase(AuthenticatedUserTestCase):
             'can_hide_events': 0,
         })
 
-        if acl:
-            category_acl.update(acl)
+        if new_acl:
+            category_acl.update(new_acl)
 
-        override_acl(self.user, {
-            'categories': {
-                self.category.pk: category_acl,
-            },
-        })
+    return patch_user_acl(patch_acl)
+
+
+class ThreadViewTestCase(AuthenticatedUserTestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.category = Category.objects.get(slug='first-category')
+        self.thread = testutils.post_thread(category=self.category)
 
 
 class ThreadVisibilityTests(ThreadViewTestCase):
@@ -57,66 +57,57 @@ class ThreadVisibilityTests(ThreadViewTestCase):
 
     def test_view_shows_owner_thread(self):
         """view handles "owned threads" only"""
-        self.override_acl({'can_see_all_threads': 0})
-
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertEqual(response.status_code, 404)
-
-        self.thread.starter = self.user
-        self.thread.save()
+        with patch_category_acl({'can_see_all_threads': 0}):
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertEqual(response.status_code, 404)
 
-        self.override_acl({'can_see_all_threads': 0})
+            self.thread.starter = self.user
+            self.thread.save()
 
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertContains(response, self.thread.title)
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertContains(response, self.thread.title)
 
     def test_view_validates_category_permissions(self):
         """view validates category visiblity"""
-        self.override_acl({'can_see': 0})
-
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertEqual(response.status_code, 404)
-
-        self.override_acl({'can_browse': 0})
+        with patch_category_acl({'can_see': 0}):
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertEqual(response.status_code, 404)
 
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertEqual(response.status_code, 404)
+        with patch_category_acl({'can_browse': 0}):
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertEqual(response.status_code, 404)
 
     def test_view_shows_unapproved_thread(self):
         """view handles unapproved thread"""
-        self.override_acl({'can_approve_content': 0})
-
-        self.thread.is_unapproved = True
-        self.thread.save()
+        with patch_category_acl({'can_approve_content': 0}):
+            self.thread.is_unapproved = True
+            self.thread.save()
 
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertEqual(response.status_code, 404)
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertEqual(response.status_code, 404)
 
         # grant permission to see unapproved content
-        self.override_acl({'can_approve_content': 1})
-
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertContains(response, self.thread.title)
-
-        # make test user thread's owner and remove permission to see unapproved
-        # user should be able to see thread as its author anyway
-        self.thread.starter = self.user
-        self.thread.save()
+        with patch_category_acl({'can_approve_content': 1}):
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertContains(response, self.thread.title)
 
-        self.override_acl({'can_approve_content': 0})
+            # make test user thread's owner and remove permission to see unapproved
+            # user should be able to see thread as its author anyway
+            self.thread.starter = self.user
+            self.thread.save()
 
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertContains(response, self.thread.title)
+        with patch_category_acl({'can_approve_content': 0}):
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertContains(response, self.thread.title)
 
     def test_view_shows_hidden_thread(self):
         """view handles hidden thread"""
-        self.override_acl({'can_hide_threads': 0})
-
-        self.thread.is_hidden = True
-        self.thread.save()
+        with patch_category_acl({'can_hide_threads': 0}):
+            self.thread.is_hidden = True
+            self.thread.save()
 
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertEqual(response.status_code, 404)
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertEqual(response.status_code, 404)
 
         # threads owners are not extempt from hidden threads check
         self.thread.starter = self.user
@@ -126,10 +117,9 @@ class ThreadVisibilityTests(ThreadViewTestCase):
         self.assertEqual(response.status_code, 404)
 
         # grant permission to see hidden content
-        self.override_acl({'can_hide_threads': 1})
-
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertContains(response, self.thread.title)
+        with patch_category_acl({'can_hide_threads': 1}):
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertContains(response, self.thread.title)
 
 
 class ThreadPostsVisibilityTests(ThreadViewTestCase):
@@ -172,23 +162,21 @@ class ThreadPostsVisibilityTests(ThreadViewTestCase):
         self.assertNotContains(response, post.parsed)
 
         # permission to hide own posts isn't enought to see post content
-        self.override_acl({'can_hide_own_posts': 1})
-
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertContains(response, post.get_absolute_url())
-        self.assertContains(response, "This post is hidden. You cannot not see its contents.")
-        self.assertNotContains(response, post.parsed)
+        with patch_category_acl({'can_hide_own_posts': 1}):
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertContains(response, post.get_absolute_url())
+            self.assertContains(response, "This post is hidden. You cannot not see its contents.")
+            self.assertNotContains(response, post.parsed)
 
         # post's content is displayed after permission to see posts is granted
-        self.override_acl({'can_hide_posts': 1})
-
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertContains(response, post.get_absolute_url())
-        self.assertContains(
-            response, "This post is hidden. Only users with permission may see its contents."
-        )
-        self.assertNotContains(response, "This post is hidden. You cannot not see its contents.")
-        self.assertContains(response, post.parsed)
+        with patch_category_acl({'can_hide_posts': 1}):
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertContains(response, post.get_absolute_url())
+            self.assertContains(
+                response, "This post is hidden. Only users with permission may see its contents."
+            )
+            self.assertNotContains(response, "This post is hidden. You cannot not see its contents.")
+            self.assertContains(response, post.parsed)
 
     def test_unapproved_post_visibility(self):
         """unapproved post renders for its author and users with perm to approve content"""
@@ -199,23 +187,21 @@ class ThreadPostsVisibilityTests(ThreadViewTestCase):
         self.assertNotContains(response, post.get_absolute_url())
 
         # post displays because we have permission to approve unapproved content
-        self.override_acl({'can_approve_content': 1})
-
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertContains(response, post.get_absolute_url())
-        self.assertContains(response, "This post is unapproved.")
-        self.assertContains(response, post.parsed)
+        with patch_category_acl({'can_approve_content': 1}):
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertContains(response, post.get_absolute_url())
+            self.assertContains(response, "This post is unapproved.")
+            self.assertContains(response, post.parsed)
 
         # post displays because we are its author
-        post.poster = self.user
-        post.save()
-
-        self.override_acl({'can_approve_content': 0})
+        with patch_category_acl({'can_approve_content': 0}):
+            post.poster = self.user
+            post.save()
 
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertContains(response, post.get_absolute_url())
-        self.assertContains(response, "This post is unapproved.")
-        self.assertContains(response, post.parsed)
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertContains(response, post.get_absolute_url())
+            self.assertContains(response, "This post is unapproved.")
+            self.assertContains(response, post.parsed)
 
 
 class ThreadEventVisibilityTests(ThreadViewTestCase):
@@ -236,51 +222,50 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         self.thread.save()
 
         for action, message in TEST_ACTIONS:
-            self.override_acl({'can_approve_content': 1, 'can_hide_threads': 1})
-
             self.thread.post_set.filter(is_event=True).delete()
-            action(MockRequest(self.user), self.thread)
 
-            event = self.thread.post_set.filter(is_event=True)[0]
+            with patch_category_acl({'can_approve_content': 1, 'can_hide_threads': 1}):
+                user_acl = useracl.get_user_acl(self.user, cache_versions)
+                request = Mock(user=self.user, user_acl=user_acl, user_ip="127.0.0.1")
+                action(request, self.thread)
 
-            # event renders
-            response = self.client.get(self.thread.get_absolute_url())
-            self.assertContains(response, event.get_absolute_url())
-            self.assertContains(response, message)
+                event = self.thread.post_set.filter(is_event=True)[0]
 
-            # hidden events don't render without permission
-            hide_post(self.user, event)
-            self.override_acl({'can_approve_content': 1, 'can_hide_threads': 1})
+                # event renders
+                response = self.client.get(self.thread.get_absolute_url())
+                self.assertContains(response, event.get_absolute_url())
+                self.assertContains(response, message)
 
-            response = self.client.get(self.thread.get_absolute_url())
-            self.assertNotContains(response, event.get_absolute_url())
-            self.assertNotContains(response, message)
+            # hidden events don't render without permission
+            with patch_category_acl({'can_approve_content': 1, 'can_hide_threads': 1}):
+                hide_post(self.user, event)
+                response = self.client.get(self.thread.get_absolute_url())
+                self.assertNotContains(response, event.get_absolute_url())
+                self.assertNotContains(response, message)
 
             # hidden event renders with permission
-            hide_post(self.user, event)
-            self.override_acl({
+            with patch_category_acl({
                 'can_approve_content': 1,
                 'can_hide_threads': 1,
                 'can_hide_events': 1,
-            })
-
-            response = self.client.get(self.thread.get_absolute_url())
-            self.assertContains(response, event.get_absolute_url())
-            self.assertContains(response, message)
-            self.assertContains(response, "Hidden by")
+            }):
+                hide_post(self.user, event)
+                response = self.client.get(self.thread.get_absolute_url())
+                self.assertContains(response, event.get_absolute_url())
+                self.assertContains(response, message)
+                self.assertContains(response, "Hidden by")
 
             # Event is only loaded if thread has events flag
-            self.thread.has_events = False
-            self.thread.save()
-
-            self.override_acl({
+            with patch_category_acl({
                 'can_approve_content': 1,
                 'can_hide_threads': 1,
                 'can_hide_events': 1,
-            })
+            }):
+                self.thread.has_events = False
+                self.thread.save()
 
-            response = self.client.get(self.thread.get_absolute_url())
-            self.assertNotContains(response, event.get_absolute_url())
+                response = self.client.get(self.thread.get_absolute_url())
+                self.assertNotContains(response, event.get_absolute_url())
 
     def test_events_limit(self):
         """forum will trim oldest events if theres more than allowed by config"""
@@ -288,7 +273,8 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         events = []
 
         for _ in range(events_limit + 5):
-            event = record_event(MockRequest(self.user), self.thread, 'closed')
+            request = Mock(user=self.user, user_ip="127.0.0.1")
+            event = record_event(request, self.thread, 'closed')
             events.append(event)
 
         # test that only events within limits were rendered
@@ -306,7 +292,8 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         events = []
 
         for _ in range(events_limit + 5):
-            event = record_event(MockRequest(self.user), self.thread, 'closed')
+            request = Mock(user=self.user, user_ip="127.0.0.1")
+            event = record_event(request, self.thread, 'closed')
             events.append(event)
 
         posts = []
@@ -326,7 +313,8 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         for _ in range(posts_limit):
             post = testutils.reply_thread(self.thread)
         for _ in range(events_limit):
-            event = record_event(MockRequest(self.user), self.thread, 'closed')
+            request = Mock(user=self.user, user_ip="127.0.0.1")
+            event = record_event(request, self.thread, 'closed')
             events.append(event)
 
         # see first page
@@ -346,8 +334,9 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
 
     def test_changed_thread_title_event_renders(self):
         """changed thread title event renders"""
+        request = Mock(user=self.user, user_ip="127.0.0.1")
         threads_moderation.change_thread_title(
-            MockRequest(self.user), self.thread, "Lorem renamed ipsum!"
+            request, self.thread, "Lorem renamed ipsum!"
         )
 
         event = self.thread.post_set.filter(is_event=True)[0]
@@ -364,7 +353,8 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         self.thread.category = self.thread.category.parent
         self.thread.save()
 
-        threads_moderation.move_thread(MockRequest(self.user), self.thread, self.category)
+        request = Mock(user=self.user, user_ip="127.0.0.1")
+        threads_moderation.move_thread(request, self.thread, self.category)
 
         event = self.thread.post_set.filter(is_event=True)[0]
         self.assertEqual(event.event_type, 'moved')
@@ -376,8 +366,9 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
 
     def test_thread_merged_event_renders(self):
         """merged thread event renders"""
+        request = Mock(user=self.user, user_ip="127.0.0.1")
         other_thread = testutils.post_thread(category=self.category)
-        threads_moderation.merge_thread(MockRequest(self.user), self.thread, other_thread)
+        threads_moderation.merge_thread(request, self.thread, other_thread)
 
         event = self.thread.post_set.filter(is_event=True)[0]
         self.assertEqual(event.event_type, 'merged')
@@ -494,21 +485,22 @@ class ThreadLikedPostsViewTests(ThreadViewTestCase):
         """
         testutils.like_post(self.thread.first_post, self.user)
 
-        self.override_acl({'can_see_posts_likes': 0})
-
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertNotContains(response, '"is_liked": true')
-        self.assertNotContains(response, '"is_liked": false')
-        self.assertContains(response, '"is_liked": null')
+        with patch_category_acl({'can_see_posts_likes': 0}):
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertNotContains(response, '"is_liked": true')
+            self.assertNotContains(response, '"is_liked": false')
+            self.assertContains(response, '"is_liked": null')
 
 
 class ThreadAnonViewTests(ThreadViewTestCase):
     def test_anonymous_user_view_no_showstoppers_display(self):
         """kitchensink thread view has no showstoppers for anons"""
+        request = Mock(user=self.user, user_ip="127.0.0.1")
+        
         poll = testutils.post_poll(self.thread, self.user)
-        event = record_event(MockRequest(self.user), self.thread, 'closed')
+        event = record_event(request, self.thread, 'closed')
 
-        hidden_event = record_event(MockRequest(self.user), self.thread, 'opened')
+        hidden_event = record_event(request, self.thread, 'opened')
         hide_post(self.user, hidden_event)
 
         unapproved_post = testutils.reply_thread(self.thread, is_unapproved=True)
@@ -528,26 +520,21 @@ class ThreadUnicodeSupportTests(ThreadViewTestCase):
     def test_category_name(self):
         """unicode in category name causes no showstopper"""
         self.category.name = 'Łódź'
-        self.category.slug = 'Lodz'
-
         self.category.save()
 
-        self.override_acl()
-
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
+        with patch_category_acl():
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertEqual(response.status_code, 200)
 
     def test_thread_title(self):
         """unicode in thread title causes no showstopper"""
         self.thread.title = 'Łódź'
         self.thread.slug = 'Lodz'
-
         self.thread.save()
 
-        self.override_acl()
-
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
+        with patch_category_acl():
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertEqual(response.status_code, 200)
 
     def test_post_content(self):
         """unicode in thread title causes no showstopper"""
@@ -555,24 +542,20 @@ class ThreadUnicodeSupportTests(ThreadViewTestCase):
         self.thread.first_post.parsed = '<p>Łódź</p>'
 
         update_post_checksum(self.thread.first_post)
-
         self.thread.first_post.save()
 
-        self.override_acl()
-
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
+        with patch_category_acl():
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertEqual(response.status_code, 200)
 
     def test_user_rank(self):
         """unicode in user rank causes no showstopper"""
         self.user.title = 'Łódź'
         self.user.rank.name = 'Łódź'
         self.user.rank.title = 'Łódź'
-
         self.user.rank.save()
         self.user.save()
 
-        self.override_acl()
-
-        response = self.client.get(self.thread.get_absolute_url())
-        self.assertEqual(response.status_code, 200)
+        with patch_category_acl():
+            response = self.client.get(self.thread.get_absolute_url())
+            self.assertEqual(response.status_code, 200)

+ 3 - 3
misago/threads/validators.py

@@ -12,7 +12,7 @@ from misago.core.validators import validate_sluggable
 from .threadtypes import trees_map
 
 
-def validate_category(user, category_id, allow_root=False):
+def validate_category(user_acl, category_id, allow_root=False):
     try:
         threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
         category = Category.objects.get(
@@ -26,10 +26,10 @@ def validate_category(user, category_id, allow_root=False):
     if allow_root and category and not category.level:
         return category
 
-    if not category or not can_see_category(user, category):
+    if not category or not can_see_category(user_acl, category):
         raise ValidationError(_("Requested category could not be found."))
 
-    if not can_browse_category(user, category):
+    if not can_browse_category(user_acl, category):
         raise ValidationError(_("You don't have permission to access this category."))
     return category
 

+ 6 - 6
misago/threads/viewmodels/category.py

@@ -1,6 +1,6 @@
 from django.http import Http404
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.categories.permissions import allow_browse_category, allow_see_category
 from misago.categories.serializers import CategorySerializer
@@ -15,7 +15,7 @@ __all__ = ['ThreadsRootCategory', 'ThreadsCategory', 'PrivateThreadsCategory']
 class ViewModel(BaseViewModel):
     def __init__(self, request, **kwargs):
         self._categories = self.get_categories(request)
-        add_acl(request.user, self._categories)
+        add_acl_to_obj(request.user_acl, self._categories)
 
         self._model = self.get_category(request, self._categories, **kwargs)
 
@@ -51,7 +51,7 @@ class ThreadsRootCategory(ViewModel):
     def get_categories(self, request):
         return [Category.objects.root_category()] + list(
             Category.objects.all_categories().filter(
-                id__in=request.user.acl_cache['visible_categories'],
+                id__in=request.user_acl['visible_categories'],
             ).select_related('parent')
         )
 
@@ -66,8 +66,8 @@ class ThreadsCategory(ThreadsRootCategory):
             if category.pk == int(kwargs['pk']):
                 if not category.special_role:
                     # check permissions for non-special categories
-                    allow_see_category(request.user, category)
-                    allow_browse_category(request.user, category)
+                    allow_see_category(request.user_acl, category)
+                    allow_browse_category(request.user_acl, category)
 
                 if 'slug' in kwargs:
                     validate_slug(category, kwargs['slug'])
@@ -81,7 +81,7 @@ class PrivateThreadsCategory(ViewModel):
         return [Category.objects.private_threads()]
 
     def get_category(self, request, categories, **kwargs):
-        allow_use_private_threads(request.user)
+        allow_use_private_threads(request.user_acl)
 
         return categories[0]
 

+ 3 - 3
misago/threads/viewmodels/post.py

@@ -1,6 +1,6 @@
 from django.shortcuts import get_object_or_404
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.viewmodel import ViewModel as BaseViewModel
 from misago.threads.permissions import exclude_invisible_posts
 
@@ -12,7 +12,7 @@ class ViewModel(BaseViewModel):
     def __init__(self, request, thread, pk):
         model = self.get_post(request, thread, pk)
 
-        add_acl(request.user, model)
+        add_acl_to_obj(request.user_acl, model)
 
         self._model = model
 
@@ -36,7 +36,7 @@ class ViewModel(BaseViewModel):
         return post
 
     def get_queryset(self, request, thread):
-        return exclude_invisible_posts(request.user, thread.category, thread.post_set)
+        return exclude_invisible_posts(request.user_acl, thread.category, thread.post_set)
 
 
 class ThreadPost(ViewModel):

+ 4 - 4
misago/threads/viewmodels/posts.py

@@ -1,4 +1,4 @@
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.readtracker.poststracker import make_read_aware
@@ -61,7 +61,7 @@ class ViewModel(object):
             posts.sort(key=lambda p: p.pk)
 
         # make posts and events ACL and reads aware
-        add_acl(request.user, posts)
+        add_acl_to_obj(request.user_acl, posts)
         make_read_aware(request.user, posts)
 
         self._user = request.user
@@ -77,7 +77,7 @@ class ViewModel(object):
             'poster__ban_cache',
             'poster__online_tracker',
         ).filter(is_event=False).order_by('id')
-        return exclude_invisible_posts(request.user, thread.category, queryset)
+        return exclude_invisible_posts(request.user_acl, thread.category, queryset)
 
     def get_events_queryset(self, request, thread, limit, first_post=None, last_post=None):
         queryset = thread.post_set.select_related(
@@ -93,7 +93,7 @@ class ViewModel(object):
         if last_post:
             queryset = queryset.filter(pk__lt=last_post.pk)
 
-        queryset = exclude_invisible_posts(request.user, thread.category, queryset)
+        queryset = exclude_invisible_posts(request.user_acl, thread.category, queryset)
         return list(queryset.order_by('-id')[:limit])
 
     def get_frontend_context(self):

+ 8 - 8
misago/threads/viewmodels/thread.py

@@ -1,7 +1,7 @@
 from django.shortcuts import get_object_or_404
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories import PRIVATE_THREADS_ROOT_NAME, THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.core.shortcuts import validate_slug
@@ -44,11 +44,11 @@ class ViewModel(BaseViewModel):
         if path_aware:
             model.path = self.get_thread_path(model.category)
 
-        add_acl(request.user, model.category)
-        add_acl(request.user, model)
+        add_acl_to_obj(request.user_acl, model.category)
+        add_acl_to_obj(request.user_acl, model)
 
         if read_aware:
-            make_read_aware(request.user, model)
+            make_read_aware(request.user, request.user_acl, model)
         if subscription_aware:
             make_subscription_aware(request.user, model)
 
@@ -56,7 +56,7 @@ class ViewModel(BaseViewModel):
 
         try:
             self._poll = model.poll
-            add_acl(request.user, self._poll)
+            add_acl_to_obj(request.user_acl, self._poll)
 
             if poll_votes_aware:
                 self._poll.make_choices_votes_aware(request.user)
@@ -109,7 +109,7 @@ class ForumThread(ViewModel):
             category__tree_id=trees_map.get_tree_id_for_root(THREADS_ROOT_NAME),
         )
 
-        allow_see_thread(request.user, thread)
+        allow_see_thread(request.user_acl, thread)
         if slug:
             validate_slug(thread, slug)
         return thread
@@ -123,7 +123,7 @@ class ForumThread(ViewModel):
 
 class PrivateThread(ViewModel):
     def get_thread(self, request, pk, slug=None):
-        allow_use_private_threads(request.user)
+        allow_use_private_threads(request.user_acl)
 
         thread = get_object_or_404(
             Thread.objects.select_related(*BASE_RELATIONS),
@@ -132,7 +132,7 @@ class PrivateThread(ViewModel):
         )
 
         make_participants_aware(request.user, thread)
-        allow_see_private_thread(request.user, thread)
+        allow_see_private_thread(request.user_acl, thread)
 
         if slug:
             validate_slug(thread, slug)

+ 17 - 16
misago/threads/viewmodels/threads.py

@@ -7,7 +7,7 @@ from django.utils import timezone
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext_lazy
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.readtracker import threadstracker
@@ -19,7 +19,6 @@ from misago.threads.serializers import ThreadsListSerializer
 from misago.threads.subscriptions import make_subscription_aware
 from misago.threads.utils import add_categories_to_items
 
-
 __all__ = ['ForumThreads', 'PrivateThreads', 'filter_read_threads_queryset']
 
 LISTS_NAMES = {
@@ -69,7 +68,7 @@ class ViewModel(object):
             threads = list(pinned_threads) + list(list_page.object_list)
 
         add_categories_to_items(category_model, category.categories, threads)
-        add_acl(request.user, threads)
+        add_acl_to_obj(request.user_acl, threads)
         make_subscription_aware(request.user, threads)
 
         if list_type in ('new', 'unread'):
@@ -78,7 +77,7 @@ class ViewModel(object):
                 thread.is_read = False
                 thread.is_new = True
         else:
-            threadstracker.make_read_aware(request.user, threads)
+            threadstracker.make_read_aware(request.user, request.user_acl, threads)
 
         self.filter_threads(request, threads)
 
@@ -96,7 +95,7 @@ class ViewModel(object):
             if list_type in LIST_DENIED_MESSAGES:
                 raise PermissionDenied(LIST_DENIED_MESSAGES[list_type])
         else:
-            has_permission = request.user.acl_cache['can_see_unapproved_content_lists']
+            has_permission = request.user_acl['can_see_unapproved_content_lists']
             if list_type == 'unapproved' and not has_permission:
                 raise PermissionDenied(
                     _("You don't have permission to see unapproved content lists.")
@@ -107,7 +106,7 @@ class ViewModel(object):
 
     def get_base_queryset(self, request, threads_categories, list_type):
         return get_threads_queryset(
-            request.user,
+            request,
             threads_categories,
             list_type,
         ).order_by('-last_post_id')
@@ -169,7 +168,7 @@ class PrivateThreads(ViewModel):
         # limit queryset to threads we are participant of
         participated_threads = request.user.threadparticipant_set.values('thread_id')
 
-        if request.user.acl_cache['can_moderate_private_threads']:
+        if request.user_acl['can_moderate_private_threads']:
             queryset = queryset.filter(Q(id__in=participated_threads) | Q(has_reported_posts=True))
         else:
             queryset = queryset.filter(id__in=participated_threads)
@@ -183,35 +182,37 @@ class PrivateThreads(ViewModel):
         make_participants_aware(request.user, threads)
 
 
-def get_threads_queryset(user, categories, list_type):
-    queryset = exclude_invisible_threads(user, categories, Thread.objects)
+def get_threads_queryset(request, categories, list_type):
+    queryset = exclude_invisible_threads(request.user_acl, categories, Thread.objects)
 
     if list_type == 'all':
         return queryset
     else:
-        return filter_threads_queryset(user, categories, list_type, queryset)
+        return filter_threads_queryset(request, categories, list_type, queryset)
 
 
-def filter_threads_queryset(user, categories, list_type, queryset):
+def filter_threads_queryset(request, categories, list_type, queryset):
     if list_type == 'my':
-        return queryset.filter(starter=user)
+        return queryset.filter(starter=request.user)
     elif list_type == 'subscribed':
-        subscribed_threads = user.subscription_set.values('thread_id')
+        subscribed_threads = request.user.subscription_set.values('thread_id')
         return queryset.filter(id__in=subscribed_threads)
     elif list_type == 'unapproved':
         return queryset.filter(has_unapproved_posts=True)
     elif list_type in ('new', 'unread'):
-        return filter_read_threads_queryset(user, categories, list_type, queryset)
+        return filter_read_threads_queryset(request, categories, list_type, queryset)
     else:
         return queryset
 
 
-def filter_read_threads_queryset(user, categories, list_type, queryset):
+def filter_read_threads_queryset(request, categories, list_type, queryset):
     # grab cutoffs for categories
+    user = request.user
+
     cutoff_date = get_cutoff_date(user)
 
     visible_posts = Post.objects.filter(posted_on__gt=cutoff_date)
-    visible_posts = exclude_invisible_posts(user, categories, visible_posts)
+    visible_posts = exclude_invisible_posts(request.user_acl, categories, visible_posts)
 
     queryset = queryset.filter(id__in=visible_posts.distinct().values('thread'))
 

+ 1 - 1
misago/threads/views/attachment.py

@@ -50,7 +50,7 @@ def allow_file_download(request, attachment):
     if not is_authenticated or request.user.id != attachment.uploader_id:
         if not attachment.post_id:
             raise Http404()
-        if not request.user.acl_cache['can_download_other_users_attachments']:
+        if not request.user_acl['can_download_other_users_attachments']:
             raise PermissionDenied()
 
     allowed_roles = set(r.pk for r in attachment.filetype.limit_downloads_to.all())

+ 6 - 3
misago/threads/views/goto.py

@@ -19,9 +19,13 @@ class GotoView(View):
         thread = self.get_thread(request, pk, slug).unwrap()
         self.test_permissions(request, thread)
 
-        posts_queryset = exclude_invisible_posts(request.user, thread.category, thread.post_set)
+        posts_queryset = exclude_invisible_posts(
+            request.user_acl, thread.category, thread.post_set
+        )
 
-        target_post = self.get_target_post(request.user, thread, posts_queryset.order_by('id'), **kwargs)
+        target_post = self.get_target_post(
+            request.user, thread, posts_queryset.order_by('id'), **kwargs
+        )
         target_page = self.compute_post_page(target_post, posts_queryset)
 
         return self.get_redirect(thread, target_post, target_page)
@@ -38,7 +42,6 @@ class GotoView(View):
     def compute_post_page(self, target_post, posts_queryset):
         # filter out events, order queryset
         posts_queryset = posts_queryset.filter(is_event=False).order_by('id')
-
         thread_length = posts_queryset.count()
 
         # is target an event?

+ 4 - 3
misago/users/api/auth.py

@@ -56,11 +56,12 @@ def login(request):
 def session_user(request):
     """GET /auth/ will return current auth user, either User or AnonymousUser"""
     if request.user.is_authenticated:
-        UserSerializer = AuthenticatedUserSerializer
+        serializer = AuthenticatedUserSerializer
     else:
-        UserSerializer = AnonymousUserSerializer
+        serializer = AnonymousUserSerializer
 
-    return Response(UserSerializer(request.user).data)
+    serialized_user = serializer(request.user, context={"acl": request.user_acl}).data
+    return Response(serialized_user)
 
 
 @api_view(['GET'])

+ 5 - 4
misago/users/api/userendpoints/signature.py

@@ -11,11 +11,11 @@ from misago.users.signatures import is_user_signature_valid, set_user_signature
 
 
 def signature_endpoint(request):
-    user = request.user
-
-    if not user.acl_cache['can_have_signature']:
+    if not request.user_acl['can_have_signature']:
         raise PermissionDenied(_("You don't have permission to change signature."))
 
+    user = request.user
+    
     if user.is_signature_locked:
         if user.signature_lock_user_message:
             reason = format_plaintext_for_html(user.signature_lock_user_message)
@@ -55,7 +55,8 @@ def get_signature_options(user):
 def edit_signature(request, user):
     serializer = EditSignatureSerializer(user, data=request.data)
     if serializer.is_valid():
-        set_user_signature(request, user, serializer.validated_data['signature'])
+        signature = serializer.validated_data['signature']
+        set_user_signature(request, user, request.user_acl, signature)
         user.save(update_fields=['signature', 'signature_parsed', 'signature_checksum'])
         return get_signature_options(user)
     else:

+ 47 - 34
misago/users/api/userendpoints/username.py

@@ -5,7 +5,7 @@ from django.db import IntegrityError
 from django.utils.translation import gettext as _
 
 from misago.conf import settings
-from misago.users.namechanges import UsernameChanges
+from misago.users.namechanges import get_username_options
 from misago.users.serializers import ChangeUsernameSerializer
 
 
@@ -13,17 +13,14 @@ def username_endpoint(request):
     if request.method == 'POST':
         return change_username(request)
     else:
-        return options_response(get_username_options(request.user))
+        options = get_username_options_from_request(request)
+        return options_response(options)
 
 
-def get_username_options(user):
-    options = UsernameChanges(user)
-    return {
-        'changes_left': options.left,
-        'next_on': options.next_on,
-        'length_min': settings.username_length_min,
-        'length_max': settings.username_length_max,
-    }
+def get_username_options_from_request(request):
+    return get_username_options(
+        settings, request.user, request.user_acl
+    )
 
 
 def options_response(options):
@@ -33,34 +30,46 @@ def options_response(options):
 
 
 def change_username(request):
-    options = get_username_options(request.user)
+    options = get_username_options_from_request(request)
     if not options['changes_left']:
-        return Response({
-            'detail': _("You can't change your username now."),
-            'options': options
-        },
-                        status=status.HTTP_400_BAD_REQUEST)
+        return Response(
+            {
+                'detail': _("You can't change your username now."),
+                'options': options
+            },
+            status=status.HTTP_400_BAD_REQUEST
+        )
 
-    serializer = ChangeUsernameSerializer(data=request.data, context={'user': request.user})
+    serializer = ChangeUsernameSerializer(
+        data=request.data, context={'user': request.user}
+    )
 
     if serializer.is_valid():
         try:
             serializer.change_username(changed_by=request.user)
+            updated_options = get_username_options_from_request(request)
+            if updated_options['next_on']:
+                updated_options['next_on'] = updated_options['next_on'].isoformat()
+
             return Response({
                 'username': request.user.username,
                 'slug': request.user.slug,
-                'options': get_username_options(request.user)
+                'options': updated_options,
             })
         except IntegrityError:
-            return Response({
-                'detail': _("Error changing username. Please try again."),
-            },
-                            status=status.HTTP_400_BAD_REQUEST)
+            return Response(
+                {
+                    'detail': _("Error changing username. Please try again."),
+                },
+                status=status.HTTP_400_BAD_REQUEST
+            )
     else:
-        return Response({
-            'detail': serializer.errors['non_field_errors'][0]
-        },
-                        status=status.HTTP_400_BAD_REQUEST)
+        return Response(
+            {
+                'detail': serializer.errors['non_field_errors'][0]
+            },
+            status=status.HTTP_400_BAD_REQUEST
+        )
 
 
 def moderate_username_endpoint(request, profile):
@@ -75,15 +84,19 @@ def moderate_username_endpoint(request, profile):
                     'slug': profile.slug,
                 })
             except IntegrityError:
-                return Response({
-                    'detail': _("Error changing username. Please try again."),
-                },
-                                status=status.HTTP_400_BAD_REQUEST)
+                return Response(
+                    {
+                        'detail': _("Error changing username. Please try again."),
+                    },
+                    status=status.HTTP_400_BAD_REQUEST
+                )
         else:
-            return Response({
-                'detail': serializer.errors['non_field_errors'][0]
-            },
-                            status=status.HTTP_400_BAD_REQUEST)
+            return Response(
+                {
+                    'detail': serializer.errors['non_field_errors'][0]
+                },
+                status=status.HTTP_400_BAD_REQUEST
+            )
     else:
         return Response({
             'length_min': settings.username_length_min,

+ 1 - 1
misago/users/api/usernamechanges.py

@@ -26,7 +26,7 @@ class UsernameChangesViewSetPermission(BasePermission):
 
         if user_pk == request.user.pk:
             return True
-        elif not request.user.acl_cache.get('can_see_users_name_history'):
+        elif not request.user_acl.get('can_see_users_name_history'):
             raise PermissionDenied(_("You don't have permission to see other users name history."))
         return True
 

+ 10 - 10
misago/users/api/users.py

@@ -11,7 +11,7 @@ from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.utils.translation import gettext as _
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.core.rest_permissions import IsAuthenticatedOrReadOnly
@@ -75,7 +75,7 @@ class UserViewSet(viewsets.GenericViewSet):
         return user
 
     def list(self, request):
-        allow_browse_users_list(request.user)
+        allow_browse_users_list(request.user_acl)
         return list_endpoint(request)
 
     def create(self, request):
@@ -84,10 +84,10 @@ class UserViewSet(viewsets.GenericViewSet):
     def retrieve(self, request, pk=None):
         profile = self.get_user(request, pk)
 
-        add_acl(request.user, profile)
+        add_acl_to_obj(request.user_acl, profile)
         profile.status = get_user_status(request, profile)
 
-        serializer = UserProfileSerializer(profile, context={'user': request.user})
+        serializer = UserProfileSerializer(profile, context={'request': request})
         profile_json = serializer.data
 
         if not profile.is_active:
@@ -153,7 +153,7 @@ class UserViewSet(viewsets.GenericViewSet):
     @detail_route(methods=['get', 'post'])
     def edit_details(self, request, pk=None):
         profile = self.get_user(request, pk)
-        allow_edit_profile_details(request.user, profile)
+        allow_edit_profile_details(request.user_acl, profile)
         return edit_details_endpoint(request, profile)
 
     @detail_route(methods=['post'])
@@ -169,7 +169,7 @@ class UserViewSet(viewsets.GenericViewSet):
     @detail_route(methods=['post'])
     def follow(self, request, pk=None):
         profile = self.get_user(request, pk)
-        allow_follow_user(request.user, profile)
+        allow_follow_user(request.user_acl, profile)
 
         profile_followers = profile.followers
 
@@ -197,7 +197,7 @@ class UserViewSet(viewsets.GenericViewSet):
     @detail_route()
     def ban(self, request, pk=None):
         profile = self.get_user(request, pk)
-        allow_see_ban_details(request.user, profile)
+        allow_see_ban_details(request.user_acl, profile)
 
         ban = get_user_ban(profile, request.cache_versions)
         if ban:
@@ -208,14 +208,14 @@ class UserViewSet(viewsets.GenericViewSet):
     @detail_route(methods=['get', 'post'])
     def moderate_avatar(self, request, pk=None):
         profile = self.get_user(request, pk)
-        allow_moderate_avatar(request.user, profile)
+        allow_moderate_avatar(request.user_acl, profile)
 
         return moderate_avatar_endpoint(request, profile)
 
     @detail_route(methods=['get', 'post'])
     def moderate_username(self, request, pk=None):
         profile = self.get_user(request, pk)
-        allow_rename_user(request.user, profile)
+        allow_rename_user(request.user_acl, profile)
 
         return moderate_username_endpoint(request, profile)
 
@@ -238,7 +238,7 @@ class UserViewSet(viewsets.GenericViewSet):
     @detail_route(methods=['get', 'post'])
     def delete(self, request, pk=None):
         profile = self.get_user(request, pk)
-        allow_delete_user(request.user, profile)
+        allow_delete_user(request.user_acl, profile)
 
         if request.method == 'POST':
             with transaction.atomic():

+ 2 - 2
misago/users/apps.py

@@ -71,14 +71,14 @@ class MisagoUsersConfig(AppConfig):
         def can_see_names_history(request, profile):
             if request.user.is_authenticated:
                 is_account_owner = profile.pk == request.user.pk
-                has_permission = request.user.acl_cache['can_see_users_name_history']
+                has_permission = request.user_acl['can_see_users_name_history']
                 return is_account_owner or has_permission
             else:
                 return False
 
         def can_see_ban_details(request, profile):
             if request.user.is_authenticated:
-                if request.user.acl_cache['can_see_ban_details']:
+                if request.user_acl['can_see_ban_details']:
                     from .bans import get_user_ban
                     return bool(get_user_ban(profile, request.cache_versions))
                 else:

+ 5 - 2
misago/users/context_processors.py

@@ -36,8 +36,11 @@ def preload_user_json(request):
     })
 
     if request.user.is_authenticated:
-        request.frontend_context.update({'user': AuthenticatedUserSerializer(request.user).data})
+        serializer = AuthenticatedUserSerializer
     else:
-        request.frontend_context.update({'user': AnonymousUserSerializer(request.user).data})
+        serializer = AnonymousUserSerializer
+
+    serialized_user = serializer(request.user, context={"acl": request.user_acl}).data
+    request.frontend_context.update({'user': serialized_user})
 
     return {}

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

@@ -1,7 +1,7 @@
 from django.db import models, transaction
 from django.urls import reverse
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear_acl_cache
 from misago.core.utils import slugify
 
 
@@ -39,11 +39,11 @@ class Rank(models.Model):
         if not self.pk:
             self.set_order()
         else:
-            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)
 
     def get_absolute_url(self):

+ 21 - 28
misago/users/models/user.py

@@ -11,7 +11,6 @@ from django.urls import reverse
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
-from misago.acl import get_user_acl
 from misago.acl.models import Role
 from misago.conf import settings
 from misago.core.pgutils import PgPartialIndex
@@ -329,22 +328,6 @@ class User(AbstractBaseUser, PermissionsMixin):
         anonymize_user_data.send(sender=self)
 
     @property
-    def acl_cache(self):
-        try:
-            return self._acl_cache
-        except AttributeError:
-            self._acl_cache = get_user_acl(self)
-            return self._acl_cache
-
-    @acl_cache.setter
-    def acl_cache(self, value):
-        raise TypeError("acl_cache can't be assigned")
-
-    @property
-    def acl_(self):
-        raise NotImplementedError('user.acl_ property was renamed to user.acl')
-
-    @property
     def requires_activation_by_admin(self):
         return self.requires_activation == self.ACTIVATION_ADMIN
 
@@ -401,13 +384,17 @@ class User(AbstractBaseUser, PermissionsMixin):
 
             if self.pk:
                 changed_by = changed_by or self
-                self.record_name_change(changed_by, new_username, old_username)
+                namechange = self.record_name_change(
+                    changed_by, new_username, old_username
+                )
 
                 from misago.users.signals import username_changed
                 username_changed.send(sender=self)
 
+                return namechange
+
     def record_name_change(self, changed_by, new_username, old_username):
-        self.namechanges.create(
+        return self.namechanges.create(
             new_username=new_username,
             old_username=old_username,
             changed_by=changed_by,
@@ -453,16 +440,26 @@ class User(AbstractBaseUser, PermissionsMixin):
         """sends an email to this user (for compat with Django)"""
         send_mail(subject, message, from_email, [self.email], **kwargs)
 
-    def is_following(self, user):
+    def is_following(self, user_or_id):
         try:
-            self.follows.get(pk=user.pk)
+            user_id = user_or_id.id
+        except AttributeError:
+            user_id = user_or_id
+
+        try:
+            self.follows.get(id=user_id)
             return True
         except User.DoesNotExist:
             return False
 
-    def is_blocking(self, user):
+    def is_blocking(self, user_or_id):
         try:
-            self.blocks.get(pk=user.pk)
+            user_id = user_or_id.id
+        except AttributeError:
+            user_id = user_or_id
+
+        try:
+            self.blocks.get(id=user_id)
             return True
         except User.DoesNotExist:
             return False
@@ -515,11 +512,7 @@ class AnonymousUser(DjangoAnonymousUser):
 
     @property
     def acl_cache(self):
-        try:
-            return self._acl_cache
-        except AttributeError:
-            self._acl_cache = get_user_acl(self)
-            return self._acl_cache
+        raise Exception("AnonymousUser.acl_cache has been removed")
 
     @acl_cache.setter
     def acl_cache(self, value):

+ 41 - 29
misago/users/namechanges.py

@@ -8,32 +8,44 @@ from django.utils import timezone
 from .models import UsernameChange
 
 
-class UsernameChanges(object):
-    def __init__(self, user):
-        self.left = 0
-        self.next_on = None
-
-        if user.acl_cache['name_changes_allowed']:
-            self.count_namechanges(user)
-
-    def count_namechanges(self, user):
-        name_changes_allowed = user.acl_cache['name_changes_allowed']
-        name_changes_expire = user.acl_cache['name_changes_expire']
-
-        valid_changes_qs = user.namechanges.filter(changed_by=user)
-        if name_changes_expire:
-            cutoff = timezone.now() - timedelta(days=name_changes_expire)
-            valid_changes_qs = valid_changes_qs.filter(changed_on__gte=cutoff)
-
-        used_changes = valid_changes_qs.count()
-        if name_changes_allowed <= used_changes:
-            self.left = 0
-        else:
-            self.left = name_changes_allowed - used_changes
-
-        if not self.left and name_changes_expire:
-            try:
-                self.next_on = valid_changes_qs.latest().changed_on
-                self.next_on += timedelta(days=name_changes_expire)
-            except UsernameChange.DoesNotExist:
-                pass
+def get_username_options(settings, user, user_acl):
+    changes_left = get_left_namechanges(user, user_acl)
+    next_on = get_next_available_namechange(user, user_acl, changes_left)
+
+    return {
+        'changes_left': changes_left,
+        'next_on': next_on,
+        'length_min': settings.username_length_min,
+        'length_max': settings.username_length_max,
+    }
+
+
+def get_left_namechanges(user, user_acl):
+    name_changes_allowed = user_acl['name_changes_allowed']
+    if not name_changes_allowed:
+        return 0
+
+    valid_changes = get_valid_changes_queryset(user, user_acl)
+    used_changes = valid_changes.count()
+    if name_changes_allowed <= used_changes:
+        left = 0
+    return name_changes_allowed - used_changes
+
+
+def get_next_available_namechange(user, user_acl, changes_left):
+    name_changes_expire = user_acl['name_changes_expire']
+    if changes_left or not name_changes_expire:
+        return None
+    
+    valid_changes = get_valid_changes_queryset(user, user_acl)
+    name_last_changed_on = valid_changes.latest().changed_on
+    return name_last_changed_on + timedelta(days=name_changes_expire)
+
+
+def get_valid_changes_queryset(user, user_acl):
+    name_changes_expire = user_acl['name_changes_expire']
+    queryset = user.namechanges.filter(changed_by=user)
+    if user_acl['name_changes_expire']:
+        cutoff = timezone.now() - timedelta(days=name_changes_expire)
+        return queryset.filter(changed_on__gte=cutoff)
+    return queryset

+ 2 - 2
misago/users/online/utils.py

@@ -48,7 +48,7 @@ def get_user_status(request, user):
 
     try:
         online_tracker = user.online_tracker
-        is_hidden = user.is_hiding_presence and not request.user.acl_cache['can_see_hidden_users']
+        is_hidden = user.is_hiding_presence and not request.user_acl['can_see_hidden_users']
 
         if online_tracker and not is_hidden:
             if online_tracker.last_click >= timezone.now() - ACTIVITY_CUTOFF:
@@ -58,7 +58,7 @@ def get_user_status(request, user):
         pass
 
     if user_status['is_hidden']:
-        if request.user.acl_cache['can_see_hidden_users']:
+        if request.user_acl['can_see_hidden_users']:
             user_status['is_hidden'] = False
             if user_status['is_online']:
                 user_status['is_online_hidden'] = True

+ 13 - 11
misago/users/permissions/decorators.py

@@ -9,22 +9,24 @@ __all__ = [
 
 
 def authenticated_only(f):
-    def perm_decorator(user, target):
-        if user.is_authenticated:
-            return f(user, target)
-        else:
-            messsage = _("You have to sig in to perform this action.")
-            raise PermissionDenied(messsage)
+    def perm_decorator(user_acl, target):
+        if user_acl["is_authenticated"]:
+            return f(user_acl, target)
+        else: 
+            raise PermissionDenied(
+                _("You have to sig in to perform this action.")
+            )
 
     return perm_decorator
 
 
 def anonymous_only(f):
-    def perm_decorator(user, target):
-        if user.is_anonymous:
-            return f(user, target)
+    def perm_decorator(user_acl, target):
+        if user_acl["is_anonymous"]:
+            return f(user_acl, target)
         else:
-            messsage = _("Only guests can perform this action.")
-            raise PermissionDenied(messsage)
+            raise PermissionDenied(
+                _("Only guests can perform this action.")
+            )
 
     return perm_decorator

+ 7 - 7
misago/users/permissions/delete.py

@@ -61,8 +61,8 @@ def build_acl(acl, roles, key_name):
     )
 
 
-def add_acl_to_user(user, target):
-    target.acl['can_delete'] = can_delete_user(user, target)
+def add_acl_to_user(user_acl, target):
+    target.acl['can_delete'] = can_delete_user(user_acl, target)
     if target.acl['can_delete']:
         target.acl['can_moderate'] = True
 
@@ -71,13 +71,13 @@ def register_with(registry):
     registry.acl_annotator(get_user_model(), add_acl_to_user)
 
 
-def allow_delete_user(user, target):
-    newer_than = user.acl_cache['can_delete_users_newer_than']
-    less_posts_than = user.acl_cache['can_delete_users_with_less_posts_than']
+def allow_delete_user(user_acl, target):
+    newer_than = user_acl['can_delete_users_newer_than']
+    less_posts_than = user_acl['can_delete_users_with_less_posts_than']
     if not newer_than and not less_posts_than:
         raise PermissionDenied(_("You can't delete users."))
 
-    if user.pk == target.pk:
+    if user_acl["user_id"] == target.id:
         raise PermissionDenied(_("You can't delete your account."))
     if target.is_staff or target.is_superuser:
         raise PermissionDenied(_("You can't delete administrators."))
@@ -106,7 +106,7 @@ can_delete_user = return_boolean(allow_delete_user)
 def allow_delete_own_account(user, target):
     if not settings.MISAGO_ENABLE_DELETE_OWN_ACCOUNT and not user.is_deleting_account:
         raise PermissionDenied(_("You can't delete your account."))
-    if user.pk != target.pk:
+    if user.id != target.id:
         raise PermissionDenied(_("You can't delete other users accounts."))
     if user.is_staff or user.is_superuser:
         raise PermissionDenied(

+ 28 - 28
misago/users/permissions/moderation.py

@@ -88,14 +88,14 @@ def build_acl(acl, roles, key_name):
     )
 
 
-def add_acl_to_user(user, target):
-    target.acl['can_rename'] = can_rename_user(user, target)
-    target.acl['can_moderate_avatar'] = can_moderate_avatar(user, target)
-    target.acl['can_moderate_signature'] = can_moderate_signature(user, target)
-    target.acl['can_edit_profile_details'] = can_edit_profile_details(user, target)
-    target.acl['can_ban'] = can_ban_user(user, target)
-    target.acl['max_ban_length'] = user.acl_cache['max_ban_length']
-    target.acl['can_lift_ban'] = can_lift_ban(user, target)
+def add_acl_to_user(user_acl, target):
+    target.acl['can_rename'] = can_rename_user(user_acl, target)
+    target.acl['can_moderate_avatar'] = can_moderate_avatar(user_acl, target)
+    target.acl['can_moderate_signature'] = can_moderate_signature(user_acl, target)
+    target.acl['can_edit_profile_details'] = can_edit_profile_details(user_acl, target)
+    target.acl['can_ban'] = can_ban_user(user_acl, target)
+    target.acl['max_ban_length'] = user_acl['max_ban_length']
+    target.acl['can_lift_ban'] = can_lift_ban(user_acl, target)
 
     mod_permissions = [
         'can_rename',
@@ -113,30 +113,30 @@ def register_with(registry):
     registry.acl_annotator(get_user_model(), add_acl_to_user)
 
 
-def allow_rename_user(user, target):
-    if not user.acl_cache['can_rename_users']:
+def allow_rename_user(user_acl, target):
+    if not user_acl['can_rename_users']:
         raise PermissionDenied(_("You can't rename users."))
-    if not user.is_superuser and (target.is_staff or target.is_superuser):
+    if not user_acl["is_superuser"] and (target.is_staff or target.is_superuser):
         raise PermissionDenied(_("You can't rename administrators."))
 
 
 can_rename_user = return_boolean(allow_rename_user)
 
 
-def allow_moderate_avatar(user, target):
-    if not user.acl_cache['can_moderate_avatars']:
+def allow_moderate_avatar(user_acl, target):
+    if not user_acl['can_moderate_avatars']:
         raise PermissionDenied(_("You can't moderate avatars."))
-    if not user.is_superuser and (target.is_staff or target.is_superuser):
+    if not user_acl["is_superuser"] and (target.is_staff or target.is_superuser):
         raise PermissionDenied(_("You can't moderate administrators avatars."))
 
 
 can_moderate_avatar = return_boolean(allow_moderate_avatar)
 
 
-def allow_moderate_signature(user, target):
-    if not user.acl_cache['can_moderate_signatures']:
+def allow_moderate_signature(user_acl, target):
+    if not user_acl['can_moderate_signatures']:
         raise PermissionDenied(_("You can't moderate signatures."))
-    if not user.is_superuser and (target.is_staff or target.is_superuser):
+    if not user_acl["is_superuser"] and (target.is_staff or target.is_superuser):
         message = _("You can't moderate administrators signatures.")
         raise PermissionDenied(message)
 
@@ -144,12 +144,12 @@ def allow_moderate_signature(user, target):
 can_moderate_signature = return_boolean(allow_moderate_signature)
 
 
-def allow_edit_profile_details(user, target):
-    if user.is_anonymous:
+def allow_edit_profile_details(user_acl, target):
+    if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to edit profile details."))
-    if user != target and not user.acl_cache['can_moderate_profile_details']:
+    if user_acl["user_id"] != target.id and not user_acl['can_moderate_profile_details']:
         raise PermissionDenied(_("You can't edit other users details."))
-    if not user.is_superuser and (target.is_staff or target.is_superuser):
+    if not user_acl["is_superuser"] and (target.is_staff or target.is_superuser):
         message = _("You can't edit administrators details.")
         raise PermissionDenied(message)
 
@@ -157,8 +157,8 @@ def allow_edit_profile_details(user, target):
 can_edit_profile_details = return_boolean(allow_edit_profile_details)
 
 
-def allow_ban_user(user, target):
-    if not user.acl_cache['can_ban_users']:
+def allow_ban_user(user_acl, target):
+    if not user_acl['can_ban_users']:
         raise PermissionDenied(_("You can't ban users."))
     if target.is_staff or target.is_superuser:
         raise PermissionDenied(_("You can't ban administrators."))
@@ -167,14 +167,14 @@ def allow_ban_user(user, target):
 can_ban_user = return_boolean(allow_ban_user)
 
 
-def allow_lift_ban(user, target):
-    if not user.acl_cache['can_lift_bans']:
+def allow_lift_ban(user_acl, target):
+    if not user_acl['can_lift_bans']:
         raise PermissionDenied(_("You can't lift bans."))
-    ban = get_user_ban(target)
+    ban = get_user_ban(target, user_acl["cache_versions"])
     if not ban:
         raise PermissionDenied(_("This user is not banned."))
-    if user.acl_cache['max_lifted_ban_length']:
-        expiration_limit = timedelta(days=user.acl_cache['max_lifted_ban_length'])
+    if user_acl['max_lifted_ban_length']:
+        expiration_limit = timedelta(days=user_acl['max_lifted_ban_length'])
         lift_cutoff = (timezone.now() + expiration_limit).date()
         if not ban.valid_until:
             raise PermissionDenied(_("You can't lift permanent bans."))

+ 13 - 15
misago/users/permissions/profiles.py

@@ -92,10 +92,10 @@ def build_acl(acl, roles, key_name):
     )
 
 
-def add_acl_to_user(user, target):
+def add_acl_to_user(user_acl, target):
     target.acl['can_have_attitude'] = False
-    target.acl['can_follow'] = can_follow_user(user, target)
-    target.acl['can_block'] = can_block_user(user, target)
+    target.acl['can_follow'] = can_follow_user(user_acl, target)
+    target.acl['can_block'] = can_block_user(user_acl, target)
 
     mod_permissions = ('can_have_attitude', 'can_follow', 'can_block', )
 
@@ -109,8 +109,8 @@ def register_with(registry):
     registry.acl_annotator(get_user_model(), add_acl_to_user)
 
 
-def allow_browse_users_list(user):
-    if not user.acl_cache['can_browse_users_list']:
+def allow_browse_users_list(user_acl):
+    if not user_acl['can_browse_users_list']:
         raise PermissionDenied(_("You can't browse users list."))
 
 
@@ -118,10 +118,10 @@ can_browse_users_list = return_boolean(allow_browse_users_list)
 
 
 @authenticated_only
-def allow_follow_user(user, target):
-    if not user.acl_cache['can_follow_users']:
+def allow_follow_user(user_acl, target):
+    if not user_acl['can_follow_users']:
         raise PermissionDenied(_("You can't follow other users."))
-    if user.pk == target.pk:
+    if user_acl["user_id"] == target.id:
         raise PermissionDenied(_("You can't add yourself to followed."))
 
 
@@ -129,22 +129,20 @@ can_follow_user = return_boolean(allow_follow_user)
 
 
 @authenticated_only
-def allow_block_user(user, target):
+def allow_block_user(user_acl, target):
     if target.is_staff or target.is_superuser:
         raise PermissionDenied(_("You can't block administrators."))
-    if user.pk == target.pk:
+    if user_acl["user_id"] == target.id:
         raise PermissionDenied(_("You can't block yourself."))
-    if not target.acl_cache['can_be_blocked'] or target.is_superuser:
-        message = _("%(user)s can't be blocked.") % {'user': target.username}
-        raise PermissionDenied(message)
+    # FIXME: check if user has "can be blocked" permission
 
 
 can_block_user = return_boolean(allow_block_user)
 
 
 @authenticated_only
-def allow_see_ban_details(user, target):
-    if not user.acl_cache['can_see_ban_details']:
+def allow_see_ban_details(user_acl, target):
+    if not user_acl['can_see_ban_details']:
         raise PermissionDenied(_("You can't see users bans details."))
 
 

+ 1 - 1
misago/users/profilefields/default.py

@@ -84,7 +84,7 @@ class JoinIpField(basefields.TextProfileField):
     readonly = True
 
     def get_value_display_data(self, request, user, value):
-        if not request.user.acl_cache.get('can_see_users_ips'):
+        if not request.user_acl.get('can_see_users_ips'):
             return None
 
         if not user.joined_from_ip:

+ 1 - 1
misago/users/profilefields/serializers.py

@@ -8,7 +8,7 @@ def serialize_profilefields_data(request, profilefields, user):
         'edit': False,
     }
 
-    can_edit = can_edit_profile_details(request.user, user)
+    can_edit = can_edit_profile_details(request.user_acl, user)
     has_editable_fields = False
 
     for group in profilefields.get_fields_groups():

+ 1 - 1
misago/users/search.py

@@ -20,7 +20,7 @@ class SearchUsers(SearchProvider):
     url = 'users'
 
     def allow_search(self):
-        if not self.request.user.acl_cache['can_search_users']:
+        if not self.request.user_acl['can_search_users']:
             raise PermissionDenied(_("You don't have permission to search users."))
 
     def search(self, query, page=1):

+ 9 - 7
misago/users/serializers/auth.py

@@ -3,11 +3,10 @@ from rest_framework import serializers
 from django.contrib.auth import get_user_model
 from django.urls import reverse
 
-from misago.acl import serialize_acl
+from misago.acl.useracl import serialize_user_acl
 
 from .user import UserSerializer
 
-
 UserModel = get_user_model()
 
 __all__ = [
@@ -43,7 +42,10 @@ class AuthenticatedUserSerializer(UserSerializer, AuthFlags):
         ]
 
     def get_acl(self, obj):
-        return serialize_acl(obj)
+        acl = self.context.get("acl")
+        if acl:
+            return serialize_user_acl(acl)
+        return {}
 
     def get_email(self, obj):
         return obj.email
@@ -81,7 +83,7 @@ class AnonymousUserSerializer(serializers.Serializer, AuthFlags):
     is_anonymous = serializers.SerializerMethodField()
 
     def get_acl(self, obj):
-        if hasattr(obj, 'acl_cache'):
-            return serialize_acl(obj)
-        else:
-            return {}
+        acl = self.context.get("acl")
+        if acl:
+            return serialize_user_acl(acl)
+        return {}

+ 6 - 3
misago/users/serializers/user.py

@@ -74,20 +74,23 @@ class UserSerializer(serializers.ModelSerializer, MutableFields):
         return obj.acl
 
     def get_email(self, obj):
-        if (obj == self.context['user'] or self.context['user'].acl_cache['can_see_users_emails']):
+        request = self.context['request']
+        if (obj == request.user or request.user_acl['can_see_users_emails']):
             return obj.email
         else:
             return None
 
     def get_is_followed(self, obj):
+        request = self.context['request']
         if obj.acl['can_follow']:
-            return self.context['user'].is_following(obj)
+            return request.user.is_following(obj)
         else:
             return False
 
     def get_is_blocked(self, obj):
+        request = self.context['request']
         if obj.acl['can_block']:
-            return self.context['user'].is_blocking(obj)
+            return request.user.is_blocking(obj)
         else:
             return False
 

+ 2 - 2
misago/users/signatures.py

@@ -1,11 +1,11 @@
 from misago.markup import checksums, signature_flavour
 
 
-def set_user_signature(request, user, signature):
+def set_user_signature(request, user, user_acl, signature):
     user.signature = signature
 
     if signature:
-        user.signature_parsed = signature_flavour(request, user, signature)
+        user.signature_parsed = signature_flavour(request, user, user_acl, signature)
         user.signature_checksum = make_signature_checksum(user.signature_parsed, user)
     else:
         user.signature_parsed = ''

+ 8 - 9
misago/users/tests/test_bans.py

@@ -4,16 +4,15 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.utils import timezone
 
-from misago.cache.versions import get_cache_versions_from_db
-
 from misago.users.bans import (
     ban_ip, ban_user, get_email_ban, get_ip_ban, get_request_ip_ban, get_user_ban, get_username_ban)
 from misago.users.constants import BANS_CACHE
 from misago.users.models import Ban
 
-
 UserModel = get_user_model()
 
+cache_versions = {"bans": "abcdefgh"}
+
 
 class GetBanTests(TestCase):
     def test_get_username_ban(self):
@@ -134,7 +133,7 @@ class UserBansTests(TestCase):
 
     def test_no_ban(self):
         """user is not caught by ban"""
-        self.assertIsNone(get_user_ban(self.user, get_cache_versions_from_db()))
+        self.assertIsNone(get_user_ban(self.user, cache_versions))
         self.assertFalse(self.user.ban_cache.is_banned)
 
     def test_permanent_ban(self):
@@ -145,7 +144,7 @@ class UserBansTests(TestCase):
             staff_message='Staff reason',
         )
 
-        user_ban = get_user_ban(self.user, get_cache_versions_from_db())
+        user_ban = get_user_ban(self.user, cache_versions)
         self.assertIsNotNone(user_ban)
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
@@ -160,7 +159,7 @@ class UserBansTests(TestCase):
             expires_on=timezone.now() + timedelta(days=7),
         )
 
-        user_ban = get_user_ban(self.user, get_cache_versions_from_db())
+        user_ban = get_user_ban(self.user, cache_versions)
         self.assertIsNotNone(user_ban)
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
@@ -173,7 +172,7 @@ class UserBansTests(TestCase):
             expires_on=timezone.now() - timedelta(days=7),
         )
 
-        self.assertIsNone(get_user_ban(self.user, get_cache_versions_from_db()))
+        self.assertIsNone(get_user_ban(self.user, cache_versions))
         self.assertFalse(self.user.ban_cache.is_banned)
 
     def test_expired_non_flagged_ban(self):
@@ -184,7 +183,7 @@ class UserBansTests(TestCase):
         )
         Ban.objects.update(is_checked=True)
 
-        self.assertIsNone(get_user_ban(self.user, get_cache_versions_from_db()))
+        self.assertIsNone(get_user_ban(self.user, cache_versions))
         self.assertFalse(self.user.ban_cache.is_banned)
 
 
@@ -261,7 +260,7 @@ class BanUserTests(TestCase):
         self.assertEqual(ban.user_message, 'User reason')
         self.assertEqual(ban.staff_message, 'Staff reason')
 
-        db_ban = get_user_ban(user, get_cache_versions_from_db())
+        db_ban = get_user_ban(user, cache_versions)
         self.assertEqual(ban.pk, db_ban.ban_id)
 
 

+ 45 - 0
misago/users/tests/test_getting_user_status.py

@@ -0,0 +1,45 @@
+from unittest.mock import Mock
+
+from django.contrib.auth import get_user_model
+
+from misago.users.online.utils import get_user_status
+from misago.users.testutils import AuthenticatedUserTestCase
+
+User = get_user_model()
+
+
+class GetUserStatusTests(AuthenticatedUserTestCase):
+    def setUp(self):
+        super().setUp()
+        self.other_user = User.objects.create_user('Tyrael', 't123@test.com', 'pass123')
+
+    def test_get_visible_user_status_returns_online(self):
+        request = Mock(
+            user=self.user,
+            user_acl={'can_see_hidden_users': False},
+            cache_versions={"bans": "abcdefgh"},
+        )
+        assert get_user_status(request, self.other_user)["is_online"]
+
+    def test_get_hidden_user_status_without_seeing_hidden_permission_returns_offline(self):
+        """get_user_status has no showstopper for hidden user"""
+        self.other_user.is_hiding_presence = True
+        self.other_user.save()
+
+        request = Mock(
+            user=self.user,
+            user_acl={'can_see_hidden_users': False},
+            cache_versions={"bans": "abcdefgh"},
+        )
+        assert get_user_status(request, self.other_user)["is_hidden"]
+
+    def test_get_hidden_user_status_with_seeing_hidden_permission_returns_online_hidden(self):
+        self.other_user.is_hiding_presence = True
+        self.other_user.save()
+
+        request = Mock(
+            user=self.user,
+            user_acl={'can_see_hidden_users': True},
+            cache_versions={"bans": "abcdefgh"},
+        )
+        assert get_user_status(request, self.other_user)["is_online_hidden"]

+ 3 - 9
misago/users/tests/test_joinip_profilefield.py

@@ -1,8 +1,8 @@
 from django.contrib.auth import get_user_model
 from django.urls import reverse
 
+from misago.acl.test import patch_user_acl
 from misago.admin.testutils import AdminTestCase
-from misago.acl.testutils import override_acl
 
 
 UserModel = get_user_model()
@@ -74,6 +74,7 @@ class JoinIpProfileFieldTests(AdminTestCase):
         self.assertContains(response, "Join IP")
         self.assertContains(response, "127.0.0.1")
 
+    @patch_user_acl({'can_see_users_ips': 0})
     def test_field_hidden_no_permission(self):
         """field is hidden on user profile if user has no permission"""
         test_link = reverse(
@@ -84,10 +85,6 @@ class JoinIpProfileFieldTests(AdminTestCase):
             },
         )
 
-        override_acl(self.user, {
-            'can_see_users_ips': 0
-        })
-
         response = self.client.get(test_link)
         self.assertNotContains(response, "IP address")
         self.assertNotContains(response, "Join IP")
@@ -132,14 +129,11 @@ class JoinIpProfileFieldTests(AdminTestCase):
             ]
         )
 
+    @patch_user_acl({'can_see_users_ips': 0})
     def test_field_hidden_no_permission_json(self):
         """field is not included in display json if user has no permission"""
         test_link = reverse('misago:api:user-details', kwargs={'pk': self.user.pk})
 
-        override_acl(self.user, {
-            'can_see_users_ips': 0
-        })
-
         response = self.client.get(test_link)
         self.assertEqual(response.json()['groups'], [])
 

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

@@ -1,7 +1,7 @@
 from django.contrib.auth import get_user_model
 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.threads.testutils import post_thread
 from misago.users.activepostersranking import build_active_posters_ranking
@@ -13,20 +13,13 @@ UserModel = get_user_model()
 
 
 class UsersListTestCase(AuthenticatedUserTestCase):
-    def setUp(self):
-        super().setUp()
-        override_acl(self.user, {
-            'can_browse_users_list': 1,
-        })
+    pass
 
 
 class UsersListLanderTests(UsersListTestCase):
+    @patch_user_acl({'can_browse_users_list': 0})
     def test_lander_no_permission(self):
         """lander returns 403 if user has no permission"""
-        override_acl(self.user, {
-            'can_browse_users_list': 0,
-        })
-
         response = self.client.get(reverse('misago:users'))
         self.assertEqual(response.status_code, 403)
 

+ 68 - 16
misago/users/tests/test_namechanges.py

@@ -1,28 +1,80 @@
+from datetime import timedelta
+
 from django.contrib.auth import get_user_model
 from django.test import TestCase
 
-from misago.users.namechanges import UsernameChanges
-
+from misago.users.namechanges import (
+    get_next_available_namechange, get_left_namechanges, get_username_options
+)
 
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class UsernameChangesTests(TestCase):
-    def test_username_changes_helper(self):
-        """username changes are tracked correctly"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+    def test_user_without_permission_to_change_name_has_no_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 0}
+        assert get_left_namechanges(user, user_acl) == 0
+
+    def test_user_without_namechanges_has_all_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 3, "name_changes_expire": 0}
+        assert get_left_namechanges(user, user_acl) == 3
+
+    def test_user_own_namechanges_are_subtracted_from_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 3, "name_changes_expire": 0}
+        user.set_username('Changed')
+
+        assert get_left_namechanges(user, user_acl) == 2
+
+    def test_user_own_recent_namechanges_subtract_from_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 3, "name_changes_expire": 5}
+    
+        user.set_username('Changed')
+
+        assert get_left_namechanges(user, user_acl) == 2
+
+    def test_user_own_expired_namechanges_dont_subtract_from_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 3, "name_changes_expire": 5}
+        
+        username_change = user.set_username('Changed')
+        username_change.changed_on -= timedelta(days=10)
+        username_change.save()
+
+        assert get_left_namechanges(user, user_acl) == 3
+
+    def test_user_namechanges_by_other_users_dont_subtract_from_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 3, "name_changes_expire": 0}
+
+        username_change = user.set_username('Changed')
+        username_change.changed_by = None
+        username_change.save()
+
+        assert get_left_namechanges(user, user_acl) == 3
+
+    def test_user_next_available_namechange_is_none_for_user_with_changes_left(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 3, "name_changes_expire": 0}
 
-        namechanges = UsernameChanges(test_user)
-        self.assertEqual(namechanges.left, 2)
-        self.assertIsNone(namechanges.next_on)
+        assert get_next_available_namechange(user, user_acl, 3) is None
+    
+    def test_user_next_available_namechange_is_none_if_own_namechanges_dont_expire(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 1, "name_changes_expire": 0}
+        user.set_username('Changed')
 
-        self.assertEqual(test_user.namechanges.count(), 0)
+        assert get_next_available_namechange(user, user_acl, 0) is None
 
-        test_user.set_username('Boberson')
-        test_user.save(update_fields=['username', 'slug'])
+    def test_user_next_available_namechange_is_calculated_if_own_namechanges_expire(self):
+        user = User.objects.create_user('User', 'test@example.com')
+        user_acl = {"name_changes_allowed": 1, "name_changes_expire": 1}
 
-        namechanges = UsernameChanges(test_user)
-        self.assertEqual(namechanges.left, 1)
-        self.assertIsNone(namechanges.next_on)
+        username_change = user.set_username('Changed')
+        next_change_on = get_next_available_namechange(user, user_acl, 0)
 
-        self.assertEqual(test_user.namechanges.count(), 1)
+        assert next_change_on
+        assert next_change_on == username_change.changed_on + timedelta(days=1)

+ 0 - 41
misago/users/tests/test_online_utils.py

@@ -1,41 +0,0 @@
-from unittest.mock import Mock
-
-from django.contrib.auth import get_user_model
-
-from misago.acl.testutils import override_acl
-from misago.users.online.utils import get_user_status
-from misago.users.testutils import AuthenticatedUserTestCase
-
-
-UserModel = get_user_model()
-
-
-class GetUserStatusTests(AuthenticatedUserTestCase):
-    def setUp(self):
-        super().setUp()
-        self.other_user = UserModel.objects.create_user('Tyrael', 't123@test.com', 'pass123')
-
-    def test_user_hiding_presence(self):
-        """get_user_status has no showstopper for hidden user"""
-        self.other_user.is_hiding_presence = True
-        self.other_user.save()
-
-        request = Mock(user=self.user, cache_versions={"bans": "abcdfghi"})
-        get_user_status(request, self.other_user)
-
-    def test_user_visible_hidden_presence(self):
-        """get_user_status has no showstopper forvisible  hidden user"""
-        self.other_user.is_hiding_presence = True
-        self.other_user.save()
-
-        override_acl(self.user, {
-            'can_see_hidden_users': True,
-        })
-
-        request = Mock(user=self.user, cache_versions={"bans": "abcdfghi"})
-        get_user_status(request, self.other_user)
-
-    def test_user_not_hiding_presence(self):
-        """get_user_status has no showstoppers for non-hidden user"""
-        request = Mock(user=self.user, cache_versions={"bans": "abcdfghi"})
-        get_user_status(request, self.other_user)

+ 23 - 32
misago/users/tests/test_profile_views.py

@@ -1,7 +1,7 @@
 from django.contrib.auth import get_user_model
 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.threads import testutils
 from misago.users.models import Ban
@@ -184,46 +184,37 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
 
     def test_user_ban_details(self):
         """user ban details page has no showstoppers"""
-        override_acl(self.user, {
-            'can_see_ban_details': 0,
-        })
-
         test_user = UserModel.objects.create_user("Bob", "bob@bob.com", 'pass.123')
         link_kwargs = {'slug': test_user.slug, 'pk': test_user.pk}
 
-        response = self.client.get(reverse(
-            'misago:user-ban',
-            kwargs=link_kwargs,
-        ))
-        self.assertEqual(response.status_code, 404)
-
-        override_acl(self.user, {
-            'can_see_ban_details': 1,
-        })
+        with patch_user_acl({'can_see_ban_details': 0}):
+            response = self.client.get(reverse(
+                'misago:user-ban',
+                kwargs=link_kwargs,
+            ))
+            self.assertEqual(response.status_code, 404)
 
-        response = self.client.get(reverse(
-            'misago:user-ban',
-            kwargs=link_kwargs,
-        ))
-        self.assertEqual(response.status_code, 404)
-
-        override_acl(self.user, {
-            'can_see_ban_details': 1,
-        })
-        test_user.ban_cache.delete()
+        with patch_user_acl({'can_see_ban_details': 1}):
+            response = self.client.get(reverse(
+                'misago:user-ban',
+                kwargs=link_kwargs,
+            ))
+            self.assertEqual(response.status_code, 404)
 
         Ban.objects.create(
             banned_value=test_user.username,
             user_message="User m3ss4ge.",
             staff_message="Staff m3ss4ge.",
             is_checked=True,
-        )
+        )      
+        test_user.ban_cache.delete()
 
-        response = self.client.get(reverse(
-            'misago:user-ban',
-            kwargs=link_kwargs,
-        ))
+        with patch_user_acl({'can_see_ban_details': 1}):
+            response = self.client.get(reverse(
+                'misago:user-ban',
+                kwargs=link_kwargs,
+            ))
 
-        self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'User m3ss4ge')
-        self.assertContains(response, 'Staff m3ss4ge')
+            self.assertEqual(response.status_code, 200)
+            self.assertContains(response, 'User m3ss4ge')
+            self.assertContains(response, 'Staff m3ss4ge')

+ 55 - 0
misago/users/tests/test_rankadmin_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.admin.testutils import AdminTestCase
+from misago.cache.test import assert_invalidates_cache
 from misago.users.models import Rank
 
 
@@ -109,6 +111,35 @@ class RankAdminViewsTests(AdminTestCase):
         self.assertTrue(test_role_a not in test_rank.roles.all())
         self.assertTrue(test_role_c not in test_rank.roles.all())
 
+    def test_editing_rank_invalidates_acl_cache(self):
+        self.client.post(
+            reverse('misago:admin:users:ranks:new'),
+            data={
+                'name': 'Test Rank',
+                'description': 'Lorem ipsum dolor met',
+                'title': 'Test Title',
+                'style': 'test',
+                'is_tab': '1',
+            },
+        )
+
+        test_rank = Rank.objects.get(slug='test-rank')
+        test_role_b = Role.objects.create(name='Test Role B')
+        
+        with assert_invalidates_cache(ACL_CACHE):
+            self.client.post(
+                reverse(
+                    'misago:admin:users:ranks:edit',
+                    kwargs={
+                        'pk': test_rank.pk,
+                    },
+                ),
+                data={
+                    'name': 'Top Lel',
+                    'roles': [test_role_b.pk],
+                },
+            )
+
     def test_default_view(self):
         """default rank view has no showstoppers"""
         self.client.post(
@@ -260,6 +291,30 @@ class RankAdminViewsTests(AdminTestCase):
 
         self.assertNotContains(response, test_rank.name)
         self.assertNotContains(response, test_rank.title)
+        
+    def test_deleting_rank_invalidates_acl_cache(self):
+        self.client.post(
+            reverse('misago:admin:users:ranks:new'),
+            data={
+                'name': 'Test Rank',
+                'description': 'Lorem ipsum dolor met',
+                'title': 'Test Title',
+                'style': 'test',
+                'is_tab': '1',
+            },
+        )
+
+        test_rank = Rank.objects.get(slug='test-rank')
+
+        with assert_invalidates_cache(ACL_CACHE):
+            self.client.post(
+                reverse(
+                    'misago:admin:users:ranks:delete',
+                    kwargs={
+                        'pk': test_rank.pk,
+                    },
+                )
+            )
 
     def test_uniquess(self):
         """rank slug uniqueness is enforced by admin forms"""

+ 26 - 27
misago/users/tests/test_search.py

@@ -1,7 +1,7 @@
 from django.contrib.auth import get_user_model
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -14,10 +14,9 @@ class SearchApiTests(AuthenticatedUserTestCase):
 
         self.api_link = reverse('misago:api:search')
 
+    @patch_user_acl({'can_search_users': 0})
     def test_no_permission(self):
         """api respects permission to search users"""
-        override_acl(self.user, {'can_search_users': 0})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertNotIn('users', [p['id'] for p in response.json()])
@@ -27,10 +26,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        reponse_json = response.json()
-        self.assertIn('users', [p['id'] for p in reponse_json])
+        response_json = response.json()
+        self.assertIn('users', [p['id'] for p in response_json])
 
-        for provider in reponse_json:
+        for provider in response_json:
             if provider['id'] == 'users':
                 self.assertEqual(provider['results']['results'], [])
 
@@ -39,10 +38,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get('%s?q=' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        reponse_json = response.json()
-        self.assertIn('users', [p['id'] for p in reponse_json])
+        response_json = response.json()
+        self.assertIn('users', [p['id'] for p in response_json])
 
-        for provider in reponse_json:
+        for provider in response_json:
             if provider['id'] == 'users':
                 self.assertEqual(provider['results']['results'], [])
 
@@ -51,10 +50,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get('%s?q=%s' % (self.api_link, self.user.username[0]))
         self.assertEqual(response.status_code, 200)
 
-        reponse_json = response.json()
-        self.assertIn('users', [p['id'] for p in reponse_json])
+        response_json = response.json()
+        self.assertIn('users', [p['id'] for p in response_json])
 
-        for provider in reponse_json:
+        for provider in response_json:
             if provider['id'] == 'users':
                 results = provider['results']['results']
                 self.assertEqual(len(results), 1)
@@ -65,10 +64,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get('%s?q=%s' % (self.api_link, self.user.username))
         self.assertEqual(response.status_code, 200)
 
-        reponse_json = response.json()
-        self.assertIn('users', [p['id'] for p in reponse_json])
+        response_json = response.json()
+        self.assertIn('users', [p['id'] for p in response_json])
 
-        for provider in reponse_json:
+        for provider in response_json:
             if provider['id'] == 'users':
                 results = provider['results']['results']
                 self.assertEqual(len(results), 1)
@@ -79,10 +78,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get('%s?q=%s' % (self.api_link, self.user.username[-3:]))
         self.assertEqual(response.status_code, 200)
 
-        reponse_json = response.json()
-        self.assertIn('users', [p['id'] for p in reponse_json])
+        response_json = response.json()
+        self.assertIn('users', [p['id'] for p in response_json])
 
-        for provider in reponse_json:
+        for provider in response_json:
             if provider['id'] == 'users':
                 results = provider['results']['results']
                 self.assertEqual(len(results), 1)
@@ -93,10 +92,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get('%s?q=BobBoberson' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        reponse_json = response.json()
-        self.assertIn('users', [p['id'] for p in reponse_json])
+        response_json = response.json()
+        self.assertIn('users', [p['id'] for p in response_json])
 
-        for provider in reponse_json:
+        for provider in response_json:
             if provider['id'] == 'users':
                 self.assertEqual(provider['results']['results'], [])
 
@@ -112,10 +111,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get('%s?q=DisabledUser' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        reponse_json = response.json()
-        self.assertIn('users', [p['id'] for p in reponse_json])
+        response_json = response.json()
+        self.assertIn('users', [p['id'] for p in response_json])
 
-        for provider in reponse_json:
+        for provider in response_json:
             if provider['id'] == 'users':
                 self.assertEqual(provider['results']['results'], [])
 
@@ -126,10 +125,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get('%s?q=DisabledUser' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        reponse_json = response.json()
-        self.assertIn('users', [p['id'] for p in reponse_json])
+        response_json = response.json()
+        self.assertIn('users', [p['id'] for p in response_json])
 
-        for provider in reponse_json:
+        for provider in response_json:
             if provider['id'] == 'users':
                 results = provider['results']['results']
                 self.assertEqual(len(results), 1)

+ 42 - 16
misago/users/tests/test_signatures.py

@@ -1,10 +1,13 @@
+from unittest.mock import Mock
+
 from django.contrib.auth import get_user_model
 from django.test import TestCase
 
+from misago.acl.useracl import get_user_acl
 from misago.users import signatures
 
-
-UserModel = get_user_model()
+User = get_user_model()
+cache_versions = {"acl": "abcdefg"}
 
 
 class MockRequest(object):
@@ -14,22 +17,45 @@ class MockRequest(object):
         return '127.0.0.1:8000'
 
 
-class SignaturesTests(TestCase):
-    def test_signature_change(self):
-        """signature module allows for signature change"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+class UserSignatureTests(TestCase):
+    def test_user_signature_and_valid_checksum_is_set(self):
+        user = User.objects.create_user('Bob', 'bob@bob.com')
+        user.signature = "Test"
+        user.signature_parsed = "Test"
+        user.signature_checksum = "Test"
+        user.save()
+
+        request = Mock(scheme="http", get_host=Mock(return_value="127.0.0.1:800"))
+        user_acl = get_user_acl(user, cache_versions)
+
+        signatures.set_user_signature(request, user, user_acl, "Changed")
+
+        assert user.signature == "Changed"
+        assert user.signature_parsed == "<p>Changed</p>"
+        assert user.signature_checksum
+        assert signatures.is_user_signature_valid(user)
+
+    def test_user_signature_is_cleared(self):
+        user = User.objects.create_user('Bob', 'bob@bob.com')
+        user.signature = "Test"
+        user.signature_parsed = "Test"
+        user.signature_checksum = "Test"
+        user.save()
 
-        signatures.set_user_signature(MockRequest(), test_user, '')
+        request = Mock(scheme="http", get_host=Mock(return_value="127.0.0.1:800"))
+        user_acl = get_user_acl(user, cache_versions)
 
-        self.assertEqual(test_user.signature, '')
-        self.assertEqual(test_user.signature_parsed, '')
-        self.assertEqual(test_user.signature_checksum, '')
+        signatures.set_user_signature(request, user, user_acl, "")
 
-        signatures.set_user_signature(MockRequest(), test_user, 'Hello, world!')
+        assert not user.signature
+        assert not user.signature_parsed
+        assert not user.signature_checksum
 
-        self.assertEqual(test_user.signature, 'Hello, world!')
-        self.assertEqual(test_user.signature_parsed, '<p>Hello, world!</p>')
-        self.assertTrue(signatures.is_user_signature_valid(test_user))
+    def test_signature_validity_check_fails_for_incorrect_signature_checksum(self):
+        user = User.objects.create_user('Bob', 'bob@bob.com')
+        user.signature = "Test"
+        user.signature_parsed = "Test"
+        user.signature_checksum = "Test"
+        user.save()
 
-        test_user.signature_parsed = '<p>Injected evil HTML!</p>'
-        self.assertFalse(signatures.is_user_signature_valid(test_user))
+        assert not signatures.is_user_signature_valid(user)

+ 4 - 1
misago/users/tests/test_social_pipeline.py

@@ -6,6 +6,8 @@ from django.test import RequestFactory, override_settings
 from social_core.backends.github import GithubOAuth2
 from social_django.utils import load_strategy
 
+from misago.acl import ACL_CACHE
+from misago.acl.useracl import get_user_acl
 from misago.core.exceptions import SocialAuthFailed, SocialAuthBanned
 from misago.legal.models import Agreement
 
@@ -28,10 +30,11 @@ def create_request(user_ip='0.0.0.0', data=None):
     else:
         request = factory.post('/', data=json.dumps(data), content_type='application/json')
     request.include_frontend_context = True
-    request.cache_versions = {BANS_CACHE: "abcdefgh"}
+    request.cache_versions = {BANS_CACHE: "abcdefgh", ACL_CACHE: "abcdefgh"}
     request.frontend_context = {}
     request.session = {}
     request.user = AnonymousUser()
+    request.user_acl = get_user_acl(request.user, request.cache_versions)
     request.user_ip = user_ip
     return request
 

+ 4 - 29
misago/users/tests/test_user_avatar_api.py

@@ -4,7 +4,7 @@ from pathlib import Path
 
 from django.contrib.auth import get_user_model
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.conf import settings
 from misago.users.avatars import gallery, store
 from misago.users.models import AvatarGallery
@@ -351,24 +351,18 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
 
         self.link = '/api/users/%s/moderate-avatar/' % self.other_user.pk
 
+    @patch_user_acl({'can_moderate_avatars': 0})
     def test_no_permission(self):
         """no permission to moderate avatar"""
-        override_acl(self.user, {
-            'can_moderate_avatars': 0,
-        })
-
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't moderate avatars.",
         })
 
+    @patch_user_acl({'can_moderate_avatars': 1})
     def test_moderate_avatar(self):
         """moderate avatar"""
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
@@ -381,10 +375,6 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], self.other_user.avatar_lock_staff_message
         )
 
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -410,10 +400,6 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
         )
 
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -438,10 +424,6 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
         )
 
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -466,10 +448,6 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
         )
 
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -492,11 +470,8 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
         )
 
+    @patch_user_acl({'can_moderate_avatars': 1})
     def test_moderate_own_avatar(self):
         """moderate own avatar"""
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.get('/api/users/%s/moderate-avatar/' % self.user.pk)
         self.assertEqual(response.status_code, 200)

+ 9 - 16
misago/users/tests/test_user_details_api.py

@@ -1,8 +1,7 @@
 from django.contrib.auth import get_user_model
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
-
+from misago.acl.test import patch_user_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -56,22 +55,16 @@ class UserDetailsApiTests(AuthenticatedUserTestCase):
         )
 
         # moderator has permission to edit details
-        override_acl(self.user, {
-            'can_moderate_profile_details': True,
-        })
-
-        response = self.client.get(api_link)
-        self.assertEqual(response.status_code, 200)
-        self.assertTrue(response.json()['edit'])
+        with patch_user_acl({'can_moderate_profile_details': True}):
+            response = self.client.get(api_link)
+            self.assertEqual(response.status_code, 200)
+            self.assertTrue(response.json()['edit'])
 
         # non-moderator has no permission to edit details
-        override_acl(self.user, {
-            'can_moderate_profile_details': False,
-        })
-
-        response = self.client.get(api_link)
-        self.assertEqual(response.status_code, 200)
-        self.assertFalse(response.json()['edit'])
+        with patch_user_acl({'can_moderate_profile_details': False}):
+            response = self.client.get(api_link)
+            self.assertEqual(response.status_code, 200)
+            self.assertFalse(response.json()['edit'])
 
     def test_nonexistant_user(self):
         """api handles nonexistant users"""

+ 7 - 14
misago/users/tests/test_user_editdetails_api.py

@@ -1,8 +1,7 @@
 from django.contrib.auth import get_user_model
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
-
+from misago.acl.test import patch_user_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -45,20 +44,14 @@ class UserEditDetailsApiTests(AuthenticatedUserTestCase):
         )
 
         # moderator has permission to edit details
-        override_acl(self.user, {
-            'can_moderate_profile_details': True,
-        })
-
-        response = self.client.get(api_link)
-        self.assertEqual(response.status_code, 200)
+        with patch_user_acl({'can_moderate_profile_details': True}):
+            response = self.client.get(api_link)
+            self.assertEqual(response.status_code, 200)
 
         # non-moderator has no permission to edit details
-        override_acl(self.user, {
-            'can_moderate_profile_details': False,
-        })
-
-        response = self.client.get(api_link)
-        self.assertEqual(response.status_code, 403)
+        with patch_user_acl({'can_moderate_profile_details': False}):
+            response = self.client.get(api_link)
+            self.assertEqual(response.status_code, 403)
 
     def test_nonexistant_user(self):
         """api handles nonexistant users"""

+ 7 - 25
misago/users/tests/test_user_signature_api.py

@@ -1,4 +1,4 @@
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -9,24 +9,18 @@ class UserSignatureTests(AuthenticatedUserTestCase):
         super().setUp()
         self.link = '/api/users/%s/signature/' % self.user.pk
 
+    @patch_user_acl({'can_have_signature': 0})
     def test_signature_no_permission(self):
         """edit signature api with no ACL returns 403"""
-        override_acl(self.user, {
-            'can_have_signature': 0,
-        })
-
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You don't have permission to change signature.",
         })
 
+    @patch_user_acl({'can_have_signature': 1})
     def test_signature_locked(self):
         """locked edit signature returns 403"""
-        override_acl(self.user, {
-            'can_have_signature': 1,
-        })
-
         self.user.is_signature_locked = True
         self.user.signature_lock_user_message = 'Your siggy is banned.'
         self.user.save()
@@ -38,12 +32,9 @@ class UserSignatureTests(AuthenticatedUserTestCase):
             "reason": "<p>Your siggy is banned.</p>",
         })
 
+    @patch_user_acl({'can_have_signature': 1})
     def test_get_signature(self):
         """GET to api returns json with no signature"""
-        override_acl(self.user, {
-            'can_have_signature': 1,
-        })
-
         self.user.is_signature_locked = False
         self.user.save()
 
@@ -52,12 +43,9 @@ class UserSignatureTests(AuthenticatedUserTestCase):
 
         self.assertFalse(response.json()['signature'])
 
+    @patch_user_acl({'can_have_signature': 1})
     def test_post_empty_signature(self):
         """empty POST empties user signature"""
-        override_acl(self.user, {
-            'can_have_signature': 1,
-        })
-
         self.user.is_signature_locked = False
         self.user.save()
 
@@ -71,12 +59,9 @@ class UserSignatureTests(AuthenticatedUserTestCase):
 
         self.assertFalse(response.json()['signature'])
 
+    @patch_user_acl({'can_have_signature': 1})
     def test_post_too_long_signature(self):
         """too long new signature errors"""
-        override_acl(self.user, {
-            'can_have_signature': 1,
-        })
-
         self.user.is_signature_locked = False
         self.user.save()
 
@@ -91,12 +76,9 @@ class UserSignatureTests(AuthenticatedUserTestCase):
             "detail": "Signature is too long.",
         })
 
+    @patch_user_acl({'can_have_signature': 1})
     def test_post_good_signature(self):
         """POST with good signature changes user signature"""
-        override_acl(self.user, {
-            'can_have_signature': 1,
-        })
-
         self.user.is_signature_locked = False
         self.user.save()
 

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

@@ -2,7 +2,7 @@ import json
 
 from django.contrib.auth import get_user_model
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.conf import settings
 from misago.users.testutils import AuthenticatedUserTestCase
 
@@ -117,34 +117,24 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
 
         self.link = '/api/users/%s/moderate-username/' % self.other_user.pk
 
+    @patch_user_acl({'can_rename_users': 0})
     def test_no_permission(self):
-        """no permission to moderate avatar"""
-        override_acl(self.user, {
-            'can_rename_users': 0,
-        })
-
+        """no permission to moderate username"""
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't rename users.",
         })
 
-        override_acl(self.user, {
-            'can_rename_users': 0,
-        })
-
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't rename users.",
         })
 
+    @patch_user_acl({'can_rename_users': 1})
     def test_moderate_username(self):
         """moderate username"""
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
@@ -152,10 +142,6 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
         self.assertEqual(options['length_min'], settings.username_length_min)
         self.assertEqual(options['length_max'], settings.username_length_max)
 
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -168,10 +154,6 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             "detail": "Enter new username.",
         })
 
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -184,10 +166,6 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             "detail": "Username can only contain latin alphabet letters and digits.",
         })
 
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -200,10 +178,6 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             "detail": "Username must be at least 3 characters long.",
         })
 
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -223,11 +197,8 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
         self.assertEqual(options['username'], other_user.username)
         self.assertEqual(options['slug'], other_user.slug)
 
+    @patch_user_acl({'can_rename_users': 1})
     def test_moderate_own_username(self):
         """moderate own username"""
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.get('/api/users/%s/moderate-username/' % self.user.pk)
         self.assertEqual(response.status_code, 200)

+ 6 - 14
misago/users/tests/test_usernamechanges_api.py

@@ -1,4 +1,4 @@
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -7,40 +7,33 @@ class UsernameChangesApiTests(AuthenticatedUserTestCase):
         super().setUp()
         self.link = '/api/username-changes/'
 
+    @patch_user_acl({'can_see_users_name_history': False})
     def test_user_can_always_see_his_name_changes(self):
         """list returns own username changes"""
         self.user.set_username('NewUsername', self.user)
-
-        override_acl(self.user, {'can_see_users_name_history': False})
-
         response = self.client.get('%s?user=%s' % (self.link, self.user.pk))
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.user.username)
 
+    @patch_user_acl({'can_see_users_name_history': True})
     def test_list_handles_invalid_filter(self):
         """list raises 404 for invalid filter"""
         self.user.set_username('NewUsername', self.user)
-
-        override_acl(self.user, {'can_see_users_name_history': True})
-
         response = self.client.get('%s?user=abcd' % self.link)
         self.assertEqual(response.status_code, 404)
 
+    @patch_user_acl({'can_see_users_name_history': True})
     def test_list_handles_nonexisting_user(self):
         """list raises 404 for invalid user id"""
         self.user.set_username('NewUsername', self.user)
-
-        override_acl(self.user, {'can_see_users_name_history': True})
-
         response = self.client.get('%s?user=142141' % self.link)
         self.assertEqual(response.status_code, 404)
 
+    @patch_user_acl({'can_see_users_name_history': False})
     def test_list_handles_search(self):
         """list returns found username changes"""
         self.user.set_username('NewUsername', self.user)
 
-        override_acl(self.user, {'can_see_users_name_history': False})
-
         response = self.client.get('%s?user=%s&search=new' % (self.link, self.user.pk))
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.user.username)
@@ -49,10 +42,9 @@ class UsernameChangesApiTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json()["count"], 0)
 
+    @patch_user_acl({'can_see_users_name_history': False})
     def test_list_denies_permission(self):
         """list denies permission for other user (or all) if no access"""
-        override_acl(self.user, {'can_see_users_name_history': False})
-
         response = self.client.get('%s?user=%s' % (self.link, self.user.pk + 1))
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {

+ 37 - 67
misago/users/tests/test_users_api.py

@@ -6,7 +6,7 @@ from django.test import override_settings
 from django.urls import reverse
 from django.utils.encoding import smart_str
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.core import threadstore
 from misago.core.cache import cache
@@ -421,12 +421,9 @@ class UserFollowTests(AuthenticatedUserTestCase):
             "detail": "You can't add yourself to followed.",
         })
 
+    @patch_user_acl({'can_follow_users': 0})
     def test_cant_follow(self):
         """no permission to follow users"""
-        override_acl(self.user, {
-            'can_follow_users': 0,
-        })
-
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
@@ -476,28 +473,25 @@ class UserBanTests(AuthenticatedUserTestCase):
 
         self.link = '/api/users/%s/ban/' % self.other_user.pk
 
+    @patch_user_acl({'can_see_ban_details': 0})
     def test_no_permission(self):
         """user has no permission to access ban"""
-        override_acl(self.user, {'can_see_ban_details': 0})
-
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't see users bans details.",
         })
 
+    @patch_user_acl({'can_see_ban_details': 1})
     def test_no_ban(self):
         """api returns empty json"""
-        override_acl(self.user, {'can_see_ban_details': 1})
-
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), {})
 
+    @patch_user_acl({'can_see_ban_details': 1})
     def test_ban_details(self):
         """api returns ban json"""
-        override_acl(self.user, {'can_see_ban_details': 1})
-
         Ban.objects.create(
             check_type=Ban.USERNAME,
             banned_value=self.other_user.username,
@@ -604,30 +598,24 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         self.other_user.threads = 1
         self.other_user.save()
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 0,
+        'can_delete_users_with_less_posts_than': 0,
+    })
     def test_delete_no_permission(self):
         """raises 403 error when no permission to delete"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 0,
-                'can_delete_users_with_less_posts_than': 0,
-            }
-        )
-
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             'detail': "You can't delete users.",
         })
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 0,
+        'can_delete_users_with_less_posts_than': 5,
+    })
     def test_delete_too_many_posts(self):
         """raises 403 error when user has too many posts"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 0,
-                'can_delete_users_with_less_posts_than': 5,
-            }
-        )
-
         self.other_user.posts = 6
         self.other_user.save()
 
@@ -637,15 +625,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete users that made more than 5 posts.",
         })
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 5,
+        'can_delete_users_with_less_posts_than': 0,
+    })
     def test_delete_too_old_member(self):
         """raises 403 error when user is too old"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 5,
-                'can_delete_users_with_less_posts_than': 0,
-            }
-        )
-
         self.other_user.joined_on -= timedelta(days=6)
         self.other_user.save()
 
@@ -656,30 +641,24 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete users that are members for more than 5 days.",
         })
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_self(self):
         """raises 403 error when attempting to delete oneself"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         response = self.client.post('/api/users/%s/delete/' % self.user.pk)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             'detail': "You can't delete your account.",
         })
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_admin(self):
         """raises 403 error when attempting to delete admin"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         self.other_user.is_staff = True
         self.other_user.save()
 
@@ -689,15 +668,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete administrators.",
         })
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_superadmin(self):
         """raises 403 error when attempting to delete superadmin"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         self.other_user.is_superuser = True
         self.other_user.save()
 
@@ -707,15 +683,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete administrators.",
         })
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_with_content(self):
         """returns 200 and deletes user with content"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -731,15 +704,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         self.assertEqual(Thread.objects.count(), self.threads)
         self.assertEqual(Post.objects.count(), self.posts)
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_without_content(self):
         """returns 200 and deletes user without content"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         response = self.client.post(
             self.link,
             json.dumps({

+ 1 - 1
misago/users/viewmodels/posts.py

@@ -6,7 +6,7 @@ from .threads import UserThreads
 
 class UserPosts(UserThreads):
     def get_threads_queryset(self, request, threads_categories, profile):
-        return exclude_invisible_threads(request.user, threads_categories, Thread.objects)
+        return exclude_invisible_threads(request.user_acl, threads_categories, Thread.objects)
 
     def get_posts_queryset(self, user, profile, threads_queryset):
         return profile.post_set.select_related('thread', 'poster').filter(

+ 4 - 4
misago/users/viewmodels/threads.py

@@ -1,4 +1,4 @@
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.threads.permissions import exclude_invisible_threads
@@ -33,8 +33,8 @@ class UserThreads(object):
 
         add_categories_to_items(root_category.unwrap(), threads_categories, posts + threads)
 
-        add_acl(request.user, threads)
-        add_acl(request.user, posts)
+        add_acl_to_obj(request.user_acl, threads)
+        add_acl_to_obj(request.user_acl, posts)
 
         self._user = request.user
 
@@ -42,7 +42,7 @@ class UserThreads(object):
         self.paginator = paginator
 
     def get_threads_queryset(self, request, threads_categories, profile):
-        return exclude_invisible_threads(request.user, threads_categories, profile.thread_set)
+        return exclude_invisible_threads(request.user_acl, threads_categories, profile.thread_set)
 
     def get_posts_queryset(self, user, profile, threads_queryset):
         return profile.post_set.select_related('thread', 'poster').filter(

+ 5 - 1
misago/users/views/admin/users.py

@@ -5,6 +5,7 @@ from django.http import JsonResponse
 from django.shortcuts import redirect
 from django.utils.translation import gettext_lazy as _
 
+from misago.acl.useracl import get_user_acl
 from misago.admin.auth import start_admin_session
 from misago.admin.views import generic
 from misago.categories.models import Category
@@ -325,7 +326,10 @@ class EditUser(UserAdmin, generic.ModelFormView):
         target.roles.clear()
         target.roles.add(*form.cleaned_data['roles'])
 
-        set_user_signature(request, target, form.cleaned_data.get('signature'))
+        target_acl = get_user_acl(target, request.cache_versions)
+        set_user_signature(
+            request, target, target_acl, form.cleaned_data.get('signature')
+        )
 
         profilefields.update_user_profile_fields(request, target, form)
 

+ 2 - 2
misago/users/views/lists.py

@@ -11,7 +11,7 @@ from misago.users.viewmodels import ActivePosters, RankUsers
 
 class ListView(View):
     def get(self, request, *args, **kwargs):
-        allow_browse_users_list(request.user)
+        allow_browse_users_list(request.user_acl)
 
         context_data = self.get_context_data(request, *args, **kwargs)
 
@@ -62,7 +62,7 @@ class ListView(View):
 
 
 def landing(request):
-    allow_browse_users_list(request.user)
+    allow_browse_users_list(request.user_acl)
     return redirect(users_list.get_default_link())
 
 

+ 4 - 4
misago/users/views/profile.py

@@ -3,7 +3,7 @@ from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect, render
 from django.views import View
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.shortcuts import paginate, pagination_dict, validate_slug
 from misago.users.bans import get_user_ban
 from misago.users.online.utils import get_user_status
@@ -44,7 +44,7 @@ class ProfileView(View):
             raise Http404()
 
         validate_slug(profile, slug)
-        add_acl(request.user, profile)
+        add_acl_to_obj(request.user_acl, profile)
 
         return profile
 
@@ -67,7 +67,7 @@ class ProfileView(View):
             })
 
         request.frontend_context['PROFILE'] = UserProfileSerializer(
-            profile, context={'user': request.user}
+            profile, context={'request': request}
         ).data
 
         if not profile.is_active:
@@ -92,7 +92,7 @@ class ProfileView(View):
             })
 
             if not context['show_email']:
-                context['show_email'] = request.user.acl_cache['can_see_users_emails']
+                context['show_email'] = request.user_acl['can_see_users_emails']
         else:
             context.update({
                 'is_authenticated_user': False,