Browse Source

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

Remove global state from ACL
Rafał Pitoń 6 years ago
parent
commit
7f1ee27cee
186 changed files with 3777 additions and 4331 deletions
  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.cache.middleware.cache_versions_middleware',
     'misago.users.middleware.UserMiddleware',
     'misago.users.middleware.UserMiddleware',
+    'misago.acl.middleware.user_acl_middleware',
     'misago.core.middleware.ExceptionHandlerMiddleware',
     'misago.core.middleware.ExceptionHandlerMiddleware',
     'misago.users.middleware.OnlineTrackerMiddleware',
     'misago.users.middleware.OnlineTrackerMiddleware',
     'misago.admin.middleware.AdminAuthMiddleware',
     'misago.admin.middleware.AdminAuthMiddleware',
@@ -285,12 +286,13 @@ TEMPLATES = [
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.messages.context_processors.messages',
                 '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.site_address',
                 'misago.core.context_processors.momentjs_locale',
                 'misago.core.context_processors.momentjs_locale',
-                'misago.conf.context_processors.settings',
+                'misago.legal.context_processors.legal_links',
                 'misago.search.context_processors.search_providers',
                 'misago.search.context_processors.search_providers',
                 'misago.users.context_processors.user_links',
                 'misago.users.context_processors.user_links',
-                'misago.legal.context_processors.legal_links',
 
 
                 # Data preloaders
                 # Data preloaders
                 'misago.conf.context_processors.preload_settings_json',
                 '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'
 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 django.apps import AppConfig
 from .providers import providers
 from .providers import providers
 
 
+
 class MisagoACLsConfig(AppConfig):
 class MisagoACLsConfig(AppConfig):
     name = 'misago.acl'
     name = 'misago.acl'
     label = '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 -*-
 # -*- coding: utf-8 -*-
 from django.db import migrations
 from django.db import migrations
 
 
+from misago.acl import ACL_CACHE
 from misago.cache.operations import StartCacheVersioning
 from misago.cache.operations import StartCacheVersioning
 
 
 
 
@@ -12,5 +13,5 @@ class Migration(migrations.Migration):
     ]
     ]
 
 
     operations = [
     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.db import models
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from . import version as acl_version
+from .cache import clear_acl_cache
 
 
 
 
 def permissions_default():
 def permissions_default():
@@ -22,11 +22,11 @@ class BaseRole(models.Model):
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         if self.pk:
         if self.pk:
-            acl_version.invalidate()
+            clear_acl_cache()
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)
 
 
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
-        acl_version.invalidate()
+        clear_acl_cache()
         return super().delete(*args, **kwargs)
         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
             misago_user = None
 
 
         try:
         try:
-            misago_acl = misago_user.acl_cache
+            misago_acl = request.user_acl
         except AttributeError:
         except AttributeError:
             misago_acl = {}
             misago_acl = {}
 
 

+ 15 - 13
misago/acl/providers.py

@@ -5,13 +5,13 @@ from misago.conf import settings
 
 
 _NOT_INITIALIZED_ERROR = (
 _NOT_INITIALIZED_ERROR = (
     "PermissionProviders instance has to load providers with load() "
     "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."
     "list() or dict() methods will be available."
 )
 )
 
 
 _ALREADY_INITIALIZED_ERROR = (
 _ALREADY_INITIALIZED_ERROR = (
     "PermissionProviders instance has already loaded providers and "
     "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._providers_dict = {}
 
 
         self._annotators = {}
         self._annotators = {}
-        self._serializers = {}
+        self._user_acl_serializers = []
 
 
     def load(self):
     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):
     def _register_providers(self):
         for namespace in settings.MISAGO_ACL_EXTENSIONS:
         for namespace in settings.MISAGO_ACL_EXTENSIONS:
@@ -41,7 +43,7 @@ class PermissionProviders(object):
             if hasattr(self._providers_dict[namespace], 'register_with'):
             if hasattr(self._providers_dict[namespace], 'register_with'):
                 self._providers_dict[namespace].register_with(self)
                 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():
         for hashType in types_dict.keys():
             types_dict[hashType] = tuple(types_dict[hashType])
             types_dict[hashType] = tuple(types_dict[hashType])
 
 
@@ -50,18 +52,18 @@ class PermissionProviders(object):
         assert not self._initialized, _ALREADY_INITIALIZED_ERROR
         assert not self._initialized, _ALREADY_INITIALIZED_ERROR
         self._annotators.setdefault(hashable_type, []).append(func)
         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"""
         """registers ACL serializer for specified types"""
         assert not self._initialized, _ALREADY_INITIALIZED_ERROR
         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):
     def get_obj_type_annotators(self, obj):
         assert self._initialized, _NOT_INITIALIZED_ERROR
         assert self._initialized, _NOT_INITIALIZED_ERROR
         return self._annotators.get(obj.__class__, [])
         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
         assert self._initialized, _NOT_INITIALIZED_ERROR
-        return self._serializers.get(obj.__class__, [])
+        return self._user_acl_serializers
 
 
     def list(self):
     def list(self):
         assert self._initialized, _NOT_INITIALIZED_ERROR
         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 django.test import TestCase
 
 
 from misago.acl.providers import PermissionProviders
 from misago.acl.providers import PermissionProviders
 from misago.conf import settings
 from misago.conf import settings
 
 
 
 
-class TestType(object):
-    pass
-
-
 class PermissionProvidersTests(TestCase):
 class PermissionProvidersTests(TestCase):
-    def test_initialization(self):
-        """providers manager is lazily initialized"""
+    def test_providers_are_not_loaded_on_container_init(self):
         providers = PermissionProviders()
         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 = PermissionProviders()
         providers.load()
         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 = PermissionProviders()
-
-        # providers.list() throws before loading providers
-        with self.assertRaises(AssertionError):
-            providers.list()
-
         providers.load()
         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
         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 = 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):
         with self.assertRaises(AssertionError):
             providers.dict()
             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
             pass
+        
 
 
         providers = PermissionProviders()
         providers = PermissionProviders()
-        providers.acl_annotator(TestType, mock_annotator)
+        providers.acl_annotator(TestType, test_annotator)
         providers.load()
         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
             pass
 
 
+
         providers = PermissionProviders()
         providers = PermissionProviders()
-        providers.acl_serializer(TestType, mock_serializer)
+        providers.user_acl_serializer(test_user_acl_serializer)
         providers.load()
         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 django.urls import reverse
 
 
+from misago.acl import ACL_CACHE
 from misago.acl.models import Role
 from misago.acl.models import Role
 from misago.acl.testutils import fake_post_data
 from misago.acl.testutils import fake_post_data
+from misago.cache.test import assert_invalidates_cache
 from misago.admin.testutils import AdminTestCase
 from misago.admin.testutils import AdminTestCase
 
 
 
 
@@ -70,6 +72,25 @@ class RoleAdminViewsTests(AdminTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, test_role.name)
         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):
     def test_users_view(self):
         """users with this role view has no showstoppers"""
         """users with this role view has no showstoppers"""
         response = self.client.post(
         response = self.client.post(
@@ -106,3 +127,19 @@ class RoleAdminViewsTests(AdminTestCase):
         self.client.get(reverse('misago:admin:permissions:users:index'))
         self.client.get(reverse('misago:admin:permissions:users:index'))
         response = 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)
         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):
     def test_fake_post_data_for_role(self):
         """fake data was created for Role"""
         """fake data was created for Role"""
         test_data = fake_post_data(Role(), {'can_fly': 1})
         test_data = fake_post_data(Role(), {'can_fly': 1})
-
         self.assertIn('can_fly', test_data)
         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
 from .forms import get_permissions_forms
 
 
 
 
@@ -21,17 +16,3 @@ def fake_post_data(target, data_dict):
             else:
             else:
                 data_dict[field.html_name] = field.value()
                 data_dict[field.html_name] = field.value()
     return data_dict
     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
 from .versions import get_cache_versions
 
 
 
 
 def cache_versions_middleware(get_response):
 def cache_versions_middleware(get_response):
     """Sets request.cache_versions attribute with dict of cache versions."""
     """Sets request.cache_versions attribute with dict of cache versions."""
     def middleware(request):
     def middleware(request):
-        request.cache_versions = SimpleLazyObject(get_cache_versions)
+        request.cache_versions = get_cache_versions()
         return get_response(request)
         return get_response(request)
 
 
     return middleware
     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.test import TestCase
-from django.utils.functional import SimpleLazyObject
 
 
 from misago.cache.versions import CACHE_NAME
 from misago.cache.versions import CACHE_NAME
 from misago.cache.middleware import cache_versions_middleware
 from misago.cache.middleware import cache_versions_middleware
@@ -11,21 +10,9 @@ class MiddlewareTests(TestCase):
     def test_middleware_sets_attr_on_request(self):
     def test_middleware_sets_attr_on_request(self):
         get_response = Mock()
         get_response = Mock()
         request = Mock()
         request = Mock()
-        cache_versions = PropertyMock()
-        type(request).cache_versions = cache_versions
         middleware = cache_versions_middleware(get_response)
         middleware = cache_versions_middleware(get_response)
         middleware(request)
         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):
     def test_middleware_calls_get_response(self):
         get_response = Mock()
         get_response = Mock()
@@ -33,18 +20,3 @@ class MiddlewareTests(TestCase):
         middleware = cache_versions_middleware(get_response)
         middleware = cache_versions_middleware(get_response)
         middleware(request)
         middleware(request)
         get_response.assert_called_once()
         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.set')
     @patch('django.core.cache.cache.get', return_value=None)
     @patch('django.core.cache.cache.get', return_value=None)
     def test_getter_sets_new_cache_if_no_cache_is_set(self, _, cache_set):
     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()
         db_caches = get_cache_versions_from_db()
         cache_set.assert_called_once_with(CACHE_NAME, db_caches)
         cache_set.assert_called_once_with(CACHE_NAME, db_caches)
 
 
     @patch('django.core.cache.cache.set')
     @patch('django.core.cache.cache.set')
     @patch('django.core.cache.cache.get', return_value=True)
     @patch('django.core.cache.cache.get', return_value=True)
     def test_getter_is_not_setting_new_cache_if_cache_is_set(self, _, cache_set):
     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()
         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):
 class CategoryViewSet(viewsets.ViewSet):
     def list(self, request):
     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)
         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 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
 from misago.categories.models import Category
 
 
 
 
@@ -19,5 +19,5 @@ class Command(BaseCommand):
         self.stdout.write("Categories tree has been rebuild.")
         self.stdout.write("Categories tree has been rebuild.")
 
 
         Category.objects.clear_cache()
         Category.objects.clear_cache()
-        acl_version.invalidate()
+        clear_acl_cache()
         self.stdout.write("Caches have been cleared.")
         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 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.acl.models import BaseRole
 from misago.conf import settings
 from misago.conf import settings
 from misago.core.cache import cache
 from misago.core.cache import cache
@@ -115,7 +115,7 @@ class Category(MPTTModel):
 
 
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
         Category.objects.clear_cache()
         Category.objects.clear_cache()
-        acl_version.invalidate()
+        clear_acl_cache()
         return super().delete(*args, **kwargs)
         return super().delete(*args, **kwargs)
 
 
     def synchronize(self):
     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)
             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 = []
     categories_acl = []
-    for category, acl in serialized_acl.pop('categories').items():
+    for category, acl in user_acl.pop('categories').items():
         if acl['can_browse']:
         if acl['can_browse']:
             categories_acl.append({
             categories_acl.append({
                 'id': category,
                 'id': category,
@@ -102,31 +102,29 @@ def serialize_categories_acls(serialized_acl):
                 'can_hide_threads': acl.get('can_hide_threads', 0),
                 'can_hide_threads': acl.get('can_hide_threads', 0),
                 'can_close_threads': acl.get('can_close_threads', False),
                 'can_close_threads': acl.get('can_close_threads', False),
             })
             })
-    serialized_acl['categories'] = categories_acl
+    user_acl['categories'] = categories_acl
 
 
 
 
 def register_with(registry):
 def register_with(registry):
     registry.acl_annotator(Category, add_acl_to_category)
     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:
     try:
         category_id = target.pk
         category_id = target.pk
     except AttributeError:
     except AttributeError:
         category_id = int(target)
         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()
         raise Http404()
 
 
 
 
 can_see_category = return_boolean(allow_see_category)
 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']:
     if not target_acl['can_browse']:
         message = _('You don\'t have permission to browse "%(category)s" contents.')
         message = _('You don\'t have permission to browse "%(category)s" contents.')
         raise PermissionDenied(message % {'category': target.name})
         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 django.urls import reverse
 
 
+from misago.acl import ACL_CACHE
 from misago.admin.testutils import AdminTestCase
 from misago.admin.testutils import AdminTestCase
+from misago.cache.test import assert_invalidates_cache
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Thread
 from misago.threads.models import Thread
@@ -140,6 +142,21 @@ class CategoryAdminViewsTests(CategoryAdminTestCase):
         response = self.client.get(reverse('misago:admin:categories:nodes:index'))
         response = self.client.get(reverse('misago:admin:categories:nodes:index'))
         self.assertContains(response, 'Test Subcategory')
         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):
     def test_edit_view(self):
         """edit category view has no showstoppers"""
         """edit category view has no showstoppers"""
         private_threads = Category.objects.private_threads()
         private_threads = Category.objects.private_threads()
@@ -228,6 +245,35 @@ class CategoryAdminViewsTests(CategoryAdminTestCase):
         response = self.client.get(reverse('misago:admin:categories:nodes:index'))
         response = self.client.get(reverse('misago:admin:categories:nodes:index'))
         self.assertContains(response, 'Test Category Edited')
         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):
     def test_move_views(self):
         """move up/down views have no showstoppers"""
         """move up/down views have no showstoppers"""
         root = Category.objects.root_category()
         root = Category.objects.root_category()
@@ -522,3 +568,15 @@ class CategoryAdminDeleteViewTests(CategoryAdminTestCase):
             (self.category_e, 1, 10, 13),
             (self.category_e, 1, 10, 13),
             (self.category_f, 2, 11, 12),
             (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.core.management import call_command
 from django.test import TestCase
 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.management.commands import fixcategoriestree
 from misago.categories.models import Category
 from misago.categories.models import Category
 
 
@@ -82,3 +84,8 @@ class FixCategoriesTreeTests(TestCase):
             (self.test_category, 1, 2, 3),
             (self.test_category, 1, 2, 3),
             (self.first_category, 1, 4, 5),
             (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 django.urls import reverse
 
 
+from misago.acl import ACL_CACHE
 from misago.acl.models import Role
 from misago.acl.models import Role
 from misago.acl.testutils import fake_post_data
 from misago.acl.testutils import fake_post_data
 from misago.admin.testutils import AdminTestCase
 from misago.admin.testutils import AdminTestCase
+from misago.cache.test import assert_invalidates_cache
 from misago.categories.models import Category, CategoryRole
 from misago.categories.models import Category, CategoryRole
 
 
 
 
@@ -72,6 +74,26 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         response = self.client.get(reverse('misago:admin:permissions:categories:index'))
         response = self.client.get(reverse('misago:admin:permissions:categories:index'))
         self.assertContains(response, test_role.name)
         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):
     def test_delete_view(self):
         """delete role view has no showstoppers"""
         """delete role view has no showstoppers"""
         self.client.post(
         self.client.post(
@@ -93,6 +115,23 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         response = self.client.get(reverse('misago:admin:permissions:categories:index'))
         response = self.client.get(reverse('misago:admin:permissions:categories:index'))
         self.assertNotContains(response, test_role.name)
         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):
     def test_change_category_roles_view(self):
         """change category roles perms view works"""
         """change category roles perms view works"""
         root = Category.objects.root_category()
         root = Category.objects.root_category()
@@ -186,6 +225,20 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         self.assertEqual(
         self.assertEqual(
             category_role_set.get(role=test_role_b).category_role_id, role_comments.pk
             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):
     def test_change_role_categories_permissions_view(self):
         """change role categories perms view works"""
         """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_c).category_role_id, role_full.pk)
         self.assertEqual(categories_acls.get(category=category_d).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.models import Category
 from misago.categories.utils import get_categories_tree, get_category_path
 from misago.categories.utils import get_categories_tree, get_category_path
 from misago.core import threadstore
 from misago.core import threadstore
 from misago.users.testutils import AuthenticatedUserTestCase
 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):
 class CategoriesUtilsTests(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
@@ -84,15 +96,11 @@ class CategoriesUtilsTests(AuthenticatedUserTestCase):
             save=True,
             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):
     def test_root_categories_tree_no_parent(self):
         """get_categories_tree returns all children of root nodes"""
         """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(len(categories_tree), 3)
 
 
         self.assertEqual(categories_tree[0], Category.objects.get(slug='first-category'))
         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):
     def test_root_categories_tree_with_parent(self):
         """get_categories_tree returns all children of given node"""
         """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(len(categories_tree), 1)
         self.assertEqual(categories_tree[0], Category.objects.get(slug='category-b'))
         self.assertEqual(categories_tree[0], Category.objects.get(slug='category-b'))
 
 
     def test_root_categories_tree_with_leaf(self):
     def test_root_categories_tree_with_leaf(self):
         """get_categories_tree returns all children of given node"""
         """get_categories_tree returns all children of given node"""
         categories_tree = get_categories_tree(
         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)
         self.assertEqual(len(categories_tree), 0)
 
 
     def test_get_category_path(self):
     def test_get_category_path(self):
         """get_categories_tree returns all children of root nodes"""
         """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))
             parent_nodes = len(get_category_path(node))
             self.assertEqual(parent_nodes, node.level)
             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 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.models import Category
-from misago.categories.utils import get_categories_tree
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
 class CategoryViewsTests(AuthenticatedUserTestCase):
 class CategoryViewsTests(AuthenticatedUserTestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.category = Category.objects.get(slug='first-category')
+
     def test_index_renders(self):
     def test_index_renders(self):
         """categories list renders for authenticated"""
         """categories list renders for authenticated"""
         response = self.client.get(reverse('misago:categories'))
         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):
     def test_index_renders_for_guest(self):
         """categories list renders for guest"""
         """categories list renders for guest"""
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.get(reverse('misago:categories'))
         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):
     def test_index_no_perms_renders(self):
         """categories list renders no visible categories for authenticated"""
         """categories list renders no visible categories for authenticated"""
-        override_acl(self.user, {'visible_categories': []})
         response = self.client.get(reverse('misago: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):
     def test_index_no_perms_renders_for_guest(self):
         """categories list renders no visible categories for guest"""
         """categories list renders no visible categories for guest"""
         self.logout_user()
         self.logout_user()
 
 
-        override_acl(self.user, {'visible_categories': []})
         response = self.client.get(reverse('misago: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):
 class CategoryAPIViewsTests(AuthenticatedUserTestCase):
@@ -59,41 +53,27 @@ class CategoryAPIViewsTests(AuthenticatedUserTestCase):
     def test_list_renders(self):
     def test_list_renders(self):
         """api returns categories for authenticated"""
         """api returns categories for authenticated"""
         response = self.client.get(reverse('misago:api:category-list'))
         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):
     def test_list_renders_for_guest(self):
         """api returns categories for guest"""
         """api returns categories for guest"""
         self.logout_user()
         self.logout_user()
 
 
         response = self.client.get(reverse('misago:api:category-list'))
         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):
     def test_list_no_perms_renders(self):
         """api returns no categories for authenticated"""
         """api returns no categories for authenticated"""
-        override_acl(self.user, {'visible_categories': []})
         response = self.client.get(reverse('misago:api:category-list'))
         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):
     def test_list_no_perms_renders_for_guest(self):
         """api returns no categories for guest"""
         """api returns no categories for guest"""
         self.logout_user()
         self.logout_user()
 
 
-        override_acl(self.user, {'visible_categories': []})
         response = self.client.get(reverse('misago:api:category-list'))
         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 misago.readtracker import categoriestracker
 
 
 from .models import Category
 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 []
         return []
 
 
     if parent:
     if parent:
@@ -13,7 +13,7 @@ def get_categories_tree(user, parent=None, join_posters=False):
     else:
     else:
         queryset = Category.objects.all_categories()
         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:
     if join_posters:
         queryset_with_acl = queryset_with_acl.select_related('last_poster')
         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:
         if category.parent_id and category.level > parent_level:
             categories_dict[category.parent_id].subcategories.append(category)
             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):
     for category in reversed(visible_categories):
         if category.acl['can_browse']:
         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.shortcuts import redirect
 from django.utils.translation import gettext_lazy as _
 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.admin.views import generic
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories.forms import CategoryFormFactory, DeleteFormFactory
 from misago.categories.forms import CategoryFormFactory, DeleteFormFactory
@@ -88,7 +88,7 @@ class CategoryFormMixin(object):
             if copied_acls:
             if copied_acls:
                 RoleCategoryACL.objects.bulk_create(copied_acls)
                 RoleCategoryACL.objects.bulk_create(copied_acls)
 
 
-        acl_version.invalidate()
+        clear_acl_cache()
         messages.success(request, self.message_submit % {'name': target.name})
         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):
 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({
     request.frontend_context.update({
         'CATEGORIES': CategorySerializer(categories_tree, many=True).data,
         '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.shortcuts import redirect
 from django.utils.translation import gettext_lazy as _
 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.forms import get_permissions_forms
 from misago.acl.models import Role
 from misago.acl.models import Role
 from misago.acl.views import RoleAdmin, RolesList
 from misago.acl.views import RoleAdmin, RolesList
@@ -128,7 +128,7 @@ class CategoryPermissions(CategoryAdmin, generic.ModelFormView):
             if new_permissions:
             if new_permissions:
                 RoleCategoryACL.objects.bulk_create(new_permissions)
                 RoleCategoryACL.objects.bulk_create(new_permissions)
 
 
-            acl_version.invalidate()
+            clear_acl_cache()
 
 
             message = _("Category %(name)s permissions have been changed.")
             message = _("Category %(name)s permissions have been changed.")
             messages.success(request, message % {'name': target.name})
             messages.success(request, message % {'name': target.name})
@@ -196,7 +196,7 @@ class RoleCategoriesACL(RoleAdmin, generic.ModelFormView):
             if new_permissions:
             if new_permissions:
                 RoleCategoryACL.objects.bulk_create(new_permissions)
                 RoleCategoryACL.objects.bulk_create(new_permissions)
 
 
-            acl_version.invalidate()
+            clear_acl_cache()
 
 
             message = _("Category permissions for role %(name)s have been changed.")
             message = _("Category permissions for role %(name)s have been changed.")
             messages.success(request, message % {'name': target.name})
             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.test.client import RequestFactory
 from django.urls import reverse
 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.testproject.views import mock_custom_403_error_page, mock_custom_404_error_page
 from misago.core.utils import encode_json_html
 from misago.core.utils import encode_json_html
-from misago.users.models import AnonymousUser
 
 
 
 
 class CSRFErrorViewTests(TestCase):
 class CSRFErrorViewTests(TestCase):
@@ -73,20 +74,21 @@ class ErrorPageViewsTests(TestCase):
         self.assertContains(response, "Banned in auth!", status_code=403)
         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')
 @override_settings(ROOT_URLCONF='misago.core.testproject.urlswitherrorhandlers')
 class CustomErrorPagesTests(TestCase):
 class CustomErrorPagesTests(TestCase):
     def setUp(self):
     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):
     def test_shared_403_decorator(self):
         """shared_403_decorator calls correct error handler"""
         """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.test.client import RequestFactory
 from django.urls import reverse
 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.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):
     def test_middleware_returns_response_for_supported_exception(self):
         """Middleware returns HttpResponse for supported exception"""
         """Middleware returns HttpResponse for supported exception"""
-        exception = Http404()
         middleware = ExceptionHandlerMiddleware()
         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):
     def test_middleware_returns_none_for_non_supported_exception(self):
         """Middleware returns None for non-supported exception"""
         """Middleware returns None for non-supported exception"""
-        exception = TypeError()
         middleware = ExceptionHandlerMiddleware()
         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 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.categories.models import Category, RoleCategoryACL
 from misago.core.management.progressbar import show_progress
 from misago.core.management.progressbar import show_progress
 
 
@@ -85,7 +85,7 @@ class Command(BaseCommand):
             created_count += 1
             created_count += 1
             show_progress(self, created_count, items_to_create, start_time)
             show_progress(self, created_count, items_to_create, start_time)
 
 
-        acl_version.invalidate()
+        clear_acl_cache()
 
 
         total_time = time.time() - start_time
         total_time = time.time() - start_time
         total_humanized = time.strftime('%H:%M:%S', time.gmtime(total_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']
     return result['parsed_text']
 
 
 
 
-def signature(request, owner, text):
+def signature(request, owner, user_acl, text):
     result = parse(
     result = parse(
         text,
         text,
         request,
         request,
         owner,
         owner,
         allow_mentions=False,
         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']
     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
 from .dates import get_cutoff_date
 
 
 
 
-def make_read_aware(user, categories):
+def make_read_aware(user, user_acl, categories):
     if not categories:
     if not categories:
         return
         return
 
 
@@ -17,7 +17,7 @@ def make_read_aware(user, categories):
         return
         return
 
 
     threads = Thread.objects.filter(category__in=categories)
     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(
     queryset = Post.objects.filter(
         category__in=categories,
         category__in=categories,
@@ -26,7 +26,7 @@ def make_read_aware(user, categories):
     ).values_list('category', flat=True).distinct()
     ).values_list('category', flat=True).distinct()
 
 
     queryset = queryset.exclude(id__in=user.postread_set.values('post'))
     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)
     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.test import TestCase
 from django.utils import timezone
 from django.utils import timezone
 
 
+from misago.acl.useracl import get_user_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.conf import settings
-from misago.core import cache, threadstore
 from misago.readtracker import poststracker, categoriestracker
 from misago.readtracker import poststracker, categoriestracker
 from misago.readtracker.models import PostRead
 from misago.readtracker.models import PostRead
 from misago.threads import testutils
 from misago.threads import testutils
 
 
+User = get_user_model()
 
 
-UserModel = get_user_model()
+cache_versions = {"acl": "abcdefgh"}
 
 
 
 
 class AnonymousUser(object):
 class AnonymousUser(object):
@@ -22,24 +23,22 @@ class AnonymousUser(object):
 
 
 class CategoriesTrackerTests(TestCase):
 class CategoriesTrackerTests(TestCase):
     def setUp(self):
     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')
         self.category = Category.objects.get(slug='first-category')
 
 
     def test_falsy_value(self):
     def test_falsy_value(self):
         """passing falsy value to readtracker causes no errors"""
         """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):
     def test_anon_thread_before_cutoff(self):
         """non-tracked thread is marked as read for anonymous users"""
         """non-tracked thread is marked as read for anonymous users"""
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         testutils.post_thread(self.category, started_on=started_on)
         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.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
         self.assertFalse(self.category.is_new)
 
 
@@ -47,7 +46,7 @@ class CategoriesTrackerTests(TestCase):
         """tracked thread is marked as read for anonymous users"""
         """tracked thread is marked as read for anonymous users"""
         testutils.post_thread(self.category, started_on=timezone.now())
         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.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
         self.assertFalse(self.category.is_new)
 
 
@@ -56,7 +55,7 @@ class CategoriesTrackerTests(TestCase):
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         testutils.post_thread(self.category, started_on=started_on)
         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.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
         self.assertFalse(self.category.is_new)
 
 
@@ -64,7 +63,7 @@ class CategoriesTrackerTests(TestCase):
         """tracked thread is marked as unread for authenticated users"""
         """tracked thread is marked as unread for authenticated users"""
         testutils.post_thread(self.category, started_on=timezone.now())
         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.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
         self.assertTrue(self.category.is_new)
 
 
@@ -73,7 +72,7 @@ class CategoriesTrackerTests(TestCase):
         started_on = timezone.now() - timedelta(days=1)
         started_on = timezone.now() - timedelta(days=1)
         testutils.post_thread(self.category, started_on=started_on)
         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.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
         self.assertFalse(self.category.is_new)
 
 
@@ -83,7 +82,7 @@ class CategoriesTrackerTests(TestCase):
 
 
         poststracker.save_read(self.user, thread.first_post)
         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.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
         self.assertFalse(self.category.is_new)
 
 
@@ -94,7 +93,7 @@ class CategoriesTrackerTests(TestCase):
         post = testutils.reply_thread(thread, posted_on=timezone.now())
         post = testutils.reply_thread(thread, posted_on=timezone.now())
         poststracker.save_read(self.user, post)
         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.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
         self.assertTrue(self.category.is_new)
 
 
@@ -105,7 +104,7 @@ class CategoriesTrackerTests(TestCase):
 
 
         testutils.reply_thread(thread, posted_on=timezone.now(), is_event=True)
         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.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
         self.assertTrue(self.category.is_new)
 
 
@@ -120,7 +119,7 @@ class CategoriesTrackerTests(TestCase):
             is_hidden=True,
             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.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
         self.assertTrue(self.category.is_new)
 
 
@@ -136,7 +135,7 @@ class CategoriesTrackerTests(TestCase):
             is_hidden=True,
             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.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
         self.assertFalse(self.category.is_new)
 
 
@@ -145,7 +144,7 @@ class CategoriesTrackerTests(TestCase):
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         testutils.post_thread(self.category, started_on=started_on)
         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.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
         self.assertFalse(self.category.is_new)
 
 
@@ -160,7 +159,7 @@ class CategoriesTrackerTests(TestCase):
             is_unapproved=True,
             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.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
         self.assertFalse(self.category.is_new)
 
 
@@ -176,7 +175,7 @@ class CategoriesTrackerTests(TestCase):
             is_unapproved=True,
             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.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
         self.assertTrue(self.category.is_new)
 
 
@@ -192,7 +191,7 @@ class CategoriesTrackerTests(TestCase):
             is_unapproved=True,
             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.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
         self.assertTrue(self.category.is_new)
 
 
@@ -204,7 +203,7 @@ class CategoriesTrackerTests(TestCase):
             is_unapproved=True,
             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.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
         self.assertFalse(self.category.is_new)
 
 
@@ -217,7 +216,7 @@ class CategoriesTrackerTests(TestCase):
             is_unapproved=True,
             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.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
         self.assertTrue(self.category.is_new)
 
 
@@ -229,6 +228,6 @@ class CategoriesTrackerTests(TestCase):
             is_hidden=True,
             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.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
         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.test import TestCase
 from django.utils import timezone
 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.categories.models import Category
 from misago.conf import settings
 from misago.conf import settings
-from misago.core import cache, threadstore
 from misago.readtracker import poststracker, threadstracker
 from misago.readtracker import poststracker, threadstracker
 from misago.readtracker.models import PostRead
 from misago.readtracker.models import PostRead
 from misago.threads import testutils
 from misago.threads import testutils
 
 
+User = get_user_model()
 
 
-UserModel = get_user_model()
+cache_versions = {"acl": "abcdefgh"}
 
 
 
 
 class AnonymousUser(object):
 class AnonymousUser(object):
@@ -23,26 +24,24 @@ class AnonymousUser(object):
 
 
 class ThreadsTrackerTests(TestCase):
 class ThreadsTrackerTests(TestCase):
     def setUp(self):
     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')
         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):
     def test_falsy_value(self):
         """passing falsy value to readtracker causes no errors"""
         """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):
     def test_anon_thread_before_cutoff(self):
         """non-tracked thread is marked as read for anonymous users"""
         """non-tracked thread is marked as read for anonymous users"""
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         thread = testutils.post_thread(self.category, started_on=started_on)
         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.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
         self.assertFalse(thread.is_new)
 
 
@@ -50,7 +49,7 @@ class ThreadsTrackerTests(TestCase):
         """tracked thread is marked as read for anonymous users"""
         """tracked thread is marked as read for anonymous users"""
         thread = testutils.post_thread(self.category, started_on=timezone.now())
         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.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
         self.assertFalse(thread.is_new)
 
 
@@ -59,7 +58,7 @@ class ThreadsTrackerTests(TestCase):
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         thread = testutils.post_thread(self.category, started_on=started_on)
         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.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
         self.assertFalse(thread.is_new)
 
 
@@ -67,7 +66,7 @@ class ThreadsTrackerTests(TestCase):
         """tracked thread is marked as unread for authenticated users"""
         """tracked thread is marked as unread for authenticated users"""
         thread = testutils.post_thread(self.category, started_on=timezone.now())
         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.assertFalse(thread.is_read)
         self.assertTrue(thread.is_new)
         self.assertTrue(thread.is_new)
 
 
@@ -76,7 +75,7 @@ class ThreadsTrackerTests(TestCase):
         started_on = timezone.now() - timedelta(days=1)
         started_on = timezone.now() - timedelta(days=1)
         thread = testutils.post_thread(self.category, started_on=started_on)
         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.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
         self.assertFalse(thread.is_new)
 
 
@@ -86,7 +85,7 @@ class ThreadsTrackerTests(TestCase):
 
 
         poststracker.save_read(self.user, thread.first_post)
         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.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
         self.assertFalse(thread.is_new)
 
 
@@ -97,7 +96,7 @@ class ThreadsTrackerTests(TestCase):
         post = testutils.reply_thread(thread, posted_on=timezone.now())
         post = testutils.reply_thread(thread, posted_on=timezone.now())
         poststracker.save_read(self.user, post)
         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.assertFalse(thread.is_read)
         self.assertTrue(thread.is_new)
         self.assertTrue(thread.is_new)
 
 
@@ -108,7 +107,7 @@ class ThreadsTrackerTests(TestCase):
 
 
         testutils.reply_thread(thread, posted_on=timezone.now(), is_event=True)
         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.assertFalse(thread.is_read)
         self.assertTrue(thread.is_new)
         self.assertTrue(thread.is_new)
 
 
@@ -123,7 +122,7 @@ class ThreadsTrackerTests(TestCase):
             is_hidden=True,
             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.assertFalse(thread.is_read)
         self.assertTrue(thread.is_new)
         self.assertTrue(thread.is_new)
 
 
@@ -139,7 +138,7 @@ class ThreadsTrackerTests(TestCase):
             is_hidden=True,
             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.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
         self.assertFalse(thread.is_new)
 
 
@@ -148,7 +147,7 @@ class ThreadsTrackerTests(TestCase):
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         thread = testutils.post_thread(self.category, started_on=started_on)
         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.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
         self.assertFalse(thread.is_new)
 
 
@@ -163,7 +162,7 @@ class ThreadsTrackerTests(TestCase):
             is_unapproved=True,
             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.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
         self.assertFalse(thread.is_new)
 
 
@@ -179,6 +178,6 @@ class ThreadsTrackerTests(TestCase):
             is_unapproved=True,
             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.assertFalse(thread.is_read)
         self.assertTrue(thread.is_new)
         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
 from .dates import get_cutoff_date
 
 
 
 
-def make_read_aware(user, threads):
+def make_read_aware(user, user_acl, threads):
     if not threads:
     if not threads:
         return
         return
 
 
@@ -24,7 +24,7 @@ def make_read_aware(user, threads):
     ).values_list('thread', flat=True).distinct()
     ).values_list('thread', flat=True).distinct()
 
 
     queryset = queryset.exclude(id__in=user.postread_set.values('post'))
     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)
     unread_threads = list(queryset)
 
 

+ 1 - 1
misago/search/api.py

@@ -15,7 +15,7 @@ from .searchproviders import searchproviders
 @api_view()
 @api_view()
 def search(request, search_provider=None):
 def search(request, search_provider=None):
     allowed_providers = searchproviders.get_allowed_providers(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."))
         raise PermissionDenied(_("You don't have permission to search site."))
 
 
     search_query = get_search_query(request)
     search_query = get_search_query(request)

+ 1 - 1
misago/search/context_processors.py

@@ -8,7 +8,7 @@ def search_providers(request):
     allowed_providers = []
     allowed_providers = []
 
 
     try:
     try:
-        if request.user.acl_cache['can_search']:
+        if request.user_acl['can_search']:
             allowed_providers = searchproviders.get_allowed_providers(request)
             allowed_providers = searchproviders.get_allowed_providers(request)
     except AttributeError:
     except AttributeError:
         # is user has no acl_cache attribute, cease entire middleware
         # 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 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.search.searchproviders import searchproviders
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -11,10 +11,9 @@ class SearchApiTests(AuthenticatedUserTestCase):
 
 
         self.test_link = reverse('misago:api:search')
         self.test_link = reverse('misago:api:search')
 
 
+    @patch_user_acl({"can_search": False})
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates permission to search"""
         """api validates permission to search"""
-        override_acl(self.user, {'can_search': 0})
-
         response = self.client.get(self.test_link)
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {

+ 6 - 10
misago/search/tests/test_views.py

@@ -1,6 +1,6 @@
 from django.urls import reverse
 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.threads.search import SearchThreads
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -11,27 +11,24 @@ class LandingTests(AuthenticatedUserTestCase):
 
 
         self.test_link = reverse('misago:search')
         self.test_link = reverse('misago:search')
 
 
+    @patch_user_acl({'can_search': False})
     def test_no_permission(self):
     def test_no_permission(self):
         """view validates permission to search forum"""
         """view validates permission to search forum"""
-        override_acl(self.user, {'can_search': 0})
-
         response = self.client.get(self.test_link)
         response = self.client.get(self.test_link)
-
         self.assertContains(response, "have permission to search site", status_code=403)
         self.assertContains(response, "have permission to search site", status_code=403)
 
 
+    @patch_user_acl({'can_search': True})
     def test_redirect_to_provider(self):
     def test_redirect_to_provider(self):
         """view validates permission to search forum"""
         """view validates permission to search forum"""
         response = self.client.get(self.test_link)
         response = self.client.get(self.test_link)
-
         self.assertEqual(response.status_code, 302)
         self.assertEqual(response.status_code, 302)
         self.assertIn(SearchThreads.url, response['location'])
         self.assertIn(SearchThreads.url, response['location'])
 
 
 
 
 class SearchTests(AuthenticatedUserTestCase):
 class SearchTests(AuthenticatedUserTestCase):
+    @patch_user_acl({'can_search': False})
     def test_no_permission(self):
     def test_no_permission(self):
         """view validates permission to search forum"""
         """view validates permission to search forum"""
-        override_acl(self.user, {'can_search': 0})
-
         response = self.client.get(
         response = self.client.get(
             reverse('misago:search', kwargs={
             reverse('misago:search', kwargs={
                 'search_provider': 'users',
                 'search_provider': 'users',
@@ -48,10 +45,9 @@ class SearchTests(AuthenticatedUserTestCase):
 
 
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_user_acl({'can_search': True, 'can_search_users': False})
     def test_provider_no_permission(self):
     def test_provider_no_permission(self):
         """provider raises 403 without permission"""
         """provider raises 403 without permission"""
-        override_acl(self.user, {'can_search_users': 0})
-
         response = self.client.get(
         response = self.client.get(
             reverse('misago:search', kwargs={
             reverse('misago:search', kwargs={
                 'search_provider': 'users',
                 'search_provider': 'users',
@@ -64,7 +60,7 @@ class SearchTests(AuthenticatedUserTestCase):
         """provider displays no script page"""
         """provider displays no script page"""
         response = self.client.get(
         response = self.client.get(
             reverse('misago:search', kwargs={
             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):
 def landing(request):
     allowed_providers = searchproviders.get_allowed_providers(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."))
         raise PermissionDenied(_("You don't have permission to search site."))
 
 
     default_provider = allowed_providers[0]
     default_provider = allowed_providers[0]
@@ -18,7 +18,7 @@ def landing(request):
 
 
 def search(request, search_provider):
 def search(request, search_provider):
     all_providers = searchproviders.get_providers(request)
     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."))
         raise PermissionDenied(_("You don't have permission to search site."))
 
 
     for provider in all_providers:
     for provider in all_providers:

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

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

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

@@ -27,7 +27,7 @@
           {% trans "Subscribed" %}
           {% trans "Subscribed" %}
         </a>
         </a>
       </li>
       </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 %}>
         <li{% if list_type == 'unapproved' %} class="active"{% endif %}>
           <a href="{{ category.get_absolute_url }}unapproved/">
           <a href="{{ category.get_absolute_url }}unapproved/">
             {% trans "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.template.defaultfilters import filesizeformat
 from django.utils.translation import gettext as _
 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.models import Attachment, AttachmentType
 from misago.threads.serializers import AttachmentSerializer
 from misago.threads.serializers import AttachmentSerializer
 from misago.users.audittrail import create_audit_trail
 from misago.users.audittrail import create_audit_trail
@@ -16,7 +16,7 @@ IMAGE_EXTENSIONS = ('jpg', 'jpeg', 'png', 'gif')
 
 
 class AttachmentViewSet(viewsets.ViewSet):
 class AttachmentViewSet(viewsets.ViewSet):
     def create(self, request):
     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."))
             raise PermissionDenied(_("You don't have permission to upload new files."))
 
 
         try:
         try:
@@ -31,7 +31,7 @@ class AttachmentViewSet(viewsets.ViewSet):
 
 
         user_roles = set(r.pk for r in request.user.get_roles())
         user_roles = set(r.pk for r in request.user.get_roles())
         filetype = validate_filetype(upload, user_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(
         attachment = Attachment(
             secret=Attachment.generate_new_secret(),
             secret=Attachment.generate_new_secret(),
@@ -52,7 +52,7 @@ class AttachmentViewSet(viewsets.ViewSet):
             attachment.set_file(upload)
             attachment.set_file(upload)
 
 
         attachment.save()
         attachment.save()
-        add_acl(request.user, attachment)
+        add_acl_to_obj(request.user_acl, attachment)
 
 
         create_audit_trail(request, 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 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.permissions import allow_vote_poll
 from misago.threads.serializers import PollSerializer, NewVoteSerializer
 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):
 def poll_vote_create(request, thread, poll):
     poll.make_choices_votes_aware(request.user)
     poll.make_choices_votes_aware(request.user)
 
 
-    allow_vote_poll(request.user, poll)
+    allow_vote_poll(request.user_acl, poll)
 
 
     serializer = NewVoteSerializer(
     serializer = NewVoteSerializer(
         data={
         data={
@@ -33,7 +33,7 @@ def poll_vote_create(request, thread, poll):
     remove_user_votes(request.user, poll, serializer.data['choices'])
     remove_user_votes(request.user, poll, serializer.data['choices'])
     set_new_votes(request, 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
     serialized_poll = PollSerializer(poll).data
 
 
     poll.choices = list(map(presave_clean_choice, deepcopy(poll.choices)))
     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):
 def delete_post(request, thread, post):
     if post.is_event:
     if post.is_event:
-        allow_delete_event(request.user, post)
+        allow_delete_event(request.user_acl, post)
     else:
     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)
     moderation.delete_post(request.user, post)
 
 
@@ -34,7 +34,7 @@ def delete_bulk(request, thread):
         data={'posts': request.data},
         data={'posts': request.data},
         context={
         context={
             'thread': thread,
             '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 import timezone
 from django.utils.translation import gettext as _
 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.core.shortcuts import get_int_or_404
 from misago.markup import common_flavour
 from misago.markup import common_flavour
 from misago.threads.checksums import update_post_checksum
 from misago.threads.checksums import update_post_checksum
@@ -71,7 +71,7 @@ def revert_post_endpoint(request, post):
     post.is_new = False
     post.is_new = False
     post.edits = post_edits + 1
     post.edits = post_edits + 1
 
 
-    add_acl(request.user, post)
+    add_acl_to_obj(request.user_acl, post)
 
 
     if post.poster:
     if post.poster:
         make_users_status_aware(request, [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.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 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
 from misago.threads.serializers import MergePostsSerializer, PostSerializer
 
 
 
 
@@ -15,7 +15,7 @@ def posts_merge_endpoint(request, thread):
         data=request.data,
         data=request.data,
         context={
         context={
             'thread': thread,
             '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.thread = thread
     first_post.category = thread.category
     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)
     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.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 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.core.apipatch import ApiPatch
 from misago.threads.moderation import posts as moderation
 from misago.threads.moderation import posts as moderation
 from misago.threads.permissions import allow_hide_event, allow_unhide_event
 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):
 def patch_acl(request, event, value):
     """useful little op that updates event acl to current state"""
     """useful little op that updates event acl to current state"""
     if value:
     if value:
-        add_acl(request.user, event)
+        add_acl_to_obj(request.user_acl, event)
         return {'acl': event.acl}
         return {'acl': event.acl}
     else:
     else:
         return {'acl': None}
         return {'acl': None}
@@ -24,10 +24,10 @@ event_patch_dispatcher.add('acl', patch_acl)
 
 
 def patch_is_hidden(request, event, value):
 def patch_is_hidden(request, event, value):
     if value:
     if value:
-        allow_hide_event(request.user, event)
+        allow_hide_event(request.user_acl, event)
         moderation.hide_post(request.user, event)
         moderation.hide_post(request.user, event)
     else:
     else:
-        allow_unhide_event(request.user, event)
+        allow_unhide_event(request.user_acl, event)
         moderation.unhide_post(request.user, event)
         moderation.unhide_post(request.user, event)
 
 
     return {'is_hidden': event.is_hidden}
     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.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 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.conf import settings
 from misago.core.apipatch import ApiPatch
 from misago.core.apipatch import ApiPatch
 from misago.threads.models import PostLike
 from misago.threads.models import PostLike
@@ -23,7 +23,7 @@ post_patch_dispatcher = ApiPatch()
 def patch_acl(request, post, value):
 def patch_acl(request, post, value):
     """useful little op that updates post acl to current state"""
     """useful little op that updates post acl to current state"""
     if value:
     if value:
-        add_acl(request.user, post)
+        add_acl_to_obj(request.user_acl, post)
         return {'acl': post.acl}
         return {'acl': post.acl}
     else:
     else:
         return {'acl': None}
         return {'acl': None}
@@ -89,7 +89,7 @@ post_patch_dispatcher.replace('is-liked', patch_is_liked)
 
 
 
 
 def patch_is_protected(request, post, value):
 def patch_is_protected(request, post, value):
-    allow_protect_post(request.user, post)
+    allow_protect_post(request.user_acl, post)
     if value:
     if value:
         moderation.protect_post(request.user, post)
         moderation.protect_post(request.user, post)
     else:
     else:
@@ -101,7 +101,7 @@ post_patch_dispatcher.replace('is-protected', patch_is_protected)
 
 
 
 
 def patch_is_unapproved(request, post, value):
 def patch_is_unapproved(request, post, value):
-    allow_approve_post(request.user, post)
+    allow_approve_post(request.user_acl, post)
 
 
     if value:
     if value:
         raise PermissionDenied(_("Content approval can't be reversed."))
         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):
 def patch_is_hidden(request, post, value):
     if value is True:
     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)
         moderation.hide_post(request.user, post)
     elif value is False:
     elif value is False:
-        allow_unhide_post(request.user, post)
+        allow_unhide_post(request.user_acl, post)
         moderation.unhide_post(request.user, post)
         moderation.unhide_post(request.user, post)
 
 
     return {'is_hidden': post.is_hidden}
     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):
 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(
     posts_queryset = posts_queryset.filter(
         id__in=posts_ids,
         id__in=posts_ids,
         is_event=False,
         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.last_read_on = post.posted_on
             thread.subscription.save()
             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
     # send signal if post read marked thread as read
     # used in some places, eg. syncing unread thread count
     # 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,
         data=request.data,
         context={
         context={
             'thread': thread,
             '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
         # build kwargs dict for passing to middlewares
         self.kwargs = kwargs
         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)
         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 gettext as _
 from django.utils.translation import ngettext
 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.conf import settings
 from misago.threads.serializers import AttachmentSerializer
 from misago.threads.serializers import AttachmentSerializer
 
 
@@ -12,7 +12,7 @@ from . import PostingEndpoint, PostingMiddleware
 
 
 class AttachmentsMiddleware(PostingMiddleware):
 class AttachmentsMiddleware(PostingMiddleware):
     def use_this_middleware(self):
     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):
     def get_serializer(self):
         return AttachmentsSerializer(
         return AttachmentsSerializer(
@@ -20,6 +20,7 @@ class AttachmentsMiddleware(PostingMiddleware):
             context={
             context={
                 'mode': self.mode,
                 'mode': self.mode,
                 'user': self.user,
                 'user': self.user,
+                'user_acl': self.user_acl,
                 'post': self.post,
                 'post': self.post,
             }
             }
         )
         )
@@ -41,7 +42,7 @@ class AttachmentsSerializer(serializers.Serializer):
         validate_attachments_count(ids)
         validate_attachments_count(ids)
 
 
         attachments = self.get_initial_attachments(
         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)
         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 += new_attachments
             self.final_attachments.sort(key=lambda a: a.pk, reverse=True)
             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 = []
         attachments = []
         if mode == PostingEndpoint.EDIT:
         if mode == PostingEndpoint.EDIT:
             queryset = post.attachment_set.select_related('filetype')
             queryset = post.attachment_set.select_related('filetype')
             attachments = list(queryset)
             attachments = list(queryset)
-            add_acl(user, attachments)
+            add_acl_to_obj(user_acl, attachments)
         return attachments
         return attachments
 
 
     def get_new_attachments(self, user, ids):
     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 as _
 from django.utils.translation import gettext_lazy
 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 import THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.categories.permissions import can_browse_category, can_see_category
 from misago.categories.permissions import can_browse_category, can_see_category
@@ -23,12 +23,12 @@ class CategoryMiddleware(PostingMiddleware):
         return False
         return False
 
 
     def get_serializer(self):
     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):
     def pre_save(self, serializer):
         category = serializer.category_cache
         category = serializer.category_cache
 
 
-        add_acl(self.user, category)
+        add_acl_to_obj(self.user_acl, category)
 
 
         # set flags for savechanges middleware
         # set flags for savechanges middleware
         category.update_all = False
         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
         self.category_cache = None
 
 
         super().__init__(*args, **kwargs)
         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)
                 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):
             if not (self.category_cache.level and can_see and can_browse):
                 raise PermissionDenied(_("Selected category is invalid."))
                 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:
         except Category.DoesNotExist:
             raise serializers.ValidationError(
             raise serializers.ValidationError(
                 _("Selected category doesn't exist or you don't have permission to browse it.")
                 _("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 django.utils.translation import gettext as _
 
 
+from misago.acl import useracl
 from misago.core.mail import build_mail, send_messages
 from misago.core.mail import build_mail, send_messages
 from misago.threads.permissions import can_see_post, can_see_thread
 from misago.threads.permissions import can_see_post, can_see_thread
 
 
@@ -23,15 +24,16 @@ class EmailNotificationMiddleware(PostingMiddleware):
 
 
         notifications = []
         notifications = []
         for subscription in queryset.iterator():
         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))
                 notifications.append(self.build_mail(subscription.user))
 
 
         if notifications:
         if notifications:
             send_messages(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
         return see_thread and see_post
 
 
     def build_mail(self, subscriber):
     def build_mail(self, subscriber):

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

@@ -13,8 +13,10 @@ MIN_POSTING_PAUSE = 3
 
 
 class FloodProtectionMiddleware(PostingMiddleware):
 class FloodProtectionMiddleware(PostingMiddleware):
     def use_this_middleware(self):
     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):
     def interrupt_posting(self, serializer):
         now = timezone.now()
         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.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _, ngettext
 from django.utils.translation import gettext as _, ngettext
 
 
+from misago.acl import useracl
 from misago.categories import PRIVATE_THREADS_ROOT_NAME
 from misago.categories import PRIVATE_THREADS_ROOT_NAME
 from misago.threads.participants import add_participants, set_owner
 from misago.threads.participants import add_participants, set_owner
 from misago.threads.permissions import allow_message_user
 from misago.threads.permissions import allow_message_user
@@ -21,7 +22,14 @@ class ParticipantsMiddleware(PostingMiddleware):
         return False
         return False
 
 
     def get_serializer(self):
     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):
     def save(self, serializer):
         set_owner(self.thread, self.user)
         set_owner(self.thread, self.user)
@@ -51,7 +59,7 @@ class ParticipantsSerializer(serializers.Serializer):
         if not clean_usernames:
         if not clean_usernames:
             raise serializers.ValidationError(_("You have to enter user names."))
             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:
         if max_participants and len(clean_usernames) > max_participants:
             message = ngettext(
             message = ngettext(
                 "You can't add more than %(users)s user to private thread (you've added %(added)s).",
                 "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 = []
         users = []
         for user in UserModel.objects.filter(slug__in=usernames):
         for user in UserModel.objects.filter(slug__in=usernames):
             try:
             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:
             except PermissionDenied as e:
                 raise serializers.ValidationError(str(e))
                 raise serializers.ValidationError(str(e))
             users.append(user)
             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 import PRIVATE_THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.categories.models import Category
 
 
@@ -16,7 +16,7 @@ class PrivateThreadMiddleware(PostingMiddleware):
     def pre_save(self, serializer):
     def pre_save(self, serializer):
         category = Category.objects.private_threads()
         category = Category.objects.private_threads()
 
 
-        add_acl(self.user, category)
+        add_acl_to_obj(self.user_acl, category)
 
 
         # set flags for savechanges middleware
         # set flags for savechanges middleware
         category.update_all = False
         category.update_all = False

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

@@ -10,7 +10,7 @@ from misago.threads.serializers import DeleteThreadsSerializer
 
 
 @transaction.atomic
 @transaction.atomic
 def delete_thread(request, thread):
 def delete_thread(request, thread):
-    allow_delete_thread(request.user, thread)
+    allow_delete_thread(request.user_acl, thread)
     moderation.delete_thread(request.user, thread)
     moderation.delete_thread(request.user, thread)
     return Response({})
     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.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 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 import THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.threads.permissions import can_start_thread
 from misago.threads.permissions import can_start_thread
@@ -19,15 +19,15 @@ def thread_start_editor(request):
     categories = []
     categories = []
 
 
     queryset = Category.objects.filter(
     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)
         tree_id=trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
     ).order_by('-lft')
     ).order_by('-lft')
 
 
     for category in queryset:
     for category in queryset:
-        add_acl(request.user, category)
+        add_acl_to_obj(request.user_acl, category)
 
 
         post = False
         post = False
-        if can_start_thread(request.user, category):
+        if can_start_thread(request.user_acl, category):
             post = {
             post = {
                 'close': bool(category.acl['can_close_threads']),
                 'close': bool(category.acl['can_close_threads']),
                 'hide': bool(category.acl['can_hide_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.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 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.events import record_event
 from misago.threads.mergeconflict import MergeConflict
 from misago.threads.mergeconflict import MergeConflict
 from misago.threads.models import Thread
 from misago.threads.models import Thread
@@ -15,7 +15,7 @@ from misago.threads.serializers import (
 
 
 
 
 def thread_merge_endpoint(request, thread, viewmodel):
 def thread_merge_endpoint(request, thread, viewmodel):
-    allow_merge_thread(request.user, thread)
+    allow_merge_thread(request.user_acl, thread)
 
 
     serializer = MergeThreadSerializer(
     serializer = MergeThreadSerializer(
         data=request.data,
         data=request.data,
@@ -88,9 +88,7 @@ def thread_merge_endpoint(request, thread, viewmodel):
 def threads_merge_endpoint(request):
 def threads_merge_endpoint(request):
     serializer = MergeThreadsSerializer(
     serializer = MergeThreadsSerializer(
         data=request.data,
         data=request.data,
-        context={
-            'user': request.user
-        },
+        context={'user_acl': request.user_acl},
     )
     )
 
 
     if not serializer.is_valid():
     if not serializer.is_valid():
@@ -108,7 +106,7 @@ def threads_merge_endpoint(request):
 
 
     for thread in threads:
     for thread in threads:
         try:
         try:
-            allow_merge_thread(request.user, thread)
+            allow_merge_thread(request.user_acl, thread)
         except PermissionDenied as e:
         except PermissionDenied as e:
             invalid_threads.append({
             invalid_threads.append({
                 'id': thread.pk,
                 'id': thread.pk,
@@ -191,5 +189,5 @@ def merge_threads(request, validated_data, threads, merge_conflict):
     new_thread.is_read = False
     new_thread.is_read = False
     new_thread.subscription = None
     new_thread.subscription = None
 
 
-    add_acl(request.user, new_thread)
+    add_acl_to_obj(request.user_acl, new_thread)
     return 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.shortcuts import get_object_or_404
 from django.utils.translation import gettext as _
 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.models import Category
 from misago.categories.permissions import allow_browse_category, allow_see_category
 from misago.categories.permissions import allow_browse_category, allow_see_category
 from misago.categories.serializers import CategorySerializer
 from misago.categories.serializers import CategorySerializer
@@ -37,7 +38,7 @@ thread_patch_dispatcher = ApiPatch()
 def patch_acl(request, thread, value):
 def patch_acl(request, thread, value):
     """useful little op that updates thread acl to current state"""
     """useful little op that updates thread acl to current state"""
     if value:
     if value:
-        add_acl(request.user, thread)
+        add_acl_to_obj(request.user_acl, thread)
         return {'acl': thread.acl}
         return {'acl': thread.acl}
     else:
     else:
         return {'acl': None}
         return {'acl': None}
@@ -57,7 +58,7 @@ def patch_title(request, thread, value):
     except ValidationError as e:
     except ValidationError as e:
         raise PermissionDenied(e.args[0])
         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)
     moderation.change_thread_title(request, thread, value_cleaned)
     return {'title': thread.title}
     return {'title': thread.title}
@@ -67,7 +68,7 @@ thread_patch_dispatcher.replace('title', patch_title)
 
 
 
 
 def patch_weight(request, thread, value):
 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:
     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."))
         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):
 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)
     category_pk = get_int_or_404(value)
     new_category = get_object_or_404(
     new_category = get_object_or_404(
         Category.objects.all_categories().select_related('parent'), pk=category_pk
         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:
     if new_category == thread.category:
         raise PermissionDenied(_("You can't move thread to the category it's already in."))
         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):
 def patch_is_unapproved(request, thread, value):
-    allow_approve_thread(request.user, thread)
+    allow_approve_thread(request.user_acl, thread)
 
 
     if value:
     if value:
         raise PermissionDenied(_("Content approval can't be reversed."))
         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):
 def patch_is_hidden(request, thread, value):
     if value:
     if value:
-        allow_hide_thread(request.user, thread)
+        allow_hide_thread(request.user_acl, thread)
         moderation.hide_thread(request, thread)
         moderation.hide_thread(request, thread)
     else:
     else:
-        allow_unhide_thread(request.user, thread)
+        allow_unhide_thread(request.user_acl, thread)
         moderation.unhide_thread(request, thread)
         moderation.unhide_thread(request, thread)
 
 
     return {'is_hidden': thread.is_hidden}
     return {'is_hidden': thread.is_hidden}
@@ -205,20 +206,20 @@ def patch_best_answer(request, thread, value):
     except (TypeError, ValueError):
     except (TypeError, ValueError):
         raise PermissionDenied(_("A valid integer is required."))
         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 = get_object_or_404(thread.post_set, id=post_id)
     post.category = thread.category
     post.category = thread.category
     post.thread = thread
     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:
     if post.is_best_answer:
         raise PermissionDenied(_("This post is already marked as thread's best answer."))
         raise PermissionDenied(_("This post is already marked as thread's best answer."))
 
 
     if thread.has_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.set_best_answer(request.user, post)
     thread.save()
     thread.save()
@@ -250,7 +251,7 @@ def patch_unmark_best_answer(request, thread, value):
         raise PermissionDenied(
         raise PermissionDenied(
             _("This post can't be unmarked because it's not currently marked as best answer."))
             _("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.clear_best_answer()
     thread.save()
     thread.save()
 
 
@@ -268,7 +269,7 @@ thread_patch_dispatcher.remove('best-answer', patch_unmark_best_answer)
 
 
 
 
 def patch_add_participant(request, thread, value):
 def patch_add_participant(request, thread, value):
-    allow_add_participants(request.user, thread)
+    allow_add_participants(request.user_acl, thread)
 
 
     try:
     try:
         username = str(value).strip().lower()
         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]:
     if participant in [p.user for p in thread.participants_list]:
         raise PermissionDenied(_("This user is already thread participant."))
         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)
     add_participant(request, thread, participant)
 
 
     make_participants_aware(request.user, thread)
     make_participants_aware(request.user, thread)
@@ -305,7 +307,7 @@ def patch_remove_participant(request, thread, value):
     else:
     else:
         raise PermissionDenied(_("Participant doesn't exist."))
         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)
     remove_participant(request, thread, participant.user)
 
 
     if len(thread.participants_list) == 1:
     if len(thread.participants_list) == 1:
@@ -338,7 +340,7 @@ def patch_replace_owner(request, thread, value):
     else:
     else:
         raise PermissionDenied(_("Participant doesn't exist."))
         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)
     change_owner(request, thread, participant.user)
 
 
     make_participants_aware(request.user, thread)
     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.http import Http404
 from django.utils.translation import gettext as _
 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.core.shortcuts import get_int_or_404
 from misago.threads.models import Poll
 from misago.threads.models import Poll
 from misago.threads.permissions import (
 from misago.threads.permissions import (
@@ -47,7 +47,7 @@ class ViewSet(viewsets.ViewSet):
     @transaction.atomic
     @transaction.atomic
     def create(self, request, thread_pk):
     def create(self, request, thread_pk):
         thread = self.get_thread(request, thread_pk)
         thread = self.get_thread(request, thread_pk)
-        allow_start_poll(request.user, thread)
+        allow_start_poll(request.user_acl, thread)
 
 
         try:
         try:
             if thread.poll and thread.poll.pk:
             if thread.poll and thread.poll.pk:
@@ -68,7 +68,7 @@ class ViewSet(viewsets.ViewSet):
 
 
         serializer.save()
         serializer.save()
 
 
-        add_acl(request.user, instance)
+        add_acl_to_obj(request.user_acl, instance)
         for choice in instance.choices:
         for choice in instance.choices:
             choice['selected'] = False
             choice['selected'] = False
 
 
@@ -84,14 +84,14 @@ class ViewSet(viewsets.ViewSet):
         thread = self.get_thread(request, thread_pk)
         thread = self.get_thread(request, thread_pk)
         instance = self.get_poll(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 = EditPollSerializer(instance, data=request.data)
         serializer.is_valid(raise_exception=True)
         serializer.is_valid(raise_exception=True)
 
 
         serializer.save()
         serializer.save()
 
 
-        add_acl(request.user, instance)
+        add_acl_to_obj(request.user_acl, instance)
         instance.make_choices_votes_aware(request.user)
         instance.make_choices_votes_aware(request.user)
 
 
         create_audit_trail(request, instance)
         create_audit_trail(request, instance)
@@ -103,7 +103,7 @@ class ViewSet(viewsets.ViewSet):
         thread = self.get_thread(request, thread_pk)
         thread = self.get_thread(request, thread_pk)
         instance = self.get_poll(thread, pk)
         instance = self.get_poll(thread, pk)
 
 
-        allow_delete_poll(request.user, instance)
+        allow_delete_poll(request.user_acl, instance)
 
 
         thread.poll.delete()
         thread.poll.delete()
 
 
@@ -111,7 +111,7 @@ class ViewSet(viewsets.ViewSet):
         thread.save()
         thread.save()
 
 
         return Response({
         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'])
     @detail_route(methods=['get', 'post'])
@@ -138,7 +138,7 @@ class ViewSet(viewsets.ViewSet):
         except Poll.DoesNotExist:
         except Poll.DoesNotExist:
             raise Http404()
             raise Http404()
 
 
-        allow_see_poll_votes(request.user, thread.poll)
+        allow_see_poll_votes(request.user_acl, thread.poll)
 
 
         choices = []
         choices = []
         voters = {}
         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.db import transaction
 from django.utils.translation import gettext as _
 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.core.shortcuts import get_int_or_404
 from misago.threads.models import Post
 from misago.threads.models import Post
 from misago.threads.permissions import allow_edit_post, allow_reply_thread
 from misago.threads.permissions import allow_edit_post, allow_reply_thread
@@ -86,7 +86,7 @@ class ViewSet(viewsets.ViewSet):
     @transaction.atomic
     @transaction.atomic
     def create(self, request, thread_pk):
     def create(self, request, thread_pk):
         thread = self.get_thread(request, thread_pk).unwrap()
         thread = self.get_thread(request, thread_pk).unwrap()
-        allow_reply_thread(request.user, thread)
+        allow_reply_thread(request.user_acl, thread)
 
 
         post = Post(
         post = Post(
             thread=thread,
             thread=thread,
@@ -122,7 +122,7 @@ class ViewSet(viewsets.ViewSet):
         thread = self.get_thread(request, thread_pk).unwrap()
         thread = self.get_thread(request, thread_pk).unwrap()
         post = self.get_post(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(
         posting = PostingEndpoint(
             request,
             request,
@@ -188,11 +188,11 @@ class ViewSet(viewsets.ViewSet):
         thread = self.get_thread(request, thread_pk)
         thread = self.get_thread(request, thread_pk)
         post = self.get_post(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)
 
 
         attachments = []
         attachments = []
         for attachment in post.attachment_set.order_by('-id'):
         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.append(attachment)
         attachments_json = AttachmentSerializer(
         attachments_json = AttachmentSerializer(
             attachments, many=True, context={'user': request.user}
             attachments, many=True, context={'user': request.user}
@@ -211,7 +211,7 @@ class ViewSet(viewsets.ViewSet):
     @list_route(methods=['get'], url_path='editor')
     @list_route(methods=['get'], url_path='editor')
     def reply_editor(self, request, thread_pk):
     def reply_editor(self, request, thread_pk):
         thread = self.get_thread(request, thread_pk).unwrap()
         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:
         if 'reply' in request.query_params:
             reply_to = self.get_post(request, thread, request.query_params['reply']).unwrap()
             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)
                 thread = self.get_thread(request, thread_pk)
                 post = self.get_post(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)
 
 
                 return revert_post_endpoint(request, post)
                 return revert_post_endpoint(request, post)
 
 

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

@@ -118,8 +118,8 @@ class PrivateThreadViewSet(ViewSet):
 
 
     @transaction.atomic
     @transaction.atomic
     def create(self, request):
     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."))
             raise PermissionDenied(_("You can't start private threads."))
 
 
         request.user.lock()
         request.user.lock()

+ 3 - 3
misago/threads/middleware.py

@@ -11,7 +11,7 @@ class UnreadThreadsCountMiddleware(MiddlewareMixin):
         if request.user.is_anonymous:
         if request.user.is_anonymous:
             return
             return
 
 
-        if not request.user.acl_cache['can_use_private_threads']:
+        if not request.user_acl['can_use_private_threads']:
             return
             return
 
 
         if not request.user.sync_unread_private_threads:
         if not request.user.sync_unread_private_threads:
@@ -22,8 +22,8 @@ class UnreadThreadsCountMiddleware(MiddlewareMixin):
         category = Category.objects.private_threads()
         category = Category.objects.private_threads()
         threads = Thread.objects.filter(category=category, id__in=participated_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.unread_private_threads = new_threads.count() + unread_threads.count()
         request.user.sync_unread_private_threads = False
         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({
         attachment.acl.update({
             'can_delete': True,
             'can_delete': True,
         })
         })
     else:
     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({
         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
     return final_acl
 
 
 
 
-def add_acl_to_thread(user, thread):
+def add_acl_to_thread(user_acl, thread):
     thread.acl.update({
     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({
     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)
     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."))
         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'):
     if not category_acl.get('can_mark_best_answers'):
         raise PermissionDenied(
         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(
         raise PermissionDenied(
             _(
             _(
                 "You don't have permission to mark best answer in this thread because you didn't "
                 "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)
 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:
     if not target.has_best_answer:
         return # shortcircut permission test
         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'):
     if not category_acl.get('can_change_marked_answers'):
         raise PermissionDenied(
         raise PermissionDenied(
@@ -191,14 +191,14 @@ def allow_change_best_answer(user, target):
         )
         )
 
 
     if category_acl['can_change_marked_answers'] == 1:
     if category_acl['can_change_marked_answers'] == 1:
-        if target.starter_id != user.id:
+        if user_acl["user_id"] != target.starter_id:
             raise PermissionDenied(
             raise PermissionDenied(
                 _(
                 _(
                     "You don't have permission to change this thread's marked answer because you "
                     "You don't have permission to change this thread's marked answer because you "
                     "are not a thread starter."
                     "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(
             raise PermissionDenied(
                 ngettext(
                 ngettext(
                     (
                     (
@@ -227,14 +227,14 @@ def allow_change_best_answer(user, target):
 can_change_best_answer = return_boolean(allow_change_best_answer)
 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."))
         raise PermissionDenied(_("You have to sign in to unmark best answers."))
 
 
     if not target.has_best_answer:
     if not target.has_best_answer:
         return # shortcircut test
         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'):
     if not category_acl.get('can_change_marked_answers'):
         raise PermissionDenied(
         raise PermissionDenied(
@@ -247,14 +247,14 @@ def allow_unmark_best_answer(user, target):
         )
         )
 
 
     if category_acl['can_change_marked_answers'] == 1:
     if category_acl['can_change_marked_answers'] == 1:
-        if target.starter_id != user.id:
+        if user_acl["user_id"] != target.starter_id:
             raise PermissionDenied(
             raise PermissionDenied(
                 _(
                 _(
                     "You don't have permission to unmark this best answer because you are not a "
                     "You don't have permission to unmark this best answer because you are not a "
                     "thread starter."
                     "thread starter."
                 )
                 )
             )
             )
-        if not has_time_to_change_answer(user, target):
+        if not has_time_to_change_answer(user_acl, target):
             raise PermissionDenied(
             raise PermissionDenied(
                 ngettext(
                 ngettext(
                     (
                     (
@@ -301,14 +301,14 @@ def allow_unmark_best_answer(user, target):
 can_unmark_best_answer = return_boolean(allow_unmark_best_answer)
 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."))
         raise PermissionDenied(_("You have to sign in to mark best answers."))
 
 
     if target.is_event:
     if target.is_event:
         raise PermissionDenied(_("Events can't be marked as best answers."))
         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'):
     if not category_acl.get('can_mark_best_answers'):
         raise PermissionDenied(
         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(
         raise PermissionDenied(
             _(
             _(
                 "You don't have permission to mark best answer in this thread because you "
                 "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)
 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:
     if target.is_best_answer:
         raise PermissionDenied(
         raise PermissionDenied(
             _("You can't hide this post because its marked as best answer.")
             _("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)
 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:
     if target.is_best_answer:
         raise PermissionDenied(
         raise PermissionDenied(
             _("You can't delete this post because its marked as best answer.")
             _("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)
 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)
     change_time = category_acl.get('best_answer_change_time', 0)
 
 
     if change_time:
     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({
     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({
     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)
     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."))
         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, {
         target.category_id, {
             'can_close_threads': False,
             '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."))
         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."))
         raise PermissionDenied(_("You can't start polls in other users threads."))
 
 
     if not category_acl.get('can_close_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)
 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."))
         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, {
         target.category_id, {
             'can_close_threads': False,
             '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."))
         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."))
             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(
             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 minute.",
                 "You can't edit polls that are older than %(minutes)s minutes.",
                 "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:
         if target.is_over:
             raise PermissionDenied(_("This poll is over. You can't edit it."))
             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)
 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."))
         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, {
         target.category_id, {
             'can_close_threads': False,
             '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."))
         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."))
             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(
             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 minute.",
                 "You can't delete polls that are older than %(minutes)s minutes.",
                 "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:
         if target.is_over:
             raise PermissionDenied(_("This poll is over. You can't delete it."))
             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)
 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."))
         raise PermissionDenied(_("You have to sign in to vote in polls."))
 
 
     if target.has_selected_choices and not target.allow_revotes:
     if target.has_selected_choices and not target.allow_revotes:
@@ -225,7 +225,7 @@ def allow_vote_poll(user, target):
     if target.is_over:
     if target.is_over:
         raise PermissionDenied(_("This poll is over. You can't vote in it."))
         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, {
         target.category_id, {
             'can_close_threads': False,
             'can_close_threads': False,
         }
         }
@@ -241,16 +241,16 @@ def allow_vote_poll(user, target):
 can_vote_poll = return_boolean(allow_vote_poll)
 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."))
         raise PermissionDenied(_("You dont have permission to this poll's voters."))
 
 
 
 
 can_see_poll_votes = return_boolean(allow_see_poll_votes)
 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:
     if edit_time:
         diff = timezone.now() - target.posted_on
         diff = timezone.now() - target.posted_on
         diff_minutes = int(diff.total_seconds() / 60)
         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
     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:
     if thread.thread_type.root_name != PRIVATE_THREADS_ROOT_NAME:
         return
         return
 
 
@@ -162,8 +162,8 @@ def add_acl_to_thread(user, thread):
 
 
     thread.acl.update({
     thread.acl.update({
         'can_start_poll': False,
         '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)
     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."))
         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."))
         raise PermissionDenied(_("You can't use private threads."))
 
 
 
 
 can_use_private_threads = return_boolean(allow_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
         can_see_reported = target.has_reported_posts
     else:
     else:
         can_see_reported = False
         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):
     if not (can_see_participating or can_see_reported):
         raise Http404()
         raise Http404()
@@ -196,8 +196,8 @@ def allow_see_private_thread(user, target):
 can_see_private_thread = return_boolean(allow_see_private_thread)
 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
     is_owner = target.participant and target.participant.is_owner
 
 
     if not (is_owner or is_moderator):
     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)
 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 is_moderator:
         if not target.participant or not target.participant.is_owner:
         if not target.participant or not target.participant.is_owner:
@@ -220,7 +220,7 @@ def allow_add_participants(user, target):
         if target.is_closed:
         if target.is_closed:
             raise PermissionDenied(_("Only moderators can add participants to closed threads."))
             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
     current_participants = len(target.participants_list) - 1
 
 
     if current_participants >= max_participants:
     if current_participants >= max_participants:
@@ -230,11 +230,11 @@ def allow_add_participants(user, target):
 can_add_participants = return_boolean(allow_add_participants)
 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
         return
 
 
-    if user == target:
+    if user_acl["user_id"] == target.id:
         return  # we can always remove ourselves
         return  # we can always remove ourselves
 
 
     if thread.is_closed:
     if thread.is_closed:
@@ -247,18 +247,18 @@ def allow_remove_participant(user, thread, target):
 can_remove_participant = return_boolean(allow_remove_participant)
 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}
     message_format = {'user': target.username}
 
 
-    if not can_use_private_threads(target):
+    if not can_use_private_threads(target_acl):
         raise PermissionDenied(
         raise PermissionDenied(
             _("%(user)s can't participate in private threads.") % message_format
             _("%(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
         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)
         raise PermissionDenied(_("%(user)s is blocking you.") % message_format)
 
 
     if target.can_be_messaged_by_nobody:
     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
             _("%(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.")
         message = _("%(user)s limits invitations to private threads to followed users.")
         raise PermissionDenied(message % message_format)
         raise PermissionDenied(message % message_format)
 
 
@@ -274,9 +274,9 @@ def allow_add_participant(user, target):
 can_add_participant = return_boolean(allow_add_participant)
 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)
 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 import timezone
 from django.utils.translation import gettext_lazy as _, ngettext
 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.decorators import return_boolean
 from misago.acl.models import Role
 from misago.acl.models import Role
+from misago.acl.objectacl import add_acl_to_obj
 from misago.admin.forms import YesNoSwitch
 from misago.admin.forms import YesNoSwitch
 from misago.categories.models import Category, CategoryRole
 from misago.categories.models import Category, CategoryRole
 from misago.categories.permissions import get_categories_roles
 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
     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({
     category.acl.update({
         'can_see_all_threads': 0,
         'can_see_all_threads': 0,
@@ -411,7 +412,7 @@ def add_acl_to_category(user, category):
         can_see_posts_likes=algebra.greater,
         can_see_posts_likes=algebra.greater,
     )
     )
 
 
-    if user.is_authenticated:
+    if user_acl["is_authenticated"]:
         algebra.sum_acls(
         algebra.sum_acls(
             category.acl,
             category.acl,
             acls=[category_acl],
             acls=[category_acl],
@@ -442,7 +443,7 @@ def add_acl_to_category(user, category):
             can_hide_events=algebra.greater,
             can_hide_events=algebra.greater,
         )
         )
 
 
-    if user.acl_cache['can_approve_content']:
+    if user_acl['can_approve_content']:
         category.acl.update({
         category.acl.update({
             'require_threads_approval': 0,
             'require_threads_approval': 0,
             'require_replies_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']
     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({
     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_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_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_move_posts': category_acl.get('can_move_posts', False),
         'can_merge_posts': category_acl.get('can_merge_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),
         '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
         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:
     if post.is_event:
-        add_acl_to_event(user, post)
+        add_acl_to_event(user_acl, post)
     else:
     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
     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, {
             event.category_id, {
                 'can_hide_events': 0,
                 'can_hide_events': 0,
             }
             }
@@ -497,25 +498,25 @@ def add_acl_to_event(user, event):
 
 
     event.acl.update({
     event.acl.update({
         'can_see_hidden': can_hide_events > 0,
         '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({
     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_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_report': category_acl.get('can_report_content', False),
         'can_see_reports': category_acl.get('can_see_reports', False),
         'can_see_reports': category_acl.get('can_see_reports', False),
         'can_see_likes': category_acl.get('can_see_posts_likes', 0),
         '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']:
     if not post.acl['can_see_hidden']:
         post.acl['can_see_hidden'] = post.id == post.thread.first_post_id
         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)
         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)
     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, {
         target.category_id, {
             'can_see': False,
             'can_see': False,
             'can_browse': 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']):
     if not (category_acl['can_see'] and category_acl['can_browse']):
         raise Http404()
         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()
         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']:
         if not category_acl['can_see_all_threads']:
             raise Http404()
             raise Http404()
 
 
@@ -559,11 +560,11 @@ def allow_see_thread(user, target):
 can_see_thread = return_boolean(allow_see_thread)
 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."))
         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, {
         target.pk, {
             'can_start_threads': False,
             'can_start_threads': False,
         }
         }
@@ -581,11 +582,11 @@ def allow_start_thread(user, target):
 can_start_thread = return_boolean(allow_start_thread)
 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."))
         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, {
         target.category_id, {
             'can_reply_threads': False,
             'can_reply_threads': False,
         }
         }
@@ -604,11 +605,11 @@ def allow_reply_thread(user, target):
 can_reply_thread = return_boolean(allow_reply_thread)
 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."))
         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, {
         target.category_id, {
             'can_edit_threads': False,
             'can_edit_threads': False,
         }
         }
@@ -618,10 +619,10 @@ def allow_edit_thread(user, target):
         raise PermissionDenied(_("You can't edit threads in this category."))
         raise PermissionDenied(_("You can't edit threads in this category."))
 
 
     if category_acl['can_edit_threads'] == 1:
     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."))
             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(
             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 minute.",
                 "You can't edit threads that are older than %(minutes)s minutes.",
                 "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)
 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."))
         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, {
         target.category_id, {
             'can_pin_threads': 0,
             'can_pin_threads': 0,
         }
         }
@@ -662,11 +663,11 @@ def allow_pin_thread(user, target):
 can_pin_thread = return_boolean(allow_pin_thread)
 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."))
         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, {
         target.category_id, {
             'can_close_threads': False,
             'can_close_threads': False,
         }
         }
@@ -682,11 +683,11 @@ def allow_unhide_thread(user, target):
 can_unhide_thread = return_boolean(allow_unhide_thread)
 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."))
         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, {
         target.category_id, {
             'can_hide_threads': 0,
             'can_hide_threads': 0,
             'can_hide_own_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."))
         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 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."))
             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(
             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 minute.",
                 "You can't hide threads that are older than %(minutes)s minutes.",
                 "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)
 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."))
         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, {
         target.category_id, {
             'can_hide_threads': 0,
             'can_hide_threads': 0,
             'can_hide_own_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."))
         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 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."))
             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(
             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 minute.",
                 "You can't delete threads that are older than %(minutes)s minutes.",
                 "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)
 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."))
         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, {
         target.category_id, {
             'can_move_threads': 0,
             'can_move_threads': 0,
         }
         }
@@ -777,11 +778,11 @@ def allow_move_thread(user, target):
 can_move_thread = return_boolean(allow_move_thread)
 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."))
         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, {
         target.category_id, {
             'can_merge_threads': 0,
             'can_merge_threads': 0,
         }
         }
@@ -806,11 +807,11 @@ def allow_merge_thread(user, target, otherthread=False):
 can_merge_thread = return_boolean(allow_merge_thread)
 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."))
         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, {
         target.category_id, {
             'can_approve_content': 0,
             'can_approve_content': 0,
         }
         }
@@ -829,8 +830,8 @@ def allow_approve_thread(user, target):
 can_approve_thread = return_boolean(allow_approve_thread)
 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, {
         target.category_id, {
             'can_approve_content': False,
             'can_approve_content': False,
             'can_hide_events': 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 not target.is_event and target.is_unapproved:
-        if user.is_anonymous:
+        if user_acl["is_anonymous"]:
             raise Http404()
             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()
             raise Http404()
 
 
     if target.is_event and target.is_hidden and not category_acl['can_hide_events']:
     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)
 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."))
         raise PermissionDenied(_("You have to sign in to edit posts."))
 
 
     if target.is_event:
     if target.is_event:
         raise PermissionDenied(_("Events can't be edited."))
         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']:
     if not category_acl['can_edit_posts']:
         raise PermissionDenied(_("You can't edit posts in this category."))
         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."))
         raise PermissionDenied(_("This post is hidden, you can't edit it."))
 
 
     if category_acl['can_edit_posts'] == 1:
     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."))
             raise PermissionDenied(_("You can't edit other users posts in this category."))
 
 
         if target.is_protected and not category_acl['can_protect_posts']:
         if target.is_protected and not category_acl['can_protect_posts']:
             raise PermissionDenied(_("This post is protected. You can't edit it."))
             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(
             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 minute.",
                 "You can't edit posts that are older than %(minutes)s minutes.",
                 "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)
 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."))
         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, {
         target.category_id, {
             'can_hide_posts': 0,
             'can_hide_posts': 0,
             'can_hide_own_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']:
         if not category_acl['can_hide_own_posts']:
             raise PermissionDenied(_("You can't reveal posts in this category."))
             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."))
             raise PermissionDenied(_("You can't reveal other users posts in this category."))
 
 
         if target.is_protected and not category_acl['can_protect_posts']:
         if target.is_protected and not category_acl['can_protect_posts']:
             raise PermissionDenied(_("This post is protected. You can't reveal it."))
             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(
             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 minute.",
                 "You can't reveal posts that are older than %(minutes)s minutes.",
                 "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)
 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."))
         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, {
         target.category_id, {
             'can_hide_posts': 0,
             'can_hide_posts': 0,
             'can_hide_own_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']:
         if not category_acl['can_hide_own_posts']:
             raise PermissionDenied(_("You can't hide posts in this category."))
             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."))
             raise PermissionDenied(_("You can't hide other users posts in this category."))
 
 
         if target.is_protected and not category_acl['can_protect_posts']:
         if target.is_protected and not category_acl['can_protect_posts']:
             raise PermissionDenied(_("This post is protected. You can't hide it."))
             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(
             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 minute.",
                 "You can't hide posts that are older than %(minutes)s minutes.",
                 "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)
 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."))
         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, {
         target.category_id, {
             'can_hide_posts': 0,
             'can_hide_posts': 0,
             'can_hide_own_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:
         if category_acl['can_hide_own_posts'] != 2:
             raise PermissionDenied(_("You can't delete posts in this category."))
             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."))
             raise PermissionDenied(_("You can't delete other users posts in this category."))
 
 
         if target.is_protected and not category_acl['can_protect_posts']:
         if target.is_protected and not category_acl['can_protect_posts']:
             raise PermissionDenied(_("This post is protected. You can't delete it."))
             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(
             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 minute.",
                 "You can't delete posts that are older than %(minutes)s minutes.",
                 "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)
 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."))
         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}
         target.category_id, {'can_protect_posts': False}
     )
     )
 
 
     if not category_acl['can_protect_posts']:
     if not category_acl['can_protect_posts']:
         raise PermissionDenied(_("You can't protect posts in this category."))
         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."))
         raise PermissionDenied(_("You can't protect posts you can't edit."))
 
 
 
 
 can_protect_post = return_boolean(allow_protect_post)
 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."))
         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}
         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)
 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."))
         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, {
         target.category_id, {
             'can_move_posts': False,
             'can_move_posts': False,
         }
         }
@@ -1088,11 +1089,11 @@ def allow_move_post(user, target):
 can_move_post = return_boolean(allow_move_post)
 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."))
         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, {
         target.category_id, {
             'can_merge_posts': False,
             'can_merge_posts': False,
         }
         }
@@ -1115,11 +1116,11 @@ def allow_merge_post(user, target):
 can_merge_post = return_boolean(allow_merge_post)
 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."))
         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, {
         target.category_id, {
             'can_move_posts': False,
             'can_move_posts': False,
         }
         }
@@ -1143,11 +1144,11 @@ def allow_split_post(user, target):
 can_split_post = return_boolean(allow_split_post)
 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."))
         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, {
         target.category_id, {
             'can_hide_events': 0,
             'can_hide_events': 0,
         }
         }
@@ -1166,11 +1167,11 @@ def allow_unhide_event(user, target):
 can_unhide_event = return_boolean(allow_unhide_event)
 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."))
         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, {
         target.category_id, {
             'can_hide_events': 0,
             'can_hide_events': 0,
         }
         }
@@ -1189,11 +1190,11 @@ def allow_hide_event(user, target):
 can_hide_event = return_boolean(allow_hide_event)
 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."))
         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, {
         target.category_id, {
             'can_hide_events': 0,
             'can_hide_events': 0,
         }
         }
@@ -1212,18 +1213,18 @@ def allow_delete_event(user, target):
 can_delete_event = return_boolean(allow_delete_event)
 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
         return False
 
 
     if target.category.is_closed or target.is_closed:
     if target.category.is_closed or target.is_closed:
         return False
         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:
     if edit_time:
         diff = timezone.now() - target.started_on
         diff = timezone.now() - target.started_on
         diff_minutes = int(diff.total_seconds() / 60)
         diff_minutes = int(diff.total_seconds() / 60)
@@ -1232,8 +1233,8 @@ def has_time_to_edit_thread(user, target):
         return True
         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:
     if edit_time:
         diff = timezone.now() - target.posted_on
         diff = timezone.now() - target.posted_on
         diff_minutes = int(diff.total_seconds() / 60)
         diff_minutes = int(diff.total_seconds() / 60)
@@ -1242,7 +1243,7 @@ def has_time_to_edit_post(user, target):
         return True
         return True
 
 
 
 
-def exclude_invisible_threads(user, categories, queryset):
+def exclude_invisible_threads(user_acl, categories, queryset):
     show_all = []
     show_all = []
     show_accepted_visible = []
     show_accepted_visible = []
     show_accepted = []
     show_accepted = []
@@ -1251,7 +1252,7 @@ def exclude_invisible_threads(user, categories, queryset):
     show_owned_visible = []
     show_owned_visible = []
 
 
     for category in categories:
     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']):
         if not (category.acl['can_see'] and category.acl['can_browse']):
             continue
             continue
@@ -1262,7 +1263,7 @@ def exclude_invisible_threads(user, categories, queryset):
 
 
             if can_mod and can_hide:
             if can_mod and can_hide:
                 show_all.append(category)
                 show_all.append(category)
-            elif user.is_authenticated:
+            elif user_acl["is_authenticated"]:
                 if not can_mod and not can_hide:
                 if not can_mod and not can_hide:
                     show_accepted_visible.append(category)
                     show_accepted_visible.append(category)
                 elif not can_mod:
                 elif not can_mod:
@@ -1271,7 +1272,7 @@ def exclude_invisible_threads(user, categories, queryset):
                     show_visible.append(category)
                     show_visible.append(category)
             else:
             else:
                 show_accepted_visible.append(category)
                 show_accepted_visible.append(category)
-        elif user.is_authenticated:
+        elif user_acl["is_authenticated"]:
             if can_hide:
             if can_hide:
                 show_owned.append(category)
                 show_owned.append(category)
             else:
             else:
@@ -1282,9 +1283,9 @@ def exclude_invisible_threads(user, categories, queryset):
         conditions = Q(category__in=show_all)
         conditions = Q(category__in=show_all)
 
 
     if show_accepted_visible:
     if show_accepted_visible:
-        if user.is_authenticated:
+        if user_acl["is_authenticated"]:
             condition = Q(
             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,
                 category__in=show_accepted_visible,
                 is_hidden=False,
                 is_hidden=False,
             )
             )
@@ -1302,7 +1303,7 @@ def exclude_invisible_threads(user, categories, queryset):
 
 
     if show_accepted:
     if show_accepted:
         condition = Q(
         condition = Q(
-            Q(starter=user) | Q(is_unapproved=False),
+            Q(starter_id=user_acl["user_id"]) | Q(is_unapproved=False),
             category__in=show_accepted,
             category__in=show_accepted,
         )
         )
 
 
@@ -1320,7 +1321,7 @@ def exclude_invisible_threads(user, categories, queryset):
             conditions = condition
             conditions = condition
 
 
     if show_owned:
     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:
         if conditions:
             conditions = conditions | condition
             conditions = conditions | condition
@@ -1330,7 +1331,7 @@ def exclude_invisible_threads(user, categories, queryset):
     if show_owned_visible:
     if show_owned_visible:
         condition = Q(
         condition = Q(
             category__in=show_owned_visible,
             category__in=show_owned_visible,
-            starter=user,
+            starter_id=user_acl["user_id"],
             is_hidden=False,
             is_hidden=False,
         )
         )
 
 
@@ -1345,14 +1346,14 @@ def exclude_invisible_threads(user, categories, queryset):
         return Thread.objects.none()
         return Thread.objects.none()
 
 
 
 
-def exclude_invisible_posts(user, categories, queryset):
+def exclude_invisible_posts(user_acl, categories, queryset):
     if hasattr(categories, '__iter__'):
     if hasattr(categories, '__iter__'):
-        return exclude_invisible_posts_in_categories(user, categories, queryset)
+        return exclude_invisible_posts_in_categories(user_acl, categories, queryset)
     else:
     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_all = []
     show_approved = []
     show_approved = []
     show_approved_owned = []
     show_approved_owned = []
@@ -1360,12 +1361,12 @@ def exclude_invisible_posts_in_categories(user, categories, queryset):
     hide_invisible_events = []
     hide_invisible_events = []
 
 
     for category in categories:
     for category in categories:
-        add_acl(user, category)
+        add_acl_to_obj(user_acl, category)
 
 
         if category.acl['can_approve_content']:
         if category.acl['can_approve_content']:
             show_all.append(category.pk)
             show_all.append(category.pk)
         else:
         else:
-            if user.is_authenticated:
+            if user_acl["is_authenticated"]:
                 show_approved_owned.append(category.pk)
                 show_approved_owned.append(category.pk)
             else:
             else:
                 show_approved.append(category.pk)
                 show_approved.append(category.pk)
@@ -1390,7 +1391,7 @@ def exclude_invisible_posts_in_categories(user, categories, queryset):
 
 
     if show_approved_owned:
     if show_approved_owned:
         condition = Q(
         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,
             category__in=show_approved_owned,
         )
         )
 
 
@@ -1412,12 +1413,12 @@ def exclude_invisible_posts_in_categories(user, categories, queryset):
         return Post.objects.none()
         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 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:
         else:
             queryset = queryset.exclude(is_unapproved=True)
             queryset = queryset.exclude(is_unapproved=True)
 
 

+ 1 - 1
misago/threads/search.py

@@ -27,7 +27,7 @@ class SearchThreads(SearchProvider):
 
 
         if len(query) > 2:
         if len(query) > 2:
             visible_threads = exclude_invisible_threads(
             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)
             results = search_threads(self.request, query, visible_threads)
         else:
         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.http import Http404
 from django.utils.translation import gettext as _, gettext_lazy, ngettext
 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.categories import THREADS_ROOT_NAME
 from misago.conf import settings
 from misago.conf import settings
 from misago.threads.mergeconflict import MergeConflict
 from misago.threads.mergeconflict import MergeConflict
@@ -62,10 +62,10 @@ class DeletePostsSerializer(serializers.Serializer):
             )
             )
             raise ValidationError(message % {'limit': POSTS_LIMIT})
             raise ValidationError(message % {'limit': POSTS_LIMIT})
 
 
-        user = self.context['user']
+        user_acl = self.context['user_acl']
         thread = self.context['thread']
         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_queryset = posts_queryset.filter(id__in=data).order_by('id')
 
 
         posts = []
         posts = []
@@ -74,10 +74,10 @@ class DeletePostsSerializer(serializers.Serializer):
             post.thread = thread
             post.thread = thread
 
 
             if post.is_event:
             if post.is_event:
-                allow_delete_event(user, post)
+                allow_delete_event(user_acl, post)
             else:
             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)
             posts.append(post)
 
 
@@ -115,10 +115,10 @@ class MergePostsSerializer(serializers.Serializer):
             )
             )
             raise serializers.ValidationError(message % {'limit': POSTS_LIMIT})
             raise serializers.ValidationError(message % {'limit': POSTS_LIMIT})
 
 
-        user = self.context['user']
+        user_acl = self.context['user_acl']
         thread = self.context['thread']
         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_queryset = posts_queryset.filter(id__in=data).order_by('id')
 
 
         posts = []
         posts = []
@@ -127,7 +127,7 @@ class MergePostsSerializer(serializers.Serializer):
             post.thread = thread
             post.thread = thread
 
 
             try:
             try:
-                allow_merge_post(user, post)
+                allow_merge_post(user_acl, post)
             except PermissionDenied as e:
             except PermissionDenied as e:
                 raise serializers.ValidationError(e)
                 raise serializers.ValidationError(e)
 
 
@@ -223,7 +223,7 @@ class MovePostsSerializer(serializers.Serializer):
         request = self.context['request']
         request = self.context['request']
         thread = self.context['thread']
         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_queryset = posts_queryset.filter(id__in=data).order_by('id')
 
 
         posts = []
         posts = []
@@ -232,7 +232,7 @@ class MovePostsSerializer(serializers.Serializer):
             post.thread = thread
             post.thread = thread
 
 
             try:
             try:
-                allow_move_post(request.user, post)
+                allow_move_post(request.user_acl, post)
                 posts.append(post)
                 posts.append(post)
             except PermissionDenied as e:
             except PermissionDenied as e:
                 raise serializers.ValidationError(e)
                 raise serializers.ValidationError(e)
@@ -259,14 +259,15 @@ class NewThreadSerializer(serializers.Serializer):
         return validate_title(title)
         return validate_title(title)
 
 
     def validate_category(self, category_id):
     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."))
             raise ValidationError(_("You can't create new threads in selected category."))
         return self.category
         return self.category
 
 
     def validate_weight(self, weight):
     def validate_weight(self, weight):
         try:
         try:
-            add_acl(self.context['user'], self.category)
+            add_acl_to_obj(self.context['user_acl'], self.category)
         except AttributeError:
         except AttributeError:
             return weight  # don't validate weight further if category failed
             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):
     def validate_is_hidden(self, is_hidden):
         try:
         try:
-            add_acl(self.context['user'], self.category)
+            add_acl_to_obj(self.context['user_acl'], self.category)
         except AttributeError:
         except AttributeError:
             return is_hidden  # don't validate hidden further if category failed
             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):
     def validate_is_closed(self, is_closed):
         try:
         try:
-            add_acl(self.context['user'], self.category)
+            add_acl_to_obj(self.context['user_acl'], self.category)
         except AttributeError:
         except AttributeError:
             return is_closed  # don't validate closed further if category failed
             return is_closed  # don't validate closed further if category failed
 
 
@@ -331,9 +332,9 @@ class SplitPostsSerializer(NewThreadSerializer):
             raise ValidationError(message % {'limit': POSTS_LIMIT})
             raise ValidationError(message % {'limit': POSTS_LIMIT})
 
 
         thread = self.context['thread']
         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_queryset = posts_queryset.filter(id__in=data).order_by('id')
 
 
         posts = []
         posts = []
@@ -342,7 +343,7 @@ class SplitPostsSerializer(NewThreadSerializer):
             post.thread = thread
             post.thread = thread
 
 
             try:
             try:
-                allow_split_post(user, post)
+                allow_split_post(user_acl, post)
             except PermissionDenied as e:
             except PermissionDenied as e:
                 raise ValidationError(e)
                 raise ValidationError(e)
 
 
@@ -389,7 +390,7 @@ class DeleteThreadsSerializer(serializers.Serializer):
         for thread_id in data:
         for thread_id in data:
             try:
             try:
                 thread = viewmodel(request, thread_id).unwrap()
                 thread = viewmodel(request, thread_id).unwrap()
-                allow_delete_thread(request.user, thread)
+                allow_delete_thread(request.user_acl, thread)
                 threads.append(thread)
                 threads.append(thread)
             except PermissionDenied as e:
             except PermissionDenied as e:
                 errors.append({
                 errors.append({
@@ -443,7 +444,7 @@ class MergeThreadSerializer(serializers.Serializer):
 
 
         try:
         try:
             other_thread = viewmodel(request, other_thread_id).unwrap()
             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:
         except PermissionDenied as e:
             raise serializers.ValidationError(e)
             raise serializers.ValidationError(e)
         except Http404:
         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."))
             raise ValidationError(_("You can't merge this thread into thread you can't reply."))
 
 
         return other_thread
         return other_thread
@@ -518,12 +519,12 @@ class MergeThreadsSerializer(NewThreadSerializer):
             category__tree_id=threads_tree_id,
             category__tree_id=threads_tree_id,
         ).select_related('category').order_by('-id')
         ).select_related('category').order_by('-id')
 
 
-        user = self.context['user']
+        user_acl = self.context['user_acl']
 
 
         threads = []
         threads = []
         for thread in threads_queryset:
         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)
                 threads.append(thread)
 
 
         if len(threads) != len(data):
         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 django.urls import reverse
 
 
 from misago.acl.models import Role
 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.conf import settings
 from misago.threads.models import Attachment, AttachmentType
 from misago.threads.models import Attachment, AttachmentType
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
 TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
 TEST_LARGEPNG_PATH = os.path.join(TESTFILES_DIR, 'large.png')
 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')
         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):
     def test_anonymous(self):
         """user has to be authenticated to be able to upload files"""
         """user has to be authenticated to be able to upload files"""
         self.logout_user()
         self.logout_user()
@@ -40,10 +33,9 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
 
 
+    @patch_user_acl({"max_attachment_size": 0})
     def test_no_permission(self):
     def test_no_permission(self):
         """user needs permission to upload files"""
         """user needs permission to upload files"""
-        self.override_acl({'max_attachment_size': 0})
-
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
@@ -181,10 +173,9 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
                 ),
                 ),
             })
             })
 
 
+    @patch_user_acl({"max_attachment_size": 100})
     def test_upload_too_big_for_user(self):
     def test_upload_too_big_for_user(self):
         """too big uploads are rejected"""
         """too big uploads are rejected"""
-        self.override_acl({'max_attachment_size': 100})
-
         AttachmentType.objects.create(
         AttachmentType.objects.create(
             name="Test extension",
             name="Test extension",
             extensions='png',
             extensions='png',
@@ -302,10 +293,9 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
 
 
         self.assertEqual(self.user.audittrail_set.count(), 1)
         self.assertEqual(self.user.audittrail_set.count(), 1)
 
 
+    @patch_user_acl({"max_attachment_size": 10 * 1024})
     def test_large_image_upload(self):
     def test_large_image_upload(self):
         """successful large image upload creates orphan attachment with thumbnail"""
         """successful large image upload creates orphan attachment with thumbnail"""
-        self.override_acl({'max_attachment_size': 10 * 1024})
-
         AttachmentType.objects.create(
         AttachmentType.objects.create(
             name="Test extension",
             name="Test extension",
             extensions='png',
             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 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.categories.models import Category
 from misago.conf import settings
 from misago.conf import settings
 from misago.threads import testutils
 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.threads.models import Attachment, AttachmentType
 from misago.users.testutils import AuthenticatedUserTestCase
 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):
 class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
@@ -26,12 +32,8 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
 
 
         self.post.update_fields = []
         self.post.update_fields = []
 
 
-        self.override_acl()
         self.filetype = AttachmentType.objects.order_by('id').last()
         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):
     def mock_attachment(self, user=True, post=None):
         return Attachment.objects.create(
         return Attachment.objects.create(
             secret=Attachment.generate_new_secret(),
             secret=Attachment.generate_new_secret(),
@@ -46,54 +48,65 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
 
 
     def test_use_this_middleware(self):
     def test_use_this_middleware(self):
         """use_this_middleware returns False if we can't upload attachments"""
         """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):
     def test_middleware_is_optional(self):
         """middleware is optional"""
         """middleware is optional"""
         INPUTS = [{}, {'attachments': []}]
         INPUTS = [{}, {'attachments': []}]
 
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
+
         for test_input in INPUTS:
         for test_input in INPUTS:
             middleware = AttachmentsMiddleware(
             middleware = AttachmentsMiddleware(
-                request=RequestMock(test_input),
+                request=Mock(data=test_input),
                 mode=PostingEndpoint.START,
                 mode=PostingEndpoint.START,
                 user=self.user,
                 user=self.user,
+                user_acl=user_acl,
                 post=self.post,
                 post=self.post,
             )
             )
 
 
             serializer = middleware.get_serializer()
             serializer = middleware.get_serializer()
             self.assertTrue(serializer.is_valid())
             self.assertTrue(serializer.is_valid())
 
 
+    @patch_attachments_acl()
     def test_middleware_validates_ids(self):
     def test_middleware_validates_ids(self):
         """middleware validates attachments ids"""
         """middleware validates attachments ids"""
         INPUTS = ['none', ['a', 'b', 123], range(settings.MISAGO_POST_ATTACHMENTS_LIMIT + 1)]
         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:
         for test_input in INPUTS:
             middleware = AttachmentsMiddleware(
             middleware = AttachmentsMiddleware(
-                request=RequestMock({
+                request=Mock(data={
                     'attachments': test_input
                     'attachments': test_input
                 }),
                 }),
                 mode=PostingEndpoint.START,
                 mode=PostingEndpoint.START,
                 user=self.user,
                 user=self.user,
+                user_acl=user_acl,
                 post=self.post,
                 post=self.post,
             )
             )
 
 
             serializer = middleware.get_serializer()
             serializer = middleware.get_serializer()
             self.assertFalse(serializer.is_valid(), "%r shouldn't validate" % test_input)
             self.assertFalse(serializer.is_valid(), "%r shouldn't validate" % test_input)
 
 
+    @patch_attachments_acl()
     def test_get_initial_attachments(self):
     def test_get_initial_attachments(self):
         """get_initial_attachments returns list of attachments already existing on post"""
         """get_initial_attachments returns list of attachments already existing on post"""
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
         middleware = AttachmentsMiddleware(
-            request=RequestMock(),
+            request=Mock(data={}),
             mode=PostingEndpoint.EDIT,
             mode=PostingEndpoint.EDIT,
             user=self.user,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
             post=self.post,
         )
         )
 
 
@@ -106,16 +119,19 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
 
 
         attachment = self.mock_attachment(post=self.post)
         attachment = self.mock_attachment(post=self.post)
         attachments = serializer.get_initial_attachments(
         attachments = serializer.get_initial_attachments(
-            middleware.mode, middleware.user, middleware.post
+            middleware.mode, middleware.user_acl, middleware.post
         )
         )
         self.assertEqual(attachments, [attachment])
         self.assertEqual(attachments, [attachment])
 
 
+    @patch_attachments_acl()
     def test_get_new_attachments(self):
     def test_get_new_attachments(self):
         """get_initial_attachments returns list of attachments already existing on post"""
         """get_initial_attachments returns list of attachments already existing on post"""
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
         middleware = AttachmentsMiddleware(
-            request=RequestMock(),
+            request=Mock(data={}),
             mode=PostingEndpoint.EDIT,
             mode=PostingEndpoint.EDIT,
             user=self.user,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
             post=self.post,
         )
         )
 
 
@@ -133,27 +149,27 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         attachments = serializer.get_new_attachments(middleware.user, [other_user_attachment.pk])
         attachments = serializer.get_new_attachments(middleware.user, [other_user_attachment.pk])
         self.assertEqual(attachments, [])
         self.assertEqual(attachments, [])
 
 
+    
+    @patch_attachments_acl({'can_delete_other_users_attachments': False})
     def test_cant_delete_attachment(self):
     def test_cant_delete_attachment(self):
         """middleware validates if we have permission to delete other users attachments"""
         """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)
         attachment = self.mock_attachment(user=False, post=self.post)
         self.assertIsNone(attachment.uploader)
         self.assertIsNone(attachment.uploader)
 
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         serializer = AttachmentsMiddleware(
         serializer = AttachmentsMiddleware(
-            request=RequestMock({
+            request=Mock(data={
                 'attachments': []
                 'attachments': []
             }),
             }),
             mode=PostingEndpoint.EDIT,
             mode=PostingEndpoint.EDIT,
             user=self.user,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
             post=self.post,
         ).get_serializer()
         ).get_serializer()
 
 
         self.assertFalse(serializer.is_valid())
         self.assertFalse(serializer.is_valid())
 
 
+    @patch_attachments_acl()
     def test_add_attachments(self):
     def test_add_attachments(self):
         """middleware adds attachments to post"""
         """middleware adds attachments to post"""
         attachments = [
         attachments = [
@@ -161,12 +177,14 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             self.mock_attachment(),
             self.mock_attachment(),
         ]
         ]
 
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
         middleware = AttachmentsMiddleware(
-            request=RequestMock({
+            request=Mock(data={
                 'attachments': [a.pk for a in attachments]
                 'attachments': [a.pk for a in attachments]
             }),
             }),
             mode=PostingEndpoint.EDIT,
             mode=PostingEndpoint.EDIT,
             user=self.user,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
             post=self.post,
         )
         )
 
 
@@ -182,6 +200,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         self.assertEqual([a['filename'] for a in self.post.attachments_cache],
         self.assertEqual([a['filename'] for a in self.post.attachments_cache],
                          attachments_filenames)
                          attachments_filenames)
 
 
+    @patch_attachments_acl()
     def test_remove_attachments(self):
     def test_remove_attachments(self):
         """middleware removes attachment from post and db"""
         """middleware removes attachment from post and db"""
         attachments = [
         attachments = [
@@ -189,12 +208,14 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             self.mock_attachment(post=self.post),
             self.mock_attachment(post=self.post),
         ]
         ]
 
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
         middleware = AttachmentsMiddleware(
-            request=RequestMock({
+            request=Mock(data={
                 'attachments': [attachments[0].pk]
                 'attachments': [attachments[0].pk]
             }),
             }),
             mode=PostingEndpoint.EDIT,
             mode=PostingEndpoint.EDIT,
             user=self.user,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
             post=self.post,
         )
         )
 
 
@@ -212,6 +233,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         self.assertEqual([a['filename'] for a in self.post.attachments_cache],
         self.assertEqual([a['filename'] for a in self.post.attachments_cache],
                          attachments_filenames)
                          attachments_filenames)
 
 
+    @patch_attachments_acl()
     def test_steal_attachments(self):
     def test_steal_attachments(self):
         """middleware validates if attachments are already assigned to other posts"""
         """middleware validates if attachments are already assigned to other posts"""
         other_post = testutils.reply_thread(self.thread)
         other_post = testutils.reply_thread(self.thread)
@@ -221,12 +243,14 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             self.mock_attachment(),
             self.mock_attachment(),
         ]
         ]
 
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
         middleware = AttachmentsMiddleware(
-            request=RequestMock({
+            request=Mock(data={
                 'attachments': [attachments[0].pk, attachments[1].pk]
                 'attachments': [attachments[0].pk, attachments[1].pk]
             }),
             }),
             mode=PostingEndpoint.EDIT,
             mode=PostingEndpoint.EDIT,
             user=self.user,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
             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[0].pk).post, other_post)
         self.assertEqual(Attachment.objects.get(pk=attachments[1].pk).post, self.post)
         self.assertEqual(Attachment.objects.get(pk=attachments[1].pk).post, self.post)
 
 
+    @patch_attachments_acl()
     def test_edit_attachments(self):
     def test_edit_attachments(self):
         """middleware removes and adds attachments to post"""
         """middleware removes and adds attachments to post"""
         attachments = [
         attachments = [
@@ -249,12 +274,14 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             self.mock_attachment(),
             self.mock_attachment(),
         ]
         ]
 
 
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
         middleware = AttachmentsMiddleware(
-            request=RequestMock({
+            request=Mock(data={
                 'attachments': [attachments[0].pk, attachments[2].pk]
                 'attachments': [attachments[0].pk, attachments[2].pk]
             }),
             }),
             mode=PostingEndpoint.EDIT,
             mode=PostingEndpoint.EDIT,
             user=self.user,
             user=self.user,
+            user_acl=user_acl,
             post=self.post,
             post=self.post,
         )
         )
 
 

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

@@ -3,19 +3,25 @@ import os
 from django.urls import reverse
 from django.urls import reverse
 
 
 from misago.acl.models import Role
 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.categories.models import Category
 from misago.conf import settings
 from misago.conf import settings
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Attachment, AttachmentType
 from misago.threads.models import Attachment, AttachmentType
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
 TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
 TEST_SMALLJPG_PATH = os.path.join(TESTFILES_DIR, 'small.jpg')
 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):
 class AttachmentViewTestCase(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
@@ -36,16 +42,6 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
             extensions='pdf',
             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):
     def upload_document(self, is_orphaned=False, by_other_user=False):
         with open(TEST_DOCUMENT_PATH, 'rb') as upload:
         with open(TEST_DOCUMENT_PATH, 'rb') as upload:
             response = self.client.post(
             response = self.client.post(
@@ -64,8 +60,6 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
             attachment.uploader = None
             attachment.uploader = None
             attachment.save()
             attachment.save()
 
 
-        self.override_acl()
-
         return attachment
         return attachment
 
 
     def upload_image(self):
     def upload_image(self):
@@ -77,25 +71,25 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
             )
             )
         self.assertEqual(response.status_code, 200)
         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):
     def assertIs404(self, response):
         self.assertEqual(response.status_code, 302)
         self.assertEqual(response.status_code, 302)
         self.assertTrue(response['location'].endswith(settings.MISAGO_404_IMAGE))
         self.assertTrue(response['location'].endswith(settings.MISAGO_404_IMAGE))
 
 
+    @patch_attachments_acl()
     def assertIs403(self, response):
     def assertIs403(self, response):
         self.assertEqual(response.status_code, 302)
         self.assertEqual(response.status_code, 302)
         self.assertTrue(response['location'].endswith(settings.MISAGO_403_IMAGE))
         self.assertTrue(response['location'].endswith(settings.MISAGO_403_IMAGE))
 
 
+    @patch_attachments_acl()
     def assertSuccess(self, response):
     def assertSuccess(self, response):
         self.assertEqual(response.status_code, 302)
         self.assertEqual(response.status_code, 302)
         self.assertFalse(response['location'].endswith(settings.MISAGO_404_IMAGE))
         self.assertFalse(response['location'].endswith(settings.MISAGO_404_IMAGE))
         self.assertFalse(response['location'].endswith(settings.MISAGO_403_IMAGE))
         self.assertFalse(response['location'].endswith(settings.MISAGO_403_IMAGE))
 
 
+    @patch_attachments_acl()
     def test_nonexistant_file(self):
     def test_nonexistant_file(self):
         """user tries to retrieve nonexistant file"""
         """user tries to retrieve nonexistant file"""
         response = self.client.get(
         response = self.client.get(
@@ -107,6 +101,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
 
 
         self.assertIs404(response)
         self.assertIs404(response)
 
 
+    @patch_attachments_acl()
     def test_invalid_secret(self):
     def test_invalid_secret(self):
         """user tries to retrieve existing file using invalid secret"""
         """user tries to retrieve existing file using invalid secret"""
         attachment = self.upload_document()
         attachment = self.upload_document()
@@ -120,15 +115,15 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
 
 
         self.assertIs404(response)
         self.assertIs404(response)
 
 
+    @patch_attachments_acl({"can_download_other_users_attachments": False})
     def test_other_user_file_no_permission(self):
     def test_other_user_file_no_permission(self):
         """user tries to retrieve other user's file without perm"""
         """user tries to retrieve other user's file without perm"""
         attachment = self.upload_document(by_other_user=True)
         attachment = self.upload_document(by_other_user=True)
 
 
-        self.override_acl(False)
-
         response = self.client.get(attachment.get_absolute_url())
         response = self.client.get(attachment.get_absolute_url())
         self.assertIs403(response)
         self.assertIs403(response)
 
 
+    @patch_attachments_acl({"can_download_other_users_attachments": False})
     def test_other_user_orphaned_file(self):
     def test_other_user_orphaned_file(self):
         """user tries to retrieve other user's orphaned file"""
         """user tries to retrieve other user's orphaned file"""
         attachment = self.upload_document(is_orphaned=True, by_other_user=True)
         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')
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         self.assertIs404(response)
         self.assertIs404(response)
 
 
+    @patch_attachments_acl()
     def test_document_thumbnail(self):
     def test_document_thumbnail(self):
         """user tries to retrieve thumbnail from non-image attachment"""
         """user tries to retrieve thumbnail from non-image attachment"""
         attachment = self.upload_document()
         attachment = self.upload_document()
@@ -154,6 +150,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         )
         )
         self.assertIs404(response)
         self.assertIs404(response)
 
 
+    @patch_attachments_acl()
     def test_no_role(self):
     def test_no_role(self):
         """user tries to retrieve attachment without perm to its type"""
         """user tries to retrieve attachment without perm to its type"""
         attachment = self.upload_document()
         attachment = self.upload_document()
@@ -164,6 +161,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         response = self.client.get(attachment.get_absolute_url())
         self.assertIs403(response)
         self.assertIs403(response)
 
 
+    @patch_attachments_acl()
     def test_type_disabled(self):
     def test_type_disabled(self):
         """user tries to retrieve attachment the type disabled downloads"""
         """user tries to retrieve attachment the type disabled downloads"""
         attachment = self.upload_document()
         attachment = self.upload_document()
@@ -174,6 +172,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         response = self.client.get(attachment.get_absolute_url())
         self.assertIs403(response)
         self.assertIs403(response)
 
 
+    @patch_attachments_acl()
     def test_locked_type(self):
     def test_locked_type(self):
         """user retrieves own locked file"""
         """user retrieves own locked file"""
         attachment = self.upload_document()
         attachment = self.upload_document()
@@ -184,6 +183,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         response = self.client.get(attachment.get_absolute_url())
         self.assertSuccess(response)
         self.assertSuccess(response)
 
 
+    @patch_attachments_acl()
     def test_own_file(self):
     def test_own_file(self):
         """user retrieves own file"""
         """user retrieves own file"""
         attachment = self.upload_document()
         attachment = self.upload_document()
@@ -191,6 +191,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         response = self.client.get(attachment.get_absolute_url())
         self.assertSuccess(response)
         self.assertSuccess(response)
 
 
+    @patch_attachments_acl()
     def test_other_user_file(self):
     def test_other_user_file(self):
         """user retrieves other user's file with perm"""
         """user retrieves other user's file with perm"""
         attachment = self.upload_document(by_other_user=True)
         attachment = self.upload_document(by_other_user=True)
@@ -198,6 +199,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         response = self.client.get(attachment.get_absolute_url())
         self.assertSuccess(response)
         self.assertSuccess(response)
 
 
+    @patch_attachments_acl()
     def test_other_user_orphaned_file_is_staff(self):
     def test_other_user_orphaned_file_is_staff(self):
         """user retrieves other user's orphaned file because he is staff"""
         """user retrieves other user's orphaned file because he is staff"""
         attachment = self.upload_document(is_orphaned=True, by_other_user=True)
         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')
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         self.assertSuccess(response)
         self.assertSuccess(response)
 
 
+    @patch_attachments_acl()
     def test_orphaned_file_is_uploader(self):
     def test_orphaned_file_is_uploader(self):
         """user retrieves orphaned file because he is its uploader"""
         """user retrieves orphaned file because he is its uploader"""
         attachment = self.upload_document(is_orphaned=True)
         attachment = self.upload_document(is_orphaned=True)
@@ -221,6 +224,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         self.assertSuccess(response)
         self.assertSuccess(response)
 
 
+    @patch_attachments_acl()
     def test_has_role(self):
     def test_has_role(self):
         """user retrieves file he has roles to download"""
         """user retrieves file he has roles to download"""
         attachment = self.upload_document()
         attachment = self.upload_document()
@@ -231,6 +235,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         self.assertSuccess(response)
         self.assertSuccess(response)
 
 
+    @patch_attachments_acl()
     def test_image(self):
     def test_image(self):
         """user retrieves """
         """user retrieves """
         attachment = self.upload_image()
         attachment = self.upload_image()
@@ -238,6 +243,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         response = self.client.get(attachment.get_absolute_url() + '?shva=1')
         self.assertSuccess(response)
         self.assertSuccess(response)
 
 
+    @patch_attachments_acl()
     def test_image_thumb(self):
     def test_image_thumb(self):
         """user retrieves image's thumbnail"""
         """user retrieves image's thumbnail"""
         attachment = self.upload_image()
         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 import timezone
 from django.utils.encoding import smart_str
 from django.utils.encoding import smart_str
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
+from misago.threads.test import (
+    patch_category_acl, patch_other_user_category_acl
+)
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -25,7 +27,6 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             category=self.category,
             category=self.category,
             started_on=timezone.now() - timedelta(seconds=5),
             started_on=timezone.now() - timedelta(seconds=5),
         )
         )
-        self.override_acl()
 
 
         self.api_link = reverse(
         self.api_link = reverse(
             'misago:api:thread-post-list', kwargs={
             '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):
     def test_no_subscriptions(self):
         """no emails are sent because noone subscibes to thread"""
         """no emails are sent because noone subscibes to thread"""
         response = self.client.post(
         response = self.client.post(
@@ -75,6 +48,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
 
 
         self.assertEqual(len(mail.outbox), 0)
         self.assertEqual(len(mail.outbox), 0)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_poster_not_notified(self):
     def test_poster_not_notified(self):
         """no emails are sent because only poster subscribes to thread"""
         """no emails are sent because only poster subscribes to thread"""
         self.user.subscription_set.create(
         self.user.subscription_set.create(
@@ -93,6 +67,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
 
 
         self.assertEqual(len(mail.outbox), 0)
         self.assertEqual(len(mail.outbox), 0)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_other_user_no_email_subscription(self):
     def test_other_user_no_email_subscription(self):
         """no emails are sent because subscriber has e-mails off"""
         """no emails are sent because subscriber has e-mails off"""
         self.other_user.subscription_set.create(
         self.other_user.subscription_set.create(
@@ -111,6 +86,8 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
 
 
         self.assertEqual(len(mail.outbox), 0)
         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):
     def test_other_user_no_permission(self):
         """no emails are sent because subscriber has no permission to read thread"""
         """no emails are sent because subscriber has no permission to read thread"""
         self.other_user.subscription_set.create(
         self.other_user.subscription_set.create(
@@ -119,7 +96,6 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             last_read_on=timezone.now(),
             last_read_on=timezone.now(),
             send_email=True,
             send_email=True,
         )
         )
-        self.override_other_user_acl(hide=True)
 
 
         response = self.client.post(
         response = self.client.post(
             self.api_link, data={
             self.api_link, data={
@@ -130,6 +106,29 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
 
 
         self.assertEqual(len(mail.outbox), 0)
         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):
     def test_other_user_not_read(self):
         """no emails are sent because subscriber didn't read previous post"""
         """no emails are sent because subscriber didn't read previous post"""
         self.other_user.subscription_set.create(
         self.other_user.subscription_set.create(
@@ -138,7 +137,6 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             last_read_on=timezone.now(),
             last_read_on=timezone.now(),
             send_email=True,
             send_email=True,
         )
         )
-        self.override_other_user_acl()
 
 
         testutils.reply_thread(self.thread, posted_on=timezone.now())
         testutils.reply_thread(self.thread, posted_on=timezone.now())
 
 
@@ -151,6 +149,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
 
 
         self.assertEqual(len(mail.outbox), 0)
         self.assertEqual(len(mail.outbox), 0)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_other_notified(self):
     def test_other_notified(self):
         """email is sent to subscriber"""
         """email is sent to subscriber"""
         self.other_user.subscription_set.create(
         self.other_user.subscription_set.create(
@@ -159,7 +158,6 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             last_read_on=timezone.now(),
             last_read_on=timezone.now(),
             send_email=True,
             send_email=True,
         )
         )
-        self.override_other_user_acl()
 
 
         response = self.client.post(
         response = self.client.post(
             self.api_link, data={
             self.api_link, data={
@@ -183,6 +181,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         last_post = self.thread.post_set.order_by('id').last()
         last_post = self.thread.post_set.order_by('id').last()
         self.assertIn(last_post.get_absolute_url(), message)
         self.assertIn(last_post.get_absolute_url(), message)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_other_notified_after_reading(self):
     def test_other_notified_after_reading(self):
         """email is sent to subscriber that had sub updated by read api"""
         """email is sent to subscriber that had sub updated by read api"""
         self.other_user.subscription_set.create(
         self.other_user.subscription_set.create(
@@ -191,7 +190,6 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             last_read_on=self.thread.last_post_on,
             last_read_on=self.thread.last_post_on,
             send_email=True,
             send_email=True,
         )
         )
-        self.override_other_user_acl()
 
 
         response = self.client.post(
         response = self.client.post(
             self.api_link, data={
             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.contrib.auth import get_user_model
 from django.test import TestCase
 from django.test import TestCase
 from django.utils import timezone
 from django.utils import timezone
 
 
-from misago.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.models import Category
 from misago.threads.events import record_event
 from misago.threads.events import record_event
 from misago.threads.models import Thread
 from misago.threads.models import Thread
 
 
-
-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):
 class EventsApiTests(TestCase):
     def setUp(self):
     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()
         datetime = timezone.now()
 
 
@@ -37,12 +34,13 @@ class EventsApiTests(TestCase):
         self.thread.set_title("Test thread")
         self.thread.set_title("Test thread")
         self.thread.save()
         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):
     def test_record_event_with_context(self):
         """record_event registers event with context in thread"""
         """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'}
         context = {'user': 'Lorem ipsum'}
         event = record_event(request, self.thread, 'announcement', context)
         event = record_event(request, self.thread, 'announcement', context)
 
 
@@ -59,7 +57,7 @@ class EventsApiTests(TestCase):
 
 
     def test_record_event_is_read(self):
     def test_record_event_is_read(self):
         """record_event makes recorded event read to its author"""
         """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')
         event = record_event(request, self.thread, 'announcement')
 
 
         self.user.postread_set.get(
         self.user.postread_set.get(

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

@@ -1,18 +1,17 @@
 from django.urls import reverse
 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.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
-class PostMentionsTests(AuthenticatedUserTestCase):
+class FloodProtectionTests(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
 
 
         self.category = Category.objects.get(slug='first-category')
         self.category = Category.objects.get(slug='first-category')
         self.thread = testutils.post_thread(category=self.category)
         self.thread = testutils.post_thread(category=self.category)
-        self.override_acl()
 
 
         self.post_link = reverse(
         self.post_link = reverse(
             'misago:api:thread-post-list', kwargs={
             '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):
     def test_flood_has_no_showstoppers(self):
         """endpoint handles posting interruption"""
         """endpoint handles posting interruption"""
         response = self.client.post(
         response = self.client.post(
@@ -49,3 +37,19 @@ class PostMentionsTests(AuthenticatedUserTestCase):
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't post message so quickly after previous one."
             "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 django.utils import timezone
 
 
-from misago.acl.testutils import override_acl
 from misago.threads.api.postingendpoint import PostingInterrupt
 from misago.threads.api.postingendpoint import PostingInterrupt
 from misago.threads.api.postingendpoint.floodprotection import FloodProtectionMiddleware
 from misago.threads.api.postingendpoint.floodprotection import FloodProtectionMiddleware
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
+user_acl = {'can_omit_flood_protection': False}
+
 
 
 class FloodProtectionMiddlewareTests(AuthenticatedUserTestCase):
 class FloodProtectionMiddlewareTests(AuthenticatedUserTestCase):
     def test_flood_protection_middleware_on_no_posts(self):
     def test_flood_protection_middleware_on_no_posts(self):
@@ -14,7 +15,7 @@ class FloodProtectionMiddlewareTests(AuthenticatedUserTestCase):
         self.user.update_fields = []
         self.user.update_fields = []
         self.assertIsNone(self.user.last_posted_on)
         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)
         middleware.interrupt_posting(None)
 
 
         self.assertIsNotNone(self.user.last_posted_on)
         self.assertIsNotNone(self.user.last_posted_on)
@@ -26,7 +27,7 @@ class FloodProtectionMiddlewareTests(AuthenticatedUserTestCase):
         original_last_posted_on = timezone.now() - timedelta(days=1)
         original_last_posted_on = timezone.now() - timedelta(days=1)
         self.user.last_posted_on = original_last_posted_on
         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)
         middleware.interrupt_posting(None)
 
 
         self.assertTrue(self.user.last_posted_on > original_last_posted_on)
         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()
         self.user.last_posted_on = timezone.now()
 
 
         with self.assertRaises(PostingInterrupt):
         with self.assertRaises(PostingInterrupt):
-            middleware = FloodProtectionMiddleware(user=self.user)
+            middleware = FloodProtectionMiddleware(user=self.user, user_acl=user_acl)
             middleware.interrupt_posting(None)
             middleware.interrupt_posting(None)
 
 
     def test_flood_permission(self):
     def test_flood_permission(self):
         """middleware is respects permission to flood for team members"""
         """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())
         self.assertFalse(middleware.use_this_middleware())

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

@@ -1,10 +1,10 @@
 from django.utils import timezone
 from django.utils import timezone
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.conf import settings
 from misago.readtracker.poststracker import save_read
 from misago.readtracker.poststracker import save_read
 from misago.threads import testutils
 from misago.threads import testutils
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -233,24 +233,18 @@ class GotoBestAnswerTests(GotoViewTestCase):
 
 
 
 
 class GotoUnapprovedTests(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):
     def test_view_validates_permission(self):
         """view validates permission to see unapproved posts"""
         """view validates permission to see unapproved posts"""
         response = self.client.get(self.thread.get_unapproved_post_url())
         response = self.client.get(self.thread.get_unapproved_post_url())
         self.assertContains(response, "You need permission to approve content", status_code=403)
         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):
     def test_view_handles_no_unapproved_posts(self):
         """if thread has no unapproved posts, redirect to last post"""
         """if thread has no unapproved posts, redirect to last post"""
-        self.grant_permission()
-
         response = self.client.get(self.thread.get_unapproved_post_url())
         response = self.client.get(self.thread.get_unapproved_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
         self.assertEqual(
@@ -258,6 +252,7 @@ class GotoUnapprovedTests(GotoViewTestCase):
             GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id)
             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):
     def test_view_handles_unapproved_posts(self):
         """if thread has unapproved posts, redirect to first of them"""
         """if thread has unapproved posts, redirect to first of them"""
         for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
         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):
         for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
             testutils.reply_thread(self.thread, posted_on=timezone.now())
             testutils.reply_thread(self.thread, posted_on=timezone.now())
 
 
-        self.grant_permission()
-
         response = self.client.get(self.thread.get_unapproved_post_url())
         response = self.client.get(self.thread.get_unapproved_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
         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.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
 from django.urls import reverse
 from django.urls import reverse
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.markup.mentions import MENTIONS_LIMIT
 from misago.markup.mentions import MENTIONS_LIMIT
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
 
 
@@ -18,7 +16,6 @@ class PostMentionsTests(AuthenticatedUserTestCase):
 
 
         self.category = Category.objects.get(slug='first-category')
         self.category = Category.objects.get(slug='first-category')
         self.thread = testutils.post_thread(category=self.category)
         self.thread = testutils.post_thread(category=self.category)
-        self.override_acl()
 
 
         self.post_link = reverse(
         self.post_link = reverse(
             'misago:api:thread-post-list', kwargs={
             '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):
     def put(self, url, data=None):
         content = encode_multipart(BOUNDARY, data or {})
         content = encode_multipart(BOUNDARY, data or {})
         return self.client.put(url, content, content_type=MULTIPART_CONTENT)
         return self.client.put(url, content, content_type=MULTIPART_CONTENT)
@@ -129,7 +114,6 @@ class PostMentionsTests(AuthenticatedUserTestCase):
             }
             }
         )
         )
 
 
-        self.override_acl()
         response = self.put(
         response = self.put(
             edit_link,
             edit_link,
             data={
             data={
@@ -142,7 +126,6 @@ class PostMentionsTests(AuthenticatedUserTestCase):
         self.assertEqual(list(post.mentions.order_by('id')), [user_a, user_b])
         self.assertEqual(list(post.mentions.order_by('id')), [user_a, user_b])
 
 
         # remove first mention from post - should preserve mentions
         # remove first mention from post - should preserve mentions
-        self.override_acl()
         response = self.put(
         response = self.put(
             edit_link, data={
             edit_link, data={
                 'post': "This is test response, @%s!" % user_b,
                 '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])
         self.assertEqual(list(post.mentions.order_by('id')), [user_a, user_b])
 
 
         # remove mentions from post - should preserve mentions
         # remove mentions from post - should preserve mentions
-        self.override_acl()
         response = self.put(
         response = self.put(
             edit_link, data={
             edit_link, data={
                 'post': "This is test response!",
                 '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.contrib.auth import get_user_model
 from django.core import mail
 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 import testutils
+from misago.threads.test import other_user_cant_use_private_threads
 from misago.threads.models import Thread, ThreadParticipant
 from misago.threads.models import Thread, ThreadParticipant
 
 
 from .test_privatethreads import PrivateThreadsTestCase
 from .test_privatethreads import PrivateThreadsTestCase
 
 
-
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
 
 
@@ -125,12 +125,11 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
             'detail': ["BobBoberson is blocking you."],
             'detail': ["BobBoberson is blocking you."],
         })
         })
 
 
+    @patch_user_acl(other_user_cant_use_private_threads)
     def test_add_no_perm_user(self):
     def test_add_no_perm_user(self):
         """can't add user that has no permission to use private threads"""
         """can't add user that has no permission to use private threads"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
 
-        override_acl(self.other_user, {'can_use_private_threads': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -146,11 +145,12 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
             'detail': ["BobBoberson can't participate in private threads."],
             'detail': ["BobBoberson can't participate in private threads."],
         })
         })
 
 
+    @patch_user_acl({"max_private_thread_participants": 3})
     def test_add_too_many_users(self):
     def test_add_too_many_users(self):
         """can't add user that is already participant"""
         """can't add user that is already participant"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
         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 = UserModel.objects.create_user(
                 'User%s' % i, 'user%s@example.com' % i, 'Pass.123'
                 '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.user.username, email.subject)
         self.assertIn(self.thread.title, 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):
     def test_add_user_to_other_user_thread_moderator(self):
         """moderators can add users to other users threads"""
         """moderators can add users to other users threads"""
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
@@ -226,8 +227,6 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.has_reported_posts = True
         self.thread.has_reported_posts = True
         self.thread.save()
         self.thread.save()
 
 
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         self.patch(
         self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -246,6 +245,7 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         # notification about new private thread wasn't send because we invited ourselves
         # notification about new private thread wasn't send because we invited ourselves
         self.assertEqual(len(mail.outbox), 0)
         self.assertEqual(len(mail.outbox), 0)
 
 
+    @patch_user_acl({'can_moderate_private_threads': True})
     def test_add_user_to_closed_moderator(self):
     def test_add_user_to_closed_moderator(self):
         """moderators can add users to closed threads"""
         """moderators can add users to closed threads"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
         ThreadParticipant.objects.set_owner(self.thread, self.user)
@@ -253,8 +253,6 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         self.patch(
         self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -458,6 +456,7 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.assertEqual(self.thread.participants.count(), 1)
         self.assertEqual(self.thread.participants.count(), 1)
         self.assertEqual(self.thread.participants.filter(pk=self.user.pk).count(), 0)
         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):
     def test_moderator_remove_user(self):
         """api allows moderator to remove other user"""
         """api allows moderator to remove other user"""
         removed_user = UserModel.objects.create_user('Vigilante', 'test@test.com', 'pass123')
         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.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.add_participants(self.thread, [self.user, removed_user])
         ThreadParticipant.objects.add_participants(self.thread, [self.user, removed_user])
 
 
-        override_acl(self.user, {'can_moderate_private_threads': True})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -742,6 +739,7 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertTrue(event.is_event)
         self.assertTrue(event.is_event)
         self.assertTrue(event.event_type, 'changed_owner')
         self.assertTrue(event.event_type, 'changed_owner')
 
 
+    @patch_user_acl({'can_moderate_private_threads': True})
     def test_moderator_change_owner(self):
     def test_moderator_change_owner(self):
         """moderator can change thread owner to other user"""
         """moderator can change thread owner to other user"""
         new_owner = UserModel.objects.create_user('NewOwner', 'new@owner.com', 'pass123')
         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.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.add_participants(self.thread, [self.user, new_owner])
         ThreadParticipant.objects.add_participants(self.thread, [self.user, new_owner])
 
 
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -768,7 +764,7 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertFalse(UserModel.objects.get(pk=self.user.pk).sync_unread_private_threads)
         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)
         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.assertEqual(self.thread.participants.count(), 3)
         self.assertTrue(ThreadParticipant.objects.get(user=new_owner).is_owner)
         self.assertTrue(ThreadParticipant.objects.get(user=new_owner).is_owner)
         self.assertFalse(ThreadParticipant.objects.get(user=self.user).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.is_event)
         self.assertTrue(event.event_type, 'changed_owner')
         self.assertTrue(event.event_type, 'changed_owner')
 
 
+    @patch_user_acl({'can_moderate_private_threads': True})
     def test_moderator_takeover(self):
     def test_moderator_takeover(self):
         """moderator can takeover the thread"""
         """moderator can takeover the thread"""
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
 
 
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -812,6 +807,7 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertTrue(event.is_event)
         self.assertTrue(event.is_event)
         self.assertTrue(event.event_type, 'tookover')
         self.assertTrue(event.event_type, 'tookover')
 
 
+    @patch_user_acl({'can_moderate_private_threads': True})
     def test_moderator_closed_thread_takeover(self):
     def test_moderator_closed_thread_takeover(self):
         """moderator can takeover closed thread thread"""
         """moderator can takeover closed thread thread"""
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
@@ -820,8 +816,6 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
-        override_acl(self.user, {'can_moderate_private_threads': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -838,7 +832,7 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertFalse(UserModel.objects.get(pk=self.user.pk).sync_unread_private_threads)
         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)
         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.assertEqual(self.thread.participants.count(), 2)
         self.assertTrue(ThreadParticipant.objects.get(user=self.user).is_owner)
         self.assertTrue(ThreadParticipant.objects.get(user=self.user).is_owner)
         self.assertFalse(ThreadParticipant.objects.get(user=self.other_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.urls import reverse
 from django.utils.encoding import smart_str
 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.categories.models import Category
 from misago.threads.models import ThreadParticipant
 from misago.threads.models import ThreadParticipant
+from misago.threads.test import other_user_cant_use_private_threads
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
 
 
@@ -30,20 +30,18 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
 
 
+    @patch_user_acl({'can_use_private_threads': False})
     def test_cant_use_private_threads(self):
     def test_cant_use_private_threads(self):
         """has no permission to use private threads"""
         """has no permission to use private threads"""
-        override_acl(self.user, {'can_use_private_threads': 0})
-
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't use private threads.",
             "detail": "You can't use private threads.",
         })
         })
 
 
+    @patch_user_acl({'can_start_private_threads': False})
     def test_cant_start_private_thread(self):
     def test_cant_start_private_thread(self):
         """permission to start private thread is validated"""
         """permission to start private thread is validated"""
-        override_acl(self.user, {'can_start_private_threads': 0})
-
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
@@ -153,10 +151,9 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
             }
             }
         )
         )
 
 
+    @patch_user_acl(other_user_cant_use_private_threads)
     def test_cant_invite_no_permission(self):
     def test_cant_invite_no_permission(self):
         """api validates invited user permission to private thread"""
         """api validates invited user permission to private thread"""
-        override_acl(self.other_user, {'can_use_private_threads': 0})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -191,8 +188,10 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
             'to': ["BobBoberson is blocking you."],
             '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(
         response = self.client.post(
             self.api_link,
             self.api_link,
@@ -233,26 +232,24 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         )
         )
 
 
         # allow us to bypass following check
         # 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
         # make user follow us
-        override_acl(self.user, {'can_add_everyone_to_private_threads': 0})
         self.other_user.follows.add(self.user)
         self.other_user.follows.add(self.user)
 
 
         response = self.client.post(
         response = self.client.post(
@@ -294,23 +291,22 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         )
         )
 
 
         # allow us to bypass user preference check
         # 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):
     def test_can_start_thread(self):
         """endpoint creates new thread"""
         """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 import testutils
 from misago.threads.models import ThreadParticipant
 from misago.threads.models import ThreadParticipant
 
 
@@ -19,10 +19,9 @@ class PrivateThreadViewTests(PrivateThreadsTestCase):
         response = self.client.get(self.test_link)
         response = self.client.get(self.test_link)
         self.assertContains(response, "sign in to use private threads", status_code=403)
         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):
     def test_no_permission(self):
         """user needs to have permission to see private thread"""
         """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)
         response = self.client.get(self.test_link)
         self.assertContains(response, "t use private threads", status_code=403)
         self.assertContains(response, "t use private threads", status_code=403)
 
 
@@ -31,10 +30,9 @@ class PrivateThreadViewTests(PrivateThreadsTestCase):
         response = self.client.get(self.test_link)
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_user_acl({"can_moderate_private_threads": True})
     def test_mod_not_reported(self):
     def test_mod_not_reported(self):
         """moderator can't see private thread that has no reports"""
         """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)
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
@@ -60,10 +58,9 @@ class PrivateThreadViewTests(PrivateThreadsTestCase):
         response = self.client.get(self.test_link)
         response = self.client.get(self.test_link)
         self.assertContains(response, self.thread.title)
         self.assertContains(response, self.thread.title)
 
 
+    @patch_user_acl({"can_moderate_private_threads": True})
     def test_mod_can_see_reported(self):
     def test_mod_can_see_reported(self):
         """moderator can see private thread that has reports"""
         """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.has_reported_posts = True
         self.thread.save()
         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.categories.models import Category
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -7,34 +6,3 @@ class PrivateThreadsTestCase(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
         self.category = Category.objects.private_threads()
         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 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 import testutils
 from misago.threads.models import Thread, ThreadParticipant
 from misago.threads.models import Thread, ThreadParticipant
+from misago.threads.test import patch_private_threads_acl
 
 
 from .test_privatethreads import PrivateThreadsTestCase
 from .test_privatethreads import PrivateThreadsTestCase
 
 
@@ -23,16 +24,16 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
             "detail": "You have to sign in to use private threads."
             "detail": "You have to sign in to use private threads."
         })
         })
 
 
+    @patch_user_acl({"can_use_private_threads": False})
     def test_no_permission(self):
     def test_no_permission(self):
         """api requires user to have permission to be able to access it"""
         """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)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't use private threads."
             "detail": "You can't use private threads."
         })
         })
 
 
+    @patch_user_acl({"can_use_private_threads": True})
     def test_empty_list(self):
     def test_empty_list(self):
         """api has no showstoppers on returning empty list"""
         """api has no showstoppers on returning empty list"""
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
@@ -41,6 +42,7 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['count'], 0)
         self.assertEqual(response_json['count'], 0)
 
 
+    @patch_user_acl({"can_use_private_threads": True})
     def test_thread_visibility(self):
     def test_thread_visibility(self):
         """only participated threads are returned by private threads api"""
         """only participated threads are returned by private threads api"""
         visible = testutils.post_thread(category=self.category, poster=self.user)
         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)
         self.assertEqual(response_json['results'][0]['id'], visible.id)
 
 
         # threads with reported posts will also show to moderators
         # 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):
 class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
@@ -90,28 +91,34 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
             "detail": "You have to sign in to use private threads."
             "detail": "You have to sign in to use private threads."
         })
         })
 
 
+    @patch_user_acl({"can_use_private_threads": False})
     def test_no_permission(self):
     def test_no_permission(self):
         """user needs to have permission to see private thread"""
         """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)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't use private threads."
             "detail": "You can't use private threads."
         })
         })
 
 
+    @patch_user_acl({"can_use_private_threads": True})
     def test_no_participant(self):
     def test_no_participant(self):
         """user cant see thread he isn't part of"""
         """user cant see thread he isn't part of"""
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_user_acl({
+        "can_use_private_threads": True,
+        "can_moderate_private_threads": True,
+    })
     def test_mod_not_reported(self):
     def test_mod_not_reported(self):
         """moderator can't see private thread that has no reports"""
         """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)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
         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):
     def test_reported_not_mod(self):
         """non-mod can't see private thread that has reported posts"""
         """non-mod can't see private thread that has reported posts"""
         self.thread.has_reported_posts = True
         self.thread.has_reported_posts = True
@@ -120,6 +127,7 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_user_acl({"can_use_private_threads": True})
     def test_can_see_owner(self):
     def test_can_see_owner(self):
         """user can see thread he is owner of"""
         """user can see thread he is owner of"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
         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):
     def test_can_see_participant(self):
         """user can see thread he is participant of"""
         """user can see thread he is participant of"""
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
         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):
     def test_mod_can_see_reported(self):
         """moderator can see private thread that has reports"""
         """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.has_reported_posts = True
         self.thread.save()
         self.thread.save()
 
 
@@ -186,30 +197,29 @@ class PrivateThreadDeleteApiTests(PrivateThreadsTestCase):
 
 
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
         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"""
         """api tests permission to delete threads"""
-        self.override_acl({'can_hide_threads': 0})
-
+        
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-
         self.assertEqual(
         self.assertEqual(
             response.json()['detail'], "You can't delete threads in this category."
             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)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-
         self.assertEqual(
         self.assertEqual(
             response.json()['detail'], "You can't delete threads in this category."
             response.json()['detail'], "You can't delete threads in this category."
         )
         )
 
 
+    @patch_private_threads_acl({"can_hide_threads": 2})
     def test_delete_thread(self):
     def test_delete_thread(self):
         """DELETE to API link with permission deletes thread"""
         """DELETE to API link with permission deletes thread"""
-        self.override_acl({'can_hide_threads': 2})
-
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
         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 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 import testutils
 from misago.threads.models import ThreadParticipant
 from misago.threads.models import ThreadParticipant
 
 
@@ -20,10 +20,9 @@ class PrivateThreadsListTests(PrivateThreadsTestCase):
         response = self.client.get(self.test_link)
         response = self.client.get(self.test_link)
         self.assertContains(response, "sign in to use private threads", status_code=403)
         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):
     def test_no_permission(self):
         """view requires user to have permission to be able to access it"""
         """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)
         response = self.client.get(self.test_link)
         self.assertContains(response, "use private threads", status_code=403)
         self.assertContains(response, "use private threads", status_code=403)
 
 
@@ -51,9 +50,8 @@ class PrivateThreadsListTests(PrivateThreadsTestCase):
         self.assertContains(response, visible.get_absolute_url())
         self.assertContains(response, visible.get_absolute_url())
 
 
         # threads with reported posts will also show to moderators
         # 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.contrib.auth import get_user_model
 from django.urls import reverse
 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.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -14,19 +15,6 @@ class SubscriptionMiddlewareTestCase(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
         self.category = Category.objects.get(slug='first-category')
         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):
 class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
@@ -34,6 +22,7 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         super().setUp()
         super().setUp()
         self.api_link = reverse('misago:api:thread-list')
         self.api_link = reverse('misago:api:thread-list')
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_dont_subscribe(self):
     def test_dont_subscribe(self):
         """middleware makes no subscription to thread"""
         """middleware makes no subscription to thread"""
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NONE
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NONE
@@ -53,6 +42,7 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         # user has no subscriptions
         # user has no subscriptions
         self.assertEqual(self.user.subscription_set.count(), 0)
         self.assertEqual(self.user.subscription_set.count(), 0)
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_subscribe(self):
     def test_subscribe(self):
         """middleware subscribes thread"""
         """middleware subscribes thread"""
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NOTIFY
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NOTIFY
@@ -75,6 +65,7 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertFalse(subscription.send_email)
         self.assertFalse(subscription.send_email)
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_email_subscribe(self):
     def test_email_subscribe(self):
         """middleware subscribes thread with an email"""
         """middleware subscribes thread with an email"""
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_ALL
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_ALL
@@ -108,6 +99,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_dont_subscribe(self):
     def test_dont_subscribe(self):
         """middleware makes no subscription to thread"""
         """middleware makes no subscription to thread"""
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NOTIFY
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NOTIFY
@@ -124,6 +116,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         # user has no subscriptions
         # user has no subscriptions
         self.assertEqual(self.user.subscription_set.count(), 0)
         self.assertEqual(self.user.subscription_set.count(), 0)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_subscribe(self):
     def test_subscribe(self):
         """middleware subscribes thread"""
         """middleware subscribes thread"""
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NOTIFY
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NOTIFY
@@ -142,6 +135,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertFalse(subscription.send_email)
         self.assertFalse(subscription.send_email)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_email_subscribe(self):
     def test_email_subscribe(self):
         """middleware subscribes thread with an email"""
         """middleware subscribes thread with an email"""
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
@@ -160,6 +154,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertTrue(subscription.send_email)
         self.assertTrue(subscription.send_email)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_subscribe_with_events(self):
     def test_subscribe_with_events(self):
         """middleware omits events when testing for replied thread"""
         """middleware omits events when testing for replied thread"""
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
@@ -182,6 +177,8 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertTrue(subscription.send_email)
         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):
     def test_dont_subscribe_replied(self):
         """middleware omits threads user already replied"""
         """middleware omits threads user already replied"""
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL

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

@@ -2,10 +2,10 @@ import json
 
 
 from django.urls import reverse
 from django.urls import reverse
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Thread
 from misago.threads.models import Thread
+from misago.threads.test import patch_category_acl, patch_other_category_acl
 
 
 from .test_threads_api import ThreadsApiTestCase
 from .test_threads_api import ThreadsApiTestCase
 
 
@@ -183,10 +183,9 @@ class ThreadAddAclApiTests(ThreadsBulkPatchApiTestCase):
 
 
 
 
 class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
 class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
+    @patch_category_acl({"can_edit_threads": 2})
     def test_change_thread_title(self):
     def test_change_thread_title(self):
         """api changes thread title and resyncs the category"""
         """api changes thread title and resyncs the category"""
-        self.override_acl({'can_edit_threads': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link,
             self.api_link,
             {
             {
@@ -210,13 +209,12 @@ class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
         for thread in Thread.objects.filter(id__in=self.ids):
         for thread in Thread.objects.filter(id__in=self.ids):
             self.assertEqual(thread.title, 'Changed the title!')
             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!')
         self.assertEqual(category.last_thread_title, 'Changed the title!')
 
 
+    @patch_category_acl({"can_edit_threads": 0})
     def test_change_thread_title_no_permission(self):
     def test_change_thread_title_no_permission(self):
         """api validates permission to change title, returns errors"""
         """api validates permission to change title, returns errors"""
-        self.override_acl({'can_edit_threads': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link,
             self.api_link,
             {
             {
@@ -246,46 +244,19 @@ class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
         super().setUp()
         super().setUp()
 
 
         Category(
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other Category',
+            slug='other-category',
         ).insert_at(
         ).insert_at(
             self.category,
             self.category,
             position='last-child',
             position='last-child',
             save=True,
             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):
     def test_move_thread(self):
         """api moves threads to other category and syncs both categories"""
         """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(
         response = self.patch(
             self.api_link,
             self.api_link,
             {
             {
@@ -294,7 +265,7 @@ class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
                     {
                     {
                         'op': 'replace',
                         'op': 'replace',
                         'path': 'category',
                         'path': 'category',
-                        'value': self.category_b.pk,
+                        'value': self.other_category.id,
                     },
                     },
                     {
                     {
                         'op': 'replace',
                         'op': 'replace',
@@ -309,23 +280,22 @@ class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         for i, thread in enumerate(self.threads):
         for i, thread in enumerate(self.threads):
             self.assertEqual(response_json[i]['id'], thread.id)
             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):
         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)
         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)
         self.assertEqual(new_category.threads, 3)
 
 
 
 
 class BulkThreadsHideApiTests(ThreadsBulkPatchApiTestCase):
 class BulkThreadsHideApiTests(ThreadsBulkPatchApiTestCase):
+    @patch_category_acl({"can_hide_threads": 1})
     def test_hide_thread(self):
     def test_hide_thread(self):
         """api makes it possible to hide thread"""
         """api makes it possible to hide thread"""
-        self.override_acl({'can_hide_threads': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link,
             self.api_link,
             {
             {
@@ -349,11 +319,12 @@ class BulkThreadsHideApiTests(ThreadsBulkPatchApiTestCase):
         for thread in Thread.objects.filter(id__in=self.ids):
         for thread in Thread.objects.filter(id__in=self.ids):
             self.assertTrue(thread.is_hidden)
             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)
         self.assertNotIn(category.last_thread_id, self.ids)
 
 
 
 
 class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
 class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
+    @patch_category_acl({"can_approve_content": True})
     def test_approve_thread(self):
     def test_approve_thread(self):
         """api approvse threads and syncs category"""
         """api approvse threads and syncs category"""
         for thread in self.threads:
         for thread in self.threads:
@@ -369,8 +340,6 @@ class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
         self.category.synchronize()
         self.category.synchronize()
         self.category.save()
         self.category.save()
 
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link,
             self.api_link,
             {
             {
@@ -396,5 +365,5 @@ class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
             self.assertFalse(thread.is_unapproved)
             self.assertFalse(thread.is_unapproved)
             self.assertFalse(thread.has_unapproved_posts)
             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)
         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.urls import reverse
 from django.utils import timezone
 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.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Post, Thread
 from misago.threads.models import Post, Thread
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 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):
     def put(self, url, data=None):
         content = encode_multipart(BOUNDARY, data or {})
         content = encode_multipart(BOUNDARY, data or {})
         return self.client.put(url, content, content_type=MULTIPART_CONTENT)
         return self.client.put(url, content, content_type=MULTIPART_CONTENT)
@@ -55,32 +41,30 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
 
     def test_thread_visibility(self):
     def test_thread_visibility(self):
         """thread's visibility is validated"""
         """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):
     def test_cant_edit_reply(self):
         """permission to edit reply is validated"""
         """permission to edit reply is validated"""
-        self.override_acl({'can_edit_posts': 0})
-
         response = self.put(self.api_link)
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't edit posts in this category.",
             "detail": "You can't edit posts in this category.",
         })
         })
 
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_cant_edit_other_user_reply(self):
     def test_cant_edit_other_user_reply(self):
         """permission to edit reply by other users is validated"""
         """permission to edit reply by other users is validated"""
-        self.override_acl({'can_edit_posts': 1})
-
         self.post.poster = None
         self.post.poster = None
         self.post.save()
         self.post.save()
 
 
@@ -90,13 +74,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "detail": "You can't edit other users posts in this category.",
             "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):
     def test_edit_too_old(self):
         """permission to edit reply within timelimit is validated"""
         """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.posted_on = timezone.now() - timedelta(minutes=5)
         self.post.save()
         self.post.save()
 
 
@@ -106,10 +86,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "detail": "You can't edit posts that are older than 1 minute.",
             "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"""
         """permssion to edit reply in closed category is validated"""
-        self.override_acl({'can_close_threads': 0})
-
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -119,16 +98,18 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "detail": "This category is closed. You can't edit posts in it.",
             "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)
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
         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"""
         """permssion to edit reply in closed thread is validated"""
-        self.override_acl({'can_close_threads': 0})
-
         self.thread.is_closed = True
         self.thread.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -138,16 +119,18 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "detail": "This thread is closed. You can't edit posts in it.",
             "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)
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
         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"""
         """permssion to edit protected post is validated"""
-        self.override_acl({'can_protect_posts': 0})
-
         self.post.is_protected = True
         self.post.is_protected = True
         self.post.save()
         self.post.save()
 
 
@@ -157,26 +140,27 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "detail": "This post is protected. You can't edit it.",
             "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)
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
 
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_empty_data(self):
     def test_empty_data(self):
         """no data sent handling has no showstoppers"""
         """no data sent handling has no showstoppers"""
-        self.override_acl()
-
         response = self.put(self.api_link, data={})
         response = self.put(self.api_link, data={})
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "post": ["You have to enter a message."],
             "post": ["You have to enter a message."],
         })
         })
 
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_invalid_data(self):
     def test_invalid_data(self):
         """api errors for invalid request data"""
         """api errors for invalid request data"""
-        self.override_acl()
-
         response = self.client.put(
         response = self.client.put(
             self.api_link,
             self.api_link,
             'false',
             'false',
@@ -187,10 +171,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "non_field_errors": ["Invalid data. Expected a dictionary, but got bool."]
             "non_field_errors": ["Invalid data. Expected a dictionary, but got bool."]
         })
         })
 
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_edit_event(self):
     def test_edit_event(self):
         """events can't be edited"""
         """events can't be edited"""
-        self.override_acl()
-
         self.post.is_event = True
         self.post.is_event = True
         self.post.save()
         self.post.save()
 
 
@@ -200,10 +183,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
             "detail": "Events can't be edited.",
             "detail": "Events can't be edited.",
         })
         })
 
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_post_is_validated(self):
     def test_post_is_validated(self):
         """post is validated"""
         """post is validated"""
-        self.override_acl()
-
         response = self.put(
         response = self.put(
             self.api_link, data={
             self.api_link, data={
                 'post': "a",
                 'post': "a",
@@ -216,15 +198,14 @@ class EditReplyTests(AuthenticatedUserTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_edit_reply_no_change(self):
     def test_edit_reply_no_change(self):
         """endpoint isn't bumping edits count if no change was made to post's body"""
         """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)
         self.assertEqual(self.post.edits_record.count(), 0)
 
 
         response = self.put(self.api_link, data={'post': self.post.original})
         response = self.put(self.api_link, data={'post': self.post.original})
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-        self.override_acl()
         response = self.client.get(self.thread.get_absolute_url())
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, self.post.parsed)
         self.assertContains(response, self.post.parsed)
 
 
@@ -237,15 +218,14 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
 
         self.assertEqual(self.post.edits_record.count(), 0)
         self.assertEqual(self.post.edits_record.count(), 0)
 
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_edit_reply(self):
     def test_edit_reply(self):
         """endpoint updates reply"""
         """endpoint updates reply"""
-        self.override_acl()
         self.assertEqual(self.post.edits_record.count(), 0)
         self.assertEqual(self.post.edits_record.count(), 0)
 
 
         response = self.put(self.api_link, data={'post': "This is test edit!"})
         response = self.put(self.api_link, data={'post': "This is test edit!"})
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-        self.override_acl()
         response = self.client.get(self.thread.get_absolute_url())
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, "<p>This is test edit!</p>")
         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_name, self.user.username)
         self.assertEqual(post_edit.editor_slug, self.user.slug)
         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):
     def test_edit_first_post_hidden(self):
         """endpoint updates hidden thread's first post"""
         """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.is_hidden = True
         self.thread.save()
         self.thread.save()
         self.thread.first_post.is_hidden = True
         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!"})
         response = self.put(api_link, data={'post': "This is test edit!"})
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
+    @patch_category_acl({"can_edit_posts": 1, "can_protect_posts": True})
     def test_protect_post(self):
     def test_protect_post(self):
         """can protect post"""
         """can protect post"""
-        self.override_acl({'can_protect_posts': 1})
-
         response = self.put(
         response = self.put(
             self.api_link, data={
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
                 'post': "Lorem ipsum dolor met!",
@@ -303,10 +281,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = self.user.post_set.order_by('id').last()
         post = self.user.post_set.order_by('id').last()
         self.assertTrue(post.is_protected)
         self.assertTrue(post.is_protected)
 
 
+    @patch_category_acl({"can_edit_posts": 1, "can_protect_posts": False})
     def test_protect_post_no_permission(self):
     def test_protect_post_no_permission(self):
         """cant protect post without permission"""
         """cant protect post without permission"""
-        self.override_acl({'can_protect_posts': 0})
-
         response = self.put(
         response = self.put(
             self.api_link, data={
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
                 'post': "Lorem ipsum dolor met!",
@@ -318,10 +295,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = self.user.post_set.order_by('id').last()
         post = self.user.post_set.order_by('id').last()
         self.assertFalse(post.is_protected)
         self.assertFalse(post.is_protected)
 
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_post_unicode(self):
     def test_post_unicode(self):
         """unicode characters can be posted"""
         """unicode characters can be posted"""
-        self.override_acl()
-
         response = self.put(
         response = self.put(
             self.api_link, data={
             self.api_link, data={
                 'post': "Chrzążczyżewoszyce, powiat Łękółody.",
                 'post': "Chrzążczyżewoszyce, powiat Łękółody.",
@@ -329,6 +305,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
+    @patch_category_acl({"can_edit_posts": 1})
     def test_reply_category_moderation_queue(self):
     def test_reply_category_moderation_queue(self):
         """edit sends reply to queue due to category setup"""
         """edit sends reply to queue due to category setup"""
         self.category.require_edits_approval = True
         self.category.require_edits_approval = True
@@ -344,10 +321,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         post = self.user.post_set.all()[:1][0]
         self.assertTrue(post.is_unapproved)
         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):
     def test_reply_category_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         """bypass moderation queue due to user's acl"""
-        override_acl(self.user, {'can_approve_content': 1})
-
         self.category.require_edits_approval = True
         self.category.require_edits_approval = True
         self.category.save()
         self.category.save()
 
 
@@ -361,10 +338,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         post = self.user.post_set.all()[:1][0]
         self.assertFalse(post.is_unapproved)
         self.assertFalse(post.is_unapproved)
 
 
+    @patch_category_acl({"can_edit_posts": 1, "require_edits_approval": True})
     def test_reply_user_moderation_queue(self):
     def test_reply_user_moderation_queue(self):
         """edit sends reply to queue due to user acl"""
         """edit sends reply to queue due to user acl"""
-        self.override_acl({'require_edits_approval': 1})
-
         response = self.put(
         response = self.put(
             self.api_link, data={
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
                 'post': "Lorem ipsum dolor met!",
@@ -375,12 +351,13 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         post = self.user.post_set.all()[:1][0]
         self.assertTrue(post.is_unapproved)
         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):
     def test_reply_user_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         """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(
         response = self.put(
             self.api_link, data={
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
                 'post': "Lorem ipsum dolor met!",
@@ -391,17 +368,17 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         post = self.user.post_set.all()[:1][0]
         self.assertFalse(post.is_unapproved)
         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):
     def test_reply_omit_other_moderation_queues(self):
         """other queues are omitted"""
         """other queues are omitted"""
         self.category.require_threads_approval = True
         self.category.require_threads_approval = True
         self.category.require_replies_approval = True
         self.category.require_replies_approval = True
         self.category.save()
         self.category.save()
 
 
-        self.override_acl({
-            'require_threads_approval': 1,
-            'require_replies_approval': 1,
-        })
-
         response = self.put(
         response = self.put(
             self.api_link, data={
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
                 '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):
     def test_first_reply_category_moderation_queue(self):
         """edit sends thread to queue due to category setup"""
         """edit sends thread to queue due to category setup"""
         self.setUpFirstReplyTest()
         self.setUpFirstReplyTest()
@@ -447,12 +425,12 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = Post.objects.get(pk=self.post.pk)
         post = Post.objects.get(pk=self.post.pk)
         self.assertTrue(post.is_unapproved)
         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):
     def test_first_reply_category_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         """bypass moderation queue due to user's acl"""
         self.setUpFirstReplyTest()
         self.setUpFirstReplyTest()
 
 
-        override_acl(self.user, {'can_approve_content': 1})
-
         self.category.require_edits_approval = True
         self.category.require_edits_approval = True
         self.category.save()
         self.category.save()
 
 
@@ -470,12 +448,11 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = Post.objects.get(pk=self.post.pk)
         post = Post.objects.get(pk=self.post.pk)
         self.assertFalse(post.is_unapproved)
         self.assertFalse(post.is_unapproved)
 
 
+    @patch_category_acl({"can_edit_posts": 1, "require_edits_approval": True})
     def test_first_reply_user_moderation_queue(self):
     def test_first_reply_user_moderation_queue(self):
         """edit sends thread to queue due to user acl"""
         """edit sends thread to queue due to user acl"""
         self.setUpFirstReplyTest()
         self.setUpFirstReplyTest()
 
 
-        self.override_acl({'require_edits_approval': 1})
-
         response = self.put(
         response = self.put(
             self.api_link, data={
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
                 'post': "Lorem ipsum dolor met!",
@@ -490,14 +467,12 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = Post.objects.get(pk=self.post.pk)
         post = Post.objects.get(pk=self.post.pk)
         self.assertTrue(post.is_unapproved)
         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):
     def test_first_reply_user_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         """bypass moderation queue due to user's acl"""
         self.setUpFirstReplyTest()
         self.setUpFirstReplyTest()
 
 
-        override_acl(self.user, {'can_approve_content': 1})
-
-        self.override_acl({'require_edits_approval': 1})
-
         response = self.put(
         response = self.put(
             self.api_link, data={
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
                 'post': "Lorem ipsum dolor met!",
@@ -512,6 +487,11 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = Post.objects.get(pk=self.post.pk)
         post = Post.objects.get(pk=self.post.pk)
         self.assertFalse(post.is_unapproved)
         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):
     def test_first_reply_omit_other_moderation_queues(self):
         """other queues are omitted"""
         """other queues are omitted"""
         self.setUpFirstReplyTest()
         self.setUpFirstReplyTest()
@@ -520,11 +500,6 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.category.require_replies_approval = True
         self.category.require_replies_approval = True
         self.category.save()
         self.category.save()
 
 
-        self.override_acl({
-            'require_threads_approval': 1,
-            'require_replies_approval': 1,
-        })
-
         response = self.put(
         response = self.put(
             self.api_link, data={
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
                 '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 django.urls import reverse
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.readtracker import poststracker
 from misago.readtracker import poststracker
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Poll, PollVote, Thread
 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
 from .test_threads_api import ThreadsApiTestCase
 
 
@@ -12,16 +12,16 @@ from .test_threads_api import ThreadsApiTestCase
 class ThreadMergeApiTests(ThreadsApiTestCase):
 class ThreadMergeApiTests(ThreadsApiTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
-
+        
         Category(
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other Category',
+            slug='other-category',
         ).insert_at(
         ).insert_at(
             self.category,
             self.category,
             position='last-child',
             position='last-child',
             save=True,
             save=True,
         )
         )
-        self.category_b = Category.objects.get(slug='category-b')
+        self.other_category = Category.objects.get(slug='other-category')
 
 
         self.api_link = reverse(
         self.api_link = reverse(
             'misago:api:thread-merge', kwargs={
             '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):
     def test_merge_no_permission(self):
         """api validates if thread can be merged with other one"""
         """api validates if thread can be merged with other one"""
-        self.override_acl({'can_merge_threads': 0})
-
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't merge threads in this category."
             "detail": "You can't merge threads in this category."
         })
         })
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_no_url(self):
     def test_merge_no_url(self):
         """api validates if thread url was given"""
         """api validates if thread url was given"""
-        self.override_acl({'can_merge_threads': 1})
-
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "Enter link to new thread."
             "detail": "Enter link to new thread."
         })
         })
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_invalid_url(self):
     def test_invalid_url(self):
         """api validates thread url"""
         """api validates thread url"""
-        self.override_acl({'can_merge_threads': 1})
-
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'other_thread': self.user.get_absolute_url(),
             'other_thread': self.user.get_absolute_url(),
         })
         })
@@ -94,10 +58,9 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "This is not a valid thread link."
             "detail": "This is not a valid thread link."
         })
         })
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_current_other_thread(self):
     def test_current_other_thread(self):
         """api validates if thread url given is to current thread"""
         """api validates if thread url given is to current thread"""
-        self.override_acl({'can_merge_threads': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link, {
             self.api_link, {
                 'other_thread': self.thread.get_absolute_url(),
                 'other_thread': self.thread.get_absolute_url(),
@@ -108,12 +71,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "You can't merge thread with itself."
             "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):
     def test_other_thread_exists(self):
         """api validates if other thread exists"""
         """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_other_thread = other_thread.get_absolute_url()
         other_thread.delete()
         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):
     def test_other_thread_is_invisible(self):
         """api validates if other thread is visible"""
         """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(
         response = self.client.post(
             self.api_link, {
             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):
     def test_other_thread_isnt_mergeable(self):
         """api validates if other thread can be merged"""
         """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(
         response = self.client.post(
             self.api_link, {
             self.api_link, {
@@ -165,17 +125,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "Other thread can't be merged with."
             "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):
     def test_thread_category_is_closed(self):
         """api validates if thread's category is open"""
         """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.is_closed = True
         self.category.save()
         self.category.save()
@@ -190,17 +144,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "This category is closed. You can't merge it's threads."
             "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):
     def test_thread_is_closed(self):
         """api validates if thread is open"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
@@ -215,20 +163,14 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "This thread is closed. You can't merge it with other threads."
             "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):
     def test_other_thread_category_is_closed(self):
         """api validates if other thread's category is open"""
         """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(
         response = self.client.post(
             self.api_link, {
             self.api_link, {
@@ -240,17 +182,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "Other thread's category is closed. You can't merge with it."
             "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):
     def test_other_thread_is_closed(self):
         """api validates if other thread is open"""
         """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.is_closed = True
         other_thread.save()
         other_thread.save()
@@ -265,16 +201,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "Other thread is closed and can't be merged with."
             "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):
     def test_other_thread_isnt_replyable(self):
         """api validates if other thread can be replied, which is condition for merge"""
         """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(
         response = self.client.post(
             self.api_link, {
             self.api_link, {
@@ -286,12 +217,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             "detail": "You can't merge this thread into thread you can't reply."
             "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):
     def test_merge_threads(self):
         """api merges two threads successfully"""
         """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(
         response = self.client.post(
             self.api_link, {
             self.api_link, {
@@ -312,12 +242,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         with self.assertRaises(Thread.DoesNotExist):
         with self.assertRaises(Thread.DoesNotExist):
             Thread.objects.get(pk=self.thread.pk)
             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):
     def test_merge_threads_kept_reads(self):
         """api keeps both threads readtrackers after merge"""
         """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, self.thread.first_post)
         poststracker.save_read(self.user, other_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.thread.first_post_id, other_thread.first_post_id]
         )
         )
         self.assertEqual(postreads.filter(thread=other_thread).count(), 2)
         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):
     def test_merge_threads_kept_subs(self):
         """api keeps other thread's subscription after merge"""
         """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(
         self.user.subscription_set.create(
             thread=self.thread,
             thread=self.thread,
@@ -377,14 +305,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         # subscriptions are kept
         # subscriptions are kept
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.user.subscription_set.get(thread=other_thread)
         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):
     def test_merge_threads_moved_subs(self):
         """api keeps other thread's subscription after merge"""
         """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(
         self.user.subscription_set.create(
             thread=other_thread,
             thread=other_thread,
@@ -395,7 +322,7 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
 
 
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.user.subscription_set.get(thread=other_thread)
         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(
         response = self.client.post(
             self.api_link, {
             self.api_link, {
@@ -412,13 +339,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         # subscriptions are kept
         # subscriptions are kept
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.user.subscription_set.get(thread=other_thread)
         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):
     def test_merge_threads_handle_subs_colision(self):
         """api resolves conflicting thread subscriptions after merge"""
         """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(
         self.user.subscription_set.create(
             thread=self.thread,
             thread=self.thread,
             category=self.thread.category,
             category=self.thread.category,
@@ -426,7 +352,7 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             send_email=False,
             send_email=False,
         )
         )
 
 
-        other_thread = testutils.post_thread(self.category_b)
+        other_thread = testutils.post_thread(self.other_category)
 
 
         self.user.subscription_set.create(
         self.user.subscription_set.create(
             thread=other_thread,
             thread=other_thread,
@@ -439,7 +365,7 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.user.subscription_set.get(thread=self.thread)
         self.user.subscription_set.get(thread=self.thread)
         self.user.subscription_set.get(category=self.category)
         self.user.subscription_set.get(category=self.category)
         self.user.subscription_set.get(thread=other_thread)
         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(
         response = self.client.post(
             self.api_link, {
             self.api_link, {
@@ -456,14 +382,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         # subscriptions are kept
         # subscriptions are kept
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.user.subscription_set.get(thread=other_thread)
         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):
     def test_merge_threads_kept_best_answer(self):
         """api merges two threads successfully, keeping best answer from old thread"""
         """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)
         best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, best_answer)
         other_thread.set_best_answer(self.user, best_answer)
         other_thread.save()
         other_thread.save()
@@ -491,12 +416,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_thread = Thread.objects.get(pk=other_thread.pk)
         other_thread = Thread.objects.get(pk=other_thread.pk)
         self.assertEqual(other_thread.best_answer, best_answer)
         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):
     def test_merge_threads_moved_best_answer(self):
         """api merges two threads successfully, moving best answer to old thread"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         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)
         other_thread = Thread.objects.get(pk=other_thread.pk)
         self.assertEqual(other_thread.best_answer, best_answer)
         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):
     def test_merge_threads_merge_conflict_best_answer(self):
         """api errors on merge conflict, returning list of available best answers"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         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_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
         other_thread.save()
@@ -560,16 +483,15 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(
         self.assertEqual(
             Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
             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):
     def test_threads_merge_conflict_best_answer_invalid_resolution(self):
         """api errors on invalid merge conflict resolution"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         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_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
         other_thread.save()
@@ -590,16 +512,15 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(
         self.assertEqual(
             Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
             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):
     def test_threads_merge_conflict_unmark_all_best_answers(self):
         """api unmarks all best answers when unmark all choice is selected"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         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_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
         other_thread.save()
@@ -627,16 +548,15 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         # final thread has no marked best answer
         # final thread has no marked best answer
         self.assertIsNone(Thread.objects.get(pk=other_thread.pk).best_answer_id)
         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):
     def test_threads_merge_conflict_keep_first_best_answer(self):
         """api unmarks other best answer on merge"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         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_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
         other_thread.save()
@@ -664,16 +584,15 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         # other thread's best answer was unchanged
         # other thread's best answer was unchanged
         self.assertEqual(Thread.objects.get(pk=other_thread.pk).best_answer_id, best_answer.id)
         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):
     def test_threads_merge_conflict_keep_other_best_answer(self):
         """api unmarks first best answer on merge"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         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_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
         other_thread.save()
@@ -702,12 +621,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(
         self.assertEqual(
             Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
             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):
     def test_merge_threads_kept_poll(self):
         """api merges two threads successfully, keeping poll from other thread"""
         """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)
         poll = testutils.post_poll(other_thread, self.user)
 
 
         response = self.client.post(
         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(Poll.objects.filter(pk=poll.pk, thread=other_thread).count(), 1)
         self.assertEqual(PollVote.objects.filter(poll=poll, thread=other_thread).count(), 4)
         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):
     def test_merge_threads_moved_poll(self):
         """api merges two threads successfully, moving poll from old thread"""
         """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)
         poll = testutils.post_poll(self.thread, self.user)
 
 
         response = self.client.post(
         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(Poll.objects.filter(pk=poll.pk, thread=other_thread).count(), 1)
         self.assertEqual(PollVote.objects.filter(poll=poll, thread=other_thread).count(), 4)
         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):
     def test_threads_merge_conflict_polls(self):
         """api errors on merge conflict, returning list of available polls"""
         """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)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_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(Poll.objects.count(), 2)
         self.assertEqual(PollVote.objects.count(), 8)
         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):
     def test_threads_merge_conflict_poll_invalid_resolution(self):
         """api errors on invalid merge conflict resolution"""
         """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(self.thread, self.user)
         testutils.post_poll(other_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(Poll.objects.count(), 2)
         self.assertEqual(PollVote.objects.count(), 8)
         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):
     def test_threads_merge_conflict_delete_all_polls(self):
         """api deletes all polls when delete all choice is selected"""
         """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(self.thread, self.user)
         testutils.post_poll(other_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(Poll.objects.count(), 0)
         self.assertEqual(PollVote.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):
     def test_threads_merge_conflict_keep_first_poll(self):
         """api deletes other poll on merge"""
         """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)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
 
 
@@ -895,12 +808,11 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         with self.assertRaises(Poll.DoesNotExist):
         with self.assertRaises(Poll.DoesNotExist):
             Poll.objects.get(pk=other_poll.pk)
             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):
     def test_threads_merge_conflict_keep_other_poll(self):
         """api deletes first poll on merge"""
         """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)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_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 django.utils import timezone
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.readtracker import poststracker
 from misago.readtracker import poststracker
 from misago.threads import testutils
 from misago.threads import testutils
+from misago.threads.test import patch_category_acl, patch_other_category_acl
 from misago.threads.models import Thread
 from misago.threads.models import Thread
 
 
 from .test_threads_api import ThreadsApiTestCase
 from .test_threads_api import ThreadsApiTestCase
@@ -48,10 +48,9 @@ class ThreadAddAclApiTests(ThreadPatchApiTestCase):
 
 
 
 
 class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
 class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_edit_threads': 2})
     def test_change_thread_title(self):
     def test_change_thread_title(self):
         """api makes it possible to change thread title"""
         """api makes it possible to change thread title"""
-        self.override_acl({'can_edit_threads': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -69,10 +68,9 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['title'], "Lorem ipsum change!")
         self.assertEqual(thread_json['title'], "Lorem ipsum change!")
 
 
+    @patch_category_acl({'can_edit_threads': 0})
     def test_change_thread_title_no_permission(self):
     def test_change_thread_title_no_permission(self):
         """api validates permission to change title"""
         """api validates permission to change title"""
-        self.override_acl({'can_edit_threads': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -87,13 +85,9 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't edit threads in this category.")
         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):
     def test_change_thread_title_closed_category_no_permission(self):
         """api test permission to edit thread title in closed category"""
         """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.is_closed = True
         self.category.save()
         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."
             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):
     def test_change_thread_title_closed_thread_no_permission(self):
         """api test permission to edit closed thread title"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -139,10 +129,9 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "This thread is closed. You can't edit it."
             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):
     def test_change_thread_title_after_edit_time(self):
         """api cleans, validates and rejects too short title"""
         """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.started_on = timezone.now() - timedelta(minutes=10)
         self.thread.starter = self.user
         self.thread.starter = self.user
         self.thread.save()
         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."
             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):
     def test_change_thread_title_invalid(self):
         """api cleans, validates and rejects too short title"""
         """api cleans, validates and rejects too short title"""
-        self.override_acl({'can_edit_threads': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -186,10 +174,9 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
 
 
 
 
 class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
 class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_pin_threads': 2})
     def test_pin_thread(self):
     def test_pin_thread(self):
         """api makes it possible to pin globally thread"""
         """api makes it possible to pin globally thread"""
-        self.override_acl({'can_pin_threads': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -207,13 +194,9 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 2)
         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):
     def test_pin_thread_closed_category_no_permission(self):
         """api checks if category is closed"""
         """api checks if category is closed"""
-        self.override_acl({
-            'can_pin_threads': 2,
-            'can_close_threads': 0,
-        })
-
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         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."
             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):
     def test_pin_thread_closed_no_permission(self):
         """api checks if thread is closed"""
         """api checks if thread is closed"""
-        self.override_acl({
-            'can_pin_threads': 2,
-            'can_close_threads': 0,
-        })
-
         self.thread.is_closed = True
         self.thread.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -259,6 +238,7 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "This thread is closed. You can't change its weight."
             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):
     def test_unpin_thread(self):
         """api makes it possible to unpin thread"""
         """api makes it possible to unpin thread"""
         self.thread.weight = 2
         self.thread.weight = 2
@@ -267,8 +247,6 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 2)
         self.assertEqual(thread_json['weight'], 2)
 
 
-        self.override_acl({'can_pin_threads': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -286,10 +264,9 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 0)
         self.assertEqual(thread_json['weight'], 0)
 
 
+    @patch_category_acl({'can_pin_threads': 1})
     def test_pin_thread_no_permission(self):
     def test_pin_thread_no_permission(self):
         """api pin thread globally with no permission fails"""
         """api pin thread globally with no permission fails"""
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -309,6 +286,7 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 0)
         self.assertEqual(thread_json['weight'], 0)
 
 
+    @patch_category_acl({'can_pin_threads': 1})
     def test_unpin_thread_no_permission(self):
     def test_unpin_thread_no_permission(self):
         """api unpin thread with no permission fails"""
         """api unpin thread with no permission fails"""
         self.thread.weight = 2
         self.thread.weight = 2
@@ -317,8 +295,6 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 2)
         self.assertEqual(thread_json['weight'], 2)
 
 
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -340,10 +316,9 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
 
 
 
 
 class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
 class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_pin_threads': 1})
     def test_pin_thread(self):
     def test_pin_thread(self):
         """api makes it possible to pin locally thread"""
         """api makes it possible to pin locally thread"""
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -361,6 +336,7 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 1)
         self.assertEqual(thread_json['weight'], 1)
 
 
+    @patch_category_acl({'can_pin_threads': 1})
     def test_unpin_thread(self):
     def test_unpin_thread(self):
         """api makes it possible to unpin thread"""
         """api makes it possible to unpin thread"""
         self.thread.weight = 1
         self.thread.weight = 1
@@ -369,8 +345,6 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 1)
         self.assertEqual(thread_json['weight'], 1)
 
 
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -388,10 +362,9 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 0)
         self.assertEqual(thread_json['weight'], 0)
 
 
+    @patch_category_acl({'can_pin_threads': 0})
     def test_pin_thread_no_permission(self):
     def test_pin_thread_no_permission(self):
         """api pin thread locally with no permission fails"""
         """api pin thread locally with no permission fails"""
-        self.override_acl({'can_pin_threads': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -411,6 +384,7 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 0)
         self.assertEqual(thread_json['weight'], 0)
 
 
+    @patch_category_acl({'can_pin_threads': 0})
     def test_unpin_thread_no_permission(self):
     def test_unpin_thread_no_permission(self):
         """api unpin thread with no permission fails"""
         """api unpin thread with no permission fails"""
         self.thread.weight = 1
         self.thread.weight = 1
@@ -419,8 +393,6 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 1)
         self.assertEqual(thread_json['weight'], 1)
 
 
-        self.override_acl({'can_pin_threads': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -446,57 +418,30 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         super().setUp()
         super().setUp()
 
 
         Category(
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other category',
+            slug='other-category',
         ).insert_at(
         ).insert_at(
             self.category,
             self.category,
             position='last-child',
             position='last-child',
             save=True,
             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):
     def test_move_thread_no_top(self):
         """api moves thread to other category, sets no top category"""
         """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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
                     'path': 'category',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 },
                 {
                 {
                     'op': 'add',
                     'op': 'add',
                     'path': 'top-category',
                     'path': 'top-category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 },
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
@@ -508,24 +453,21 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         reponse_json = response.json()
         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()
         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):
     def test_move_thread_with_top(self):
         """api moves thread to other category, sets top"""
         """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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
                     'path': 'category',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 },
                 {
                 {
                     'op': 'add',
                     'op': 'add',
@@ -542,18 +484,15 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         reponse_json = response.json()
         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()
         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):
     def test_move_thread_reads(self):
         """api moves thread reads together with thread"""
         """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)
         poststracker.save_read(self.user, self.thread.first_post)
 
 
         self.assertEqual(self.user.postread_set.count(), 1)
         self.assertEqual(self.user.postread_set.count(), 1)
@@ -564,12 +503,12 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
                     'path': 'category',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 },
                 {
                 {
                     'op': 'add',
                     'op': 'add',
                     'path': 'top-category',
                     'path': 'top-category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 },
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
@@ -584,13 +523,12 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         postreads = self.user.postread_set.filter(post__is_event=False).order_by('id')
         postreads = self.user.postread_set.filter(post__is_event=False).order_by('id')
 
 
         self.assertEqual(postreads.count(), 1)
         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):
     def test_move_thread_subscriptions(self):
         """api moves thread subscriptions together with thread"""
         """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(
         self.user.subscription_set.create(
             thread=self.thread,
             thread=self.thread,
             category=self.thread.category,
             category=self.thread.category,
@@ -606,12 +544,12 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
                     'path': 'category',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 },
                 {
                 {
                     'op': 'add',
                     'op': 'add',
                     'path': 'top-category',
                     'path': 'top-category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 },
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
@@ -624,19 +562,17 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
 
 
         # thread read was moved to new category
         # thread read was moved to new category
         self.assertEqual(self.user.subscription_set.count(), 1)
         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):
     def test_move_thread_no_permission(self):
         """api move thread to other category with no permission fails"""
         """api move thread to other category with no permission fails"""
-        self.override_acl({'can_move_threads': False})
-        self.override_other_acl({})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
                     'path': 'category',
                     '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."
             response_json['detail'][0], "You can't move threads in this category."
         )
         )
 
 
-        self.override_other_acl({})
-
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category.pk)
         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):
     def test_move_thread_closed_category_no_permission(self):
         """api move thread from closed category with no permission fails"""
         """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.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -668,7 +598,7 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
                     'path': 'category',
                     '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."
             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):
     def test_move_closed_thread_no_permission(self):
         """api move closed thread with no permission fails"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -695,7 +621,7 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
                     'path': 'category',
                     '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."
             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):
     def test_move_thread_no_category_access(self):
         """api move thread to category with no access fails"""
         """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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
                     'path': 'category',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 },
             ]
             ]
         )
         )
@@ -725,22 +650,19 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], 'NOT FOUND')
         self.assertEqual(response_json['detail'][0], 'NOT FOUND')
 
 
-        self.override_other_acl({})
-
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category.pk)
         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):
     def test_move_thread_no_category_browse(self):
         """api move thread to category with no browsing access fails"""
         """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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
                     'path': 'category',
                     'path': 'category',
-                    'value': self.category_b.pk,
+                    'value': self.dst_category.pk,
                 },
                 },
             ]
             ]
         )
         )
@@ -749,25 +671,22 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(
         self.assertEqual(
             response_json['detail'][0],
             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()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category.pk)
         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):
     def test_move_thread_no_category_start_threads(self):
         """api move thread to category with no posting access fails"""
         """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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
                     'op': 'replace',
                     'op': 'replace',
                     'path': 'category',
                     '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."
             "You don't have permission to start new threads in this category."
         )
         )
 
 
-        self.override_other_acl({})
-
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category.pk)
         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):
     def test_move_thread_same_category(self):
         """api move thread to category it's already in fails"""
         """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(
         response = self.patch(
             self.api_link, [
             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."
             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()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['category']['id'], self.category.pk)
         self.assertEqual(thread_json['category']['id'], self.category.pk)
 
 
@@ -828,10 +742,9 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
 
 
 
 
 class ThreadCloseApiTests(ThreadPatchApiTestCase):
 class ThreadCloseApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_close_threads': True})
     def test_close_thread(self):
     def test_close_thread(self):
         """api makes it possible to close thread"""
         """api makes it possible to close thread"""
-        self.override_acl({'can_close_threads': True})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -849,6 +762,7 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_closed'])
         self.assertTrue(thread_json['is_closed'])
 
 
+    @patch_category_acl({'can_close_threads': True})
     def test_open_thread(self):
     def test_open_thread(self):
         """api makes it possible to open thread"""
         """api makes it possible to open thread"""
         self.thread.is_closed = True
         self.thread.is_closed = True
@@ -857,8 +771,6 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_closed'])
         self.assertTrue(thread_json['is_closed'])
 
 
-        self.override_acl({'can_close_threads': True})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -876,10 +788,9 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertFalse(thread_json['is_closed'])
         self.assertFalse(thread_json['is_closed'])
 
 
+    @patch_category_acl({'can_close_threads': False})
     def test_close_thread_no_permission(self):
     def test_close_thread_no_permission(self):
         """api close thread with no permission fails"""
         """api close thread with no permission fails"""
-        self.override_acl({'can_close_threads': False})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -899,6 +810,7 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertFalse(thread_json['is_closed'])
         self.assertFalse(thread_json['is_closed'])
 
 
+    @patch_category_acl({'can_close_threads': False})
     def test_open_thread_no_permission(self):
     def test_open_thread_no_permission(self):
         """api open thread with no permission fails"""
         """api open thread with no permission fails"""
         self.thread.is_closed = True
         self.thread.is_closed = True
@@ -907,8 +819,6 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_closed'])
         self.assertTrue(thread_json['is_closed'])
 
 
-        self.override_acl({'can_close_threads': False})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -930,6 +840,7 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
 
 
 
 
 class ThreadApproveApiTests(ThreadPatchApiTestCase):
 class ThreadApproveApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_approve_content': True})
     def test_approve_thread(self):
     def test_approve_thread(self):
         """api makes it possible to approve thread"""
         """api makes it possible to approve thread"""
         self.thread.first_post.is_unapproved = True
         self.thread.first_post.is_unapproved = True
@@ -941,8 +852,6 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         self.assertTrue(self.thread.is_unapproved)
         self.assertTrue(self.thread.is_unapproved)
         self.assertTrue(self.thread.has_unapproved_posts)
         self.assertTrue(self.thread.has_unapproved_posts)
 
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -966,6 +875,7 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         self.assertFalse(thread.is_unapproved)
         self.assertFalse(thread.is_unapproved)
         self.assertFalse(thread.has_unapproved_posts)
         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):
     def test_approve_thread_category_closed_no_permission(self):
         """api checks permission for approving threads in closed categories"""
         """api checks permission for approving threads in closed categories"""
         self.thread.first_post.is_unapproved = True
         self.thread.first_post.is_unapproved = True
@@ -980,11 +890,6 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         self.category.save()
 
 
-        self.override_acl({
-            'can_approve_content': 1,
-            'can_close_threads': 0,
-        })
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -999,6 +904,7 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "This category is closed. You can't approve threads in it.")
         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):
     def test_approve_thread_closed_no_permission(self):
         """api checks permission for approving posts in closed categories"""
         """api checks permission for approving posts in closed categories"""
         self.thread.first_post.is_unapproved = True
         self.thread.first_post.is_unapproved = True
@@ -1013,11 +919,6 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
-        self.override_acl({
-            'can_approve_content': 1,
-            'can_close_threads': 0,
-        })
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1032,10 +933,9 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "This thread is closed. You can't approve it.")
         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):
     def test_unapprove_thread(self):
         """api returns permission error on approval removal"""
         """api returns permission error on approval removal"""
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1052,10 +952,9 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
 
 
 
 
 class ThreadHideApiTests(ThreadPatchApiTestCase):
 class ThreadHideApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_hide_threads': True})
     def test_hide_thread(self):
     def test_hide_thread(self):
         """api makes it possible to hide thread"""
         """api makes it possible to hide thread"""
-        self.override_acl({'can_hide_threads': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1070,15 +969,12 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
         reponse_json = response.json()
         reponse_json = response.json()
         self.assertTrue(reponse_json['is_hidden'])
         self.assertTrue(reponse_json['is_hidden'])
 
 
-        self.override_acl({'can_hide_threads': 1})
-
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_hidden'])
         self.assertTrue(thread_json['is_hidden'])
 
 
+    @patch_category_acl({'can_hide_threads': False})
     def test_hide_thread_no_permission(self):
     def test_hide_thread_no_permission(self):
         """api hide thread with no permission fails"""
         """api hide thread with no permission fails"""
-        self.override_acl({'can_hide_threads': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1098,13 +994,9 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertFalse(thread_json['is_hidden'])
         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):
     def test_hide_non_owned_thread(self):
         """api forbids non-moderator from hiding other users threads"""
         """api forbids non-moderator from hiding other users threads"""
-        self.override_acl({
-            'can_hide_own_threads': 1,
-            'can_hide_threads': 0
-        })
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1121,14 +1013,13 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
             response_json['detail'][0], "You can't hide other users theads in this category."
             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):
     def test_hide_owned_thread_no_time(self):
         """api forbids non-moderator from hiding other users threads"""
         """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.started_on = timezone.now() - timedelta(minutes=5)
         self.thread.starter = self.user
         self.thread.starter = self.user
         self.thread.save()
         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."
             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):
     def test_hide_closed_category_no_permission(self):
         """api test permission to hide thread in closed category"""
         """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.is_closed = True
         self.category.save()
         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."
             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):
     def test_hide_closed_thread_no_permission(self):
         """api test permission to hide closed thread"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -1209,10 +1092,9 @@ class ThreadUnhideApiTests(ThreadPatchApiTestCase):
         self.thread.is_hidden = True
         self.thread.is_hidden = True
         self.thread.save()
         self.thread.save()
 
 
+    @patch_category_acl({'can_hide_threads': True})
     def test_unhide_thread(self):
     def test_unhide_thread(self):
         """api makes it possible to unhide thread"""
         """api makes it possible to unhide thread"""
-        self.override_acl({'can_hide_threads': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1227,15 +1109,12 @@ class ThreadUnhideApiTests(ThreadPatchApiTestCase):
         reponse_json = response.json()
         reponse_json = response.json()
         self.assertFalse(reponse_json['is_hidden'])
         self.assertFalse(reponse_json['is_hidden'])
 
 
-        self.override_acl({'can_hide_threads': 1})
-
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertFalse(thread_json['is_hidden'])
         self.assertFalse(thread_json['is_hidden'])
 
 
+    @patch_category_acl({'can_hide_threads': False})
     def test_unhide_thread_no_permission(self):
     def test_unhide_thread_no_permission(self):
         """api unhide thread with no permission fails as thread is invisible"""
         """api unhide thread with no permission fails as thread is invisible"""
-        self.override_acl({'can_hide_threads': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1247,13 +1126,9 @@ class ThreadUnhideApiTests(ThreadPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 404)
         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):
     def test_unhide_closed_category_no_permission(self):
         """api test permission to unhide thread in closed category"""
         """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.is_closed = True
         self.category.save()
         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."
             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):
     def test_unhide_closed_thread_no_permission(self):
         """api test permission to unhide closed thread"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -1405,10 +1276,9 @@ class ThreadSubscribeApiTests(ThreadPatchApiTestCase):
 
 
 
 
 class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
 class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer(self):
     def test_mark_best_answer(self):
         """api makes it possible to mark best answer"""
         """api makes it possible to mark best answer"""
-        self.override_acl({'can_mark_best_answers': 2})
-
         best_answer = testutils.reply_thread(self.thread)
         best_answer = testutils.reply_thread(self.thread)
 
 
         response = self.patch(
         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_name'], self.user.username)
         self.assertEqual(thread_json['best_answer_marked_by_slug'], self.user.slug)
         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):
     def test_mark_best_answer_anonymous(self):
         """api validates that user is authenticated before marking best answer"""
         """api validates that user is authenticated before marking best answer"""
         self.logout_user()
         self.logout_user()
 
 
-        self.override_acl({'can_mark_best_answers': 2})
-
         best_answer = testutils.reply_thread(self.thread)
         best_answer = testutils.reply_thread(self.thread)
 
 
         response = self.patch(
         response = self.patch(
@@ -1467,10 +1336,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         self.assertIsNone(thread_json['best_answer'])
 
 
+    @patch_category_acl({'can_mark_best_answers': 0})
     def test_mark_best_answer_no_permission(self):
     def test_mark_best_answer_no_permission(self):
         """api validates permission to mark best answers"""
         """api validates permission to mark best answers"""
-        self.override_acl({'can_mark_best_answers': 0})
-
         best_answer = testutils.reply_thread(self.thread)
         best_answer = testutils.reply_thread(self.thread)
 
 
         response = self.patch(
         response = self.patch(
@@ -1493,10 +1361,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         self.assertIsNone(thread_json['best_answer'])
 
 
+    @patch_category_acl({'can_mark_best_answers': 1})
     def test_mark_best_answer_not_thread_starter(self):
     def test_mark_best_answer_not_thread_starter(self):
         """api validates permission to mark best answers in owned thread"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
 
 
         response = self.patch(
         response = self.patch(
@@ -1524,8 +1391,6 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.starter = self.user
         self.thread.starter = self.user
         self.thread.save()
         self.thread.save()
 
 
-        self.override_acl({'can_mark_best_answers': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1537,10 +1402,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
 
 
         self.category.is_closed = True
         self.category.is_closed = True
@@ -1567,8 +1431,13 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
@@ -1581,10 +1450,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
 
 
         self.thread.is_closed = True
         self.thread.is_closed = True
@@ -1611,8 +1479,13 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
@@ -1624,11 +1497,10 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
             ]
             ]
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
-
+    
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_invalid_post_id(self):
     def test_mark_best_answer_invalid_post_id(self):
         """api validates that post id is int"""
         """api validates that post id is int"""
-        self.override_acl({'can_mark_best_answers': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1647,10 +1519,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         self.assertIsNone(thread_json['best_answer'])
 
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_post_not_found(self):
     def test_mark_best_answer_post_not_found(self):
         """api validates that post exists"""
         """api validates that post exists"""
-        self.override_acl({'can_mark_best_answers': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1668,11 +1539,10 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
 
 
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         self.assertIsNone(thread_json['best_answer'])
-        
+
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_post_invisible(self):
     def test_mark_best_answer_post_invisible(self):
         """api validates post visibility to action author"""
         """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)
         unapproved_post = testutils.reply_thread(self.thread, is_unapproved=True)
 
 
         response = self.patch(
         response = self.patch(
@@ -1693,10 +1563,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         self.assertIsNone(thread_json['best_answer'])
 
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_post_other_thread(self):
     def test_mark_best_answer_post_other_thread(self):
         """api validates post belongs to same thread"""
         """api validates post belongs to same thread"""
-        self.override_acl({'can_mark_best_answers': 2})
-
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
 
 
         response = self.patch(
         response = self.patch(
@@ -1717,10 +1586,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         self.assertIsNone(thread_json['best_answer'])
 
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_event_id(self):
     def test_mark_best_answer_event_id(self):
         """api validates that post is not an event"""
         """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 = testutils.reply_thread(self.thread)
         best_answer.is_event = True
         best_answer.is_event = True
         best_answer.save()
         best_answer.save()
@@ -1743,10 +1611,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         self.assertIsNone(thread_json['best_answer'])
 
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_first_post(self):
     def test_mark_best_answer_first_post(self):
         """api validates that post is not a first post in thread"""
         """api validates that post is not a first post in thread"""
-        self.override_acl({'can_mark_best_answers': 2})
-        
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1765,10 +1632,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         self.assertIsNone(thread_json['best_answer'])
 
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_hidden_post(self):
     def test_mark_best_answer_hidden_post(self):
         """api validates that post is not hidden"""
         """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)
         best_answer = testutils.reply_thread(self.thread, is_hidden=True)
 
 
         response = self.patch(
         response = self.patch(
@@ -1789,10 +1655,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         self.assertIsNone(thread_json['best_answer'])
 
 
+    @patch_category_acl({'can_mark_best_answers': 2})
     def test_mark_best_answer_unapproved_post(self):
     def test_mark_best_answer_unapproved_post(self):
         """api validates that post is not unapproved"""
         """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)
         best_answer = testutils.reply_thread(self.thread, poster=self.user, is_unapproved=True)
 
 
         response = self.patch(
         response = self.patch(
@@ -1813,10 +1678,9 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         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"""
         """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)
         best_answer = testutils.reply_thread(self.thread, is_protected=True)
 
 
         response = self.patch(
         response = self.patch(
@@ -1840,8 +1704,10 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertIsNone(thread_json['best_answer'])
         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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
@@ -1863,10 +1729,9 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.set_best_answer(self.user, self.best_answer)
         self.thread.set_best_answer(self.user, self.best_answer)
         self.thread.save()
         self.thread.save()
 
 
+    @patch_category_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 2})
     def test_change_best_answer(self):
     def test_change_best_answer(self):
         """api makes it possible to change best answer"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
 
 
         response = self.patch(
         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_name'], self.user.username)
         self.assertEqual(thread_json['best_answer_marked_by_slug'], self.user.slug)
         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):
     def test_change_best_answer_same_post(self):
         """api validates if new best answer is same as current one"""
         """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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1922,10 +1786,9 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         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):
     def test_change_best_answer_no_permission_to_mark(self):
         """api validates permission to mark best answers before allowing answer change"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
 
 
         response = self.patch(
         response = self.patch(
@@ -1948,10 +1811,9 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         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):
     def test_change_best_answer_no_permission(self):
         """api validates permission to change best answers"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
 
 
         response = self.patch(
         response = self.patch(
@@ -1975,10 +1837,9 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         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):
     def test_change_best_answer_not_starter(self):
         """api validates permission to change best answers"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
 
 
         response = self.patch(
         response = self.patch(
@@ -2003,8 +1864,6 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
 
         # passing scenario is possible
         # passing scenario is possible
-        self.override_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 1})
-        
         self.thread.starter = self.user
         self.thread.starter = self.user
         self.thread.save()
         self.thread.save()
 
 
@@ -2019,14 +1878,13 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
 
 
         self.thread.best_answer_marked_on = timezone.now() - timedelta(minutes=6)
         self.thread.best_answer_marked_on = timezone.now() - timedelta(minutes=6)
@@ -2054,13 +1912,19 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -2072,14 +1936,13 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
 
 
         self.thread.best_answer_is_protected = True
         self.thread.best_answer_is_protected = True
@@ -2106,13 +1969,18 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -2124,13 +1992,9 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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):
     def test_change_best_answer_post_validation(self):
         """api validates new post'"""
         """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)
         best_answer = testutils.reply_thread(self.thread, is_hidden=True)
 
 
         response = self.patch(
         response = self.patch(
@@ -2156,10 +2020,9 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.set_best_answer(self.user, self.best_answer)
         self.thread.set_best_answer(self.user, self.best_answer)
         self.thread.save()
         self.thread.save()
 
 
+    @patch_category_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
     def test_unmark_best_answer(self):
     def test_unmark_best_answer(self):
         """api makes it possible to unmark best answer"""
         """api makes it possible to unmark best answer"""
-        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             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_name'])
         self.assertIsNone(thread_json['best_answer_marked_by_slug'])
         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):
     def test_unmark_best_answer_invalid_post_id(self):
         """api validates that post id is int"""
         """api validates that post id is int"""
-        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -2212,10 +2074,9 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         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):
     def test_unmark_best_answer_post_not_found(self):
         """api validates that post to unmark exists"""
         """api validates that post to unmark exists"""
-        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -2233,11 +2094,10 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
 
 
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         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):
     def test_unmark_best_answer_wrong_post(self):
         """api validates if post given to unmark is best answer"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
 
 
         response = self.patch(
         response = self.patch(
@@ -2260,10 +2120,9 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         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):
     def test_unmark_best_answer_no_permission(self):
         """api validates if user has permission to unmark best answers"""
         """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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -2285,10 +2144,9 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         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):
     def test_unmark_best_answer_not_starter(self):
         """api validates if starter has permission to unmark best answers"""
         """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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -2311,8 +2169,6 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
 
         # passing scenario is possible
         # passing scenario is possible
-        self.override_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 1})
-
         self.thread.starter = self.user
         self.thread.starter = self.user
         self.thread.save()
         self.thread.save()
 
 
@@ -2327,14 +2183,13 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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):
     def test_unmark_best_answer_timelimit(self):
         """api validates if starter has permission to unmark best answer within time limit"""
         """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.best_answer_marked_on = timezone.now() - timedelta(minutes=6)
         self.thread.starter = self.user
         self.thread.starter = self.user
         self.thread.save()
         self.thread.save()
@@ -2361,11 +2216,8 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
 
 
         # passing scenario is possible
         # 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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
@@ -2378,14 +2230,13 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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"""
         """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.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -2410,13 +2261,16 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -2428,14 +2282,13 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -2460,13 +2313,16 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -2478,14 +2334,13 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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"""
         """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.best_answer_is_protected = True
         self.thread.save()
         self.thread.save()
 
 
@@ -2510,13 +2365,16 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['best_answer'], self.best_answer.id)
         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(
         response = self.patch(
             self.api_link, [
             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 django.urls import reverse
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
@@ -14,7 +13,6 @@ class ThreadPollApiTestCase(AuthenticatedUserTestCase):
 
 
         self.category = Category.objects.get(slug='first-category')
         self.category = Category.objects.get(slug='first-category')
         self.thread = testutils.post_thread(self.category, poster=self.user)
         self.thread = testutils.post_thread(self.category, poster=self.user)
-        self.override_acl()
 
 
         self.api_link = reverse(
         self.api_link = reverse(
             'misago:api:thread-poll-list', kwargs={
             'misago:api:thread-poll-list', kwargs={
@@ -28,29 +26,6 @@ class ThreadPollApiTestCase(AuthenticatedUserTestCase):
     def put(self, url, data=None):
     def put(self, url, data=None):
         return self.client.put(url, json.dumps(data or {}), content_type='application/json')
         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):
     def mock_poll(self):
         self.poll = self.thread.poll = testutils.post_poll(self.thread, self.user)
         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 django.urls import reverse
 
 
+from misago.acl.test import patch_user_acl
 from misago.threads.models import Poll, Thread
 from misago.threads.models import Poll, Thread
 from misago.threads.serializers.poll import MAX_POLL_OPTIONS
 from misago.threads.serializers.poll import MAX_POLL_OPTIONS
+from misago.threads.test import patch_category_acl
 
 
 from .test_thread_poll_api import ThreadPollApiTestCase
 from .test_thread_poll_api import ThreadPollApiTestCase
 
 
@@ -36,20 +38,19 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         response = self.post(api_link)
         response = self.post(api_link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_user_acl({"can_start_polls": 0})
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates that user has permission to start poll in thread"""
         """api validates that user has permission to start poll in thread"""
-        self.override_acl({'can_start_polls': 0})
-
         response = self.post(self.api_link)
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't start polls."
             "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"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -59,15 +60,20 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
             "detail": "This thread is closed. You can't start polls in it."
             "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)
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
         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"""
         """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.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -77,15 +83,19 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
             "detail": "This category is closed. You can't start polls in it."
             "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)
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
         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"""
         """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.starter = None
         self.thread.save()
         self.thread.save()
 
 
@@ -95,7 +105,11 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
             "detail": "You can't start polls in other users threads."
             "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)
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
         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.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 
 
+from misago.acl.test import patch_user_acl
 from misago.threads.models import Poll, PollVote, Thread
 from misago.threads.models import Poll, PollVote, Thread
+from misago.threads.test import patch_category_acl
 
 
 from .test_thread_poll_api import ThreadPollApiTestCase
 from .test_thread_poll_api import ThreadPollApiTestCase
 
 
@@ -73,20 +75,18 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         response = self.client.delete(api_link)
         response = self.client.delete(api_link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_user_acl({"can_delete_polls": 0})
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates that user has permission to delete poll in thread"""
         """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)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't delete polls."
             "detail": "You can't delete polls."
         })
         })
 
 
+    @patch_user_acl({"can_delete_polls": 1, "poll_edit_time": 5})
     def test_no_permission_timeout(self):
     def test_no_permission_timeout(self):
         """api validates that user's window to delete poll in thread has closed"""
         """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.posted_on = timezone.now() - timedelta(minutes=15)
         self.poll.save()
         self.poll.save()
 
 
@@ -96,10 +96,9 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
             "detail": "You can't delete polls that are older than 5 minutes."
             "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):
     def test_no_permission_poll_closed(self):
         """api validates that user's window to delete poll in thread has closed"""
         """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.posted_on = timezone.now() - timedelta(days=15)
         self.poll.length = 5
         self.poll.length = 5
         self.poll.save()
         self.poll.save()
@@ -110,10 +109,9 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
             "detail": "This poll is over. You can't delete it."
             "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):
     def test_no_permission_other_user_poll(self):
         """api validates that user has permission to delete other user poll in thread"""
         """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.poster = None
         self.poll.save()
         self.poll.save()
 
 
@@ -123,10 +121,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
             "detail": "You can't delete other users polls in this category."
             "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):
     def test_no_permission_closed_thread(self):
         """api validates that user has permission to delete poll in closed thread"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -136,15 +134,20 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
             "detail": "This thread is closed. You can't delete polls in it."
             "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)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
         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):
     def test_no_permission_closed_category(self):
         """api validates that user has permission to delete poll in closed category"""
         """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.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -154,11 +157,17 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
             "detail": "This category is closed. You can't delete polls in it."
             "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)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
+    @patch_user_acl({"can_delete_polls": 1, "poll_edit_time": 5})
     def test_poll_delete(self):
     def test_poll_delete(self):
         """api deletes poll and associated votes"""
         """api deletes poll and associated votes"""
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
@@ -173,10 +182,9 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         thread = Thread.objects.get(pk=self.thread.pk)
         thread = Thread.objects.get(pk=self.thread.pk)
         self.assertFalse(thread.has_poll)
         self.assertFalse(thread.has_poll)
 
 
+    @patch_user_acl({"can_delete_polls": 2, "poll_edit_time": 5})
     def test_other_user_poll_delete(self):
     def test_other_user_poll_delete(self):
         """api deletes other user's poll and associated votes, even if its over"""
         """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.poster = None
         self.poll.posted_on = timezone.now() - timedelta(days=15)
         self.poll.posted_on = timezone.now() - timedelta(days=15)
         self.poll.length = 5
         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.urls import reverse
 from django.utils import timezone
 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.serializers.poll import MAX_POLL_OPTIONS
+from misago.threads.test import patch_category_acl
 
 
 from .test_thread_poll_api import ThreadPollApiTestCase
 from .test_thread_poll_api import ThreadPollApiTestCase
 
 
@@ -73,20 +75,18 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         response = self.put(api_link)
         response = self.put(api_link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_user_acl({"can_edit_polls": 0})
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates that user has permission to edit poll in thread"""
         """api validates that user has permission to edit poll in thread"""
-        self.override_acl({'can_edit_polls': 0})
-
         response = self.put(self.api_link)
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't edit polls.",
             "detail": "You can't edit polls.",
         })
         })
 
 
+    @patch_user_acl({"can_edit_polls": 1, "poll_edit_time": 5})
     def test_no_permission_timeout(self):
     def test_no_permission_timeout(self):
         """api validates that user's window to edit poll in thread has closed"""
         """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.posted_on = timezone.now() - timedelta(minutes=15)
         self.poll.save()
         self.poll.save()
 
 
@@ -96,10 +96,9 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
             "detail": "You can't edit polls that are older than 5 minutes.",
             "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):
     def test_no_permission_poll_closed(self):
         """api validates that user's window to edit poll in thread has closed"""
         """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.posted_on = timezone.now() - timedelta(days=15)
         self.poll.length = 5
         self.poll.length = 5
         self.poll.save()
         self.poll.save()
@@ -110,10 +109,9 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
             "detail": "This poll is over. You can't edit it.",
             "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):
     def test_no_permission_other_user_poll(self):
         """api validates that user has permission to edit other user poll in thread"""
         """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.poster = None
         self.poll.save()
         self.poll.save()
 
 
@@ -123,10 +121,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
             "detail": "You can't edit other users polls in this category.",
             "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):
     def test_no_permission_closed_thread(self):
         """api validates that user has permission to edit poll in closed thread"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -136,15 +134,20 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
             "detail": "This thread is closed. You can't edit polls in it.",
             "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)
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
         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):
     def test_no_permission_closed_category(self):
         """api validates that user has permission to edit poll in closed category"""
         """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.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -154,7 +157,12 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
             "detail": "This category is closed. You can't edit polls in it.",
             "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)
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
@@ -513,10 +521,9 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
 
         self.assertEqual(self.user.audittrail_set.count(), 1)
         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):
     def test_moderate_user_poll(self):
         """api edits all poll choices out in other users poll, even if its over"""
         """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.poster = None
         self.poll.posted_on = timezone.now() - timedelta(days=15)
         self.poll.posted_on = timezone.now() - timedelta(days=15)
         self.poll.length = 5
         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.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 
 
+from misago.acl.test import patch_user_acl
 from misago.threads.models import Poll
 from misago.threads.models import Poll
+from misago.threads.test import patch_category_acl
 
 
 from .test_thread_poll_api import ThreadPollApiTestCase
 from .test_thread_poll_api import ThreadPollApiTestCase
 
 
@@ -88,10 +90,9 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
         response = self.client.get(api_link)
         response = self.client.get(api_link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_user_acl({"can_always_see_poll_voters": False})
     def test_no_permission(self):
     def test_no_permission(self):
         """api chcecks permission to see poll voters"""
         """api chcecks permission to see poll voters"""
-        self.override_acl({'can_always_see_poll_voters': False})
-
         self.poll.is_public = False
         self.poll.is_public = False
         self.poll.save()
         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],
         self.assertEqual([[v['url'] for v in c['voters']] for c in response_json][0][0],
                          user.get_absolute_url())
                          user.get_absolute_url())
 
 
+    @patch_user_acl({"can_always_see_poll_voters": True})
     def test_get_votes_private_poll(self):
     def test_get_votes_private_poll(self):
         """api returns list of voters on private poll for user with permission"""
         """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.is_public = False
         self.poll.save()
         self.poll.save()
 
 
@@ -271,10 +271,9 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
             "detail": 'Expected a list of items but got type "dict".',
             "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"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -286,18 +285,20 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
             "detail": "This thread is closed. You can't vote in it.",
             "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)
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
         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"""
         """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.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -309,13 +310,16 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
             "detail": "This category is closed. You can't vote in it.",
             "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)
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
         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):
     def test_vote_in_finished_poll(self):
         """api valdiates if poll has finished before letting user to vote in it"""
         """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 import testutils
 from misago.threads.models import Post, Thread
 from misago.threads.models import Post, Thread
+from misago.threads.test import patch_category_acl
 
 
 from .test_threads_api import ThreadsApiTestCase
 from .test_threads_api import ThreadsApiTestCase
 
 
@@ -64,13 +65,9 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "You have to specify at least one post to delete.",
             "detail": "You have to specify at least one post to delete.",
         })
         })
 
 
+    @patch_category_acl({'can_hide_posts': 2})
     def test_validate_ids(self):
     def test_validate_ids(self):
         """api validates that ids are list of ints"""
         """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)
         response = self.delete(self.api_link, True)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
@@ -89,39 +86,27 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "One or more post ids received were invalid.",
             "detail": "One or more post ids received were invalid.",
         })
         })
 
 
+    @patch_category_acl({'can_hide_posts': 2})
     def test_validate_ids_length(self):
     def test_validate_ids_length(self):
         """api validates that ids are list of ints"""
         """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)))
         response = self.delete(self.api_link, list(range(100)))
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "No more than 24 posts can be deleted at single time.",
             "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):
     def test_validate_posts_exist(self):
         """api validates that ids are visible posts"""
         """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])
         response = self.delete(self.api_link, [p.id * 10 for p in self.posts])
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "One or more posts to delete could not be found.",
             "detail": "One or more posts to delete could not be found.",
         })
         })
 
 
+    @patch_category_acl({'can_hide_posts': 2})
     def test_validate_posts_visibility(self):
     def test_validate_posts_visibility(self):
         """api validates that ids are visible posts"""
         """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].is_unapproved = True
         self.posts[1].save()
         self.posts[1].save()
 
 
@@ -131,13 +116,9 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "One or more posts to delete could not be found.",
             "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):
     def test_validate_posts_same_thread(self):
         """api validates that ids are same thread posts"""
         """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)
         other_thread = testutils.post_thread(category=self.category)
         self.posts.append(testutils.reply_thread(other_thread, poster=self.user))
         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.",
             "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):
     def test_no_permission(self):
         """api validates permission to delete"""
         """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])
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't delete posts in this category.",
             "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):
     def test_delete_other_user_post_no_permission(self):
         """api valdiates if user can delete other users posts"""
         """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])
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't delete other users posts in this category.",
             "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):
     def test_delete_protected_post_no_permission(self):
         """api validates if user can delete protected post"""
         """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].is_protected = True
         self.posts[0].save()
         self.posts[0].save()
 
 
@@ -191,14 +166,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "This post is protected. You can't delete it.",
             "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):
     def test_delete_protected_post_after_edit_time(self):
         """api validates if user can delete delete post after edit time"""
         """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].posted_on = timezone.now() - timedelta(minutes=10)
         self.posts[0].save()
         self.posts[0].save()
 
 
@@ -208,13 +182,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete posts that are older than 1 minute.",
             "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):
     def test_delete_post_closed_thread_no_permission(self):
         """api valdiates if user can delete posts in closed threads"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -224,13 +198,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "This thread is closed. You can't delete posts in it.",
             "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):
     def test_delete_post_closed_category_no_permission(self):
         """api valdiates if user can delete posts in closed categories"""
         """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.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -240,13 +214,9 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "This category is closed. You can't delete posts in it.",
             "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):
     def test_delete_first_post(self):
         """api disallows first post's deletion"""
         """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 = [p.id for p in self.posts]
         ids.append(self.thread.first_post_id)
         ids.append(self.thread.first_post_id)
 
 
@@ -256,10 +226,9 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete thread's first post.",
             "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):
     def test_delete_best_answer(self):
         """api disallows best answer deletion"""
         """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.set_best_answer(self.user, self.posts[0])
         self.thread.save()
         self.thread.save()
 
 
@@ -269,14 +238,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete this post because its marked as best answer.",
             "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):
     def test_delete_event(self):
         """api differs posts from events"""
         """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].is_event = True
         self.posts[1].save()
         self.posts[1].save()
 
 
@@ -286,14 +254,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete events in this category.",
             "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):
     def test_delete_owned_posts(self):
         """api deletes owned thread posts"""
         """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]
         ids = [self.posts[0].id, self.posts[-1].id]
 
 
         response = self.delete(self.api_link, ids)
         response = self.delete(self.api_link, ids)
@@ -304,13 +271,9 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             with self.assertRaises(Post.DoesNotExist):
             with self.assertRaises(Post.DoesNotExist):
                 self.thread.post_set.get(pk=post)
                 self.thread.post_set.get(pk=post)
 
 
+    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 0})
     def test_delete_posts(self):
     def test_delete_posts(self):
         """api deletes thread posts"""
         """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])
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 200)
         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.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Post, Thread
 from misago.threads.models import Post, Thread
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -35,21 +35,6 @@ class ThreadPostBulkPatchApiTestCase(AuthenticatedUserTestCase):
     def patch(self, api_link, ops):
     def patch(self, api_link, ops):
         return self.client.patch(api_link, json.dumps(ops), content_type="application/json")
         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):
 class BulkPatchSerializerTests(ThreadPostBulkPatchApiTestCase):
     def test_invalid_input_type(self):
     def test_invalid_input_type(self):
@@ -220,13 +205,9 @@ class PostsAddAclApiTests(ThreadPostBulkPatchApiTestCase):
 
 
 
 
 class BulkPostProtectApiTests(ThreadPostBulkPatchApiTestCase):
 class BulkPostProtectApiTests(ThreadPostBulkPatchApiTestCase):
+    @patch_category_acl({"can_protect_posts": True, "can_edit_posts": 2})
     def test_protect_post(self):
     def test_protect_post(self):
         """api makes it possible to protect posts"""
         """api makes it possible to protect posts"""
-        self.override_acl({
-            'can_protect_posts': 1,
-            'can_edit_posts': 2,
-        })
-
         response = self.patch(
         response = self.patch(
             self.api_link, {
             self.api_link, {
                 'ids': self.ids,
                 'ids': self.ids,
@@ -249,10 +230,9 @@ class BulkPostProtectApiTests(ThreadPostBulkPatchApiTestCase):
         for post in Post.objects.filter(id__in=self.ids):
         for post in Post.objects.filter(id__in=self.ids):
             self.assertTrue(post.is_protected)
             self.assertTrue(post.is_protected)
 
 
+    @patch_category_acl({"can_protect_posts": False})
     def test_protect_post_no_permission(self):
     def test_protect_post_no_permission(self):
         """api validates permission to protect posts and returns errors"""
         """api validates permission to protect posts and returns errors"""
-        self.override_acl({'can_protect_posts': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, {
             self.api_link, {
                 'ids': self.ids,
                 'ids': self.ids,
@@ -280,6 +260,7 @@ class BulkPostProtectApiTests(ThreadPostBulkPatchApiTestCase):
 
 
 
 
 class BulkPostsApproveApiTests(ThreadPostBulkPatchApiTestCase):
 class BulkPostsApproveApiTests(ThreadPostBulkPatchApiTestCase):
+    @patch_category_acl({"can_approve_content": True})
     def test_approve_post(self):
     def test_approve_post(self):
         """api resyncs thread and categories on posts approval"""
         """api resyncs thread and categories on posts approval"""
         for post in self.posts:
         for post in self.posts:
@@ -291,8 +272,6 @@ class BulkPostsApproveApiTests(ThreadPostBulkPatchApiTestCase):
 
 
         self.assertNotIn(self.thread.last_post_id, self.ids)
         self.assertNotIn(self.thread.last_post_id, self.ids)
 
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, {
             self.api_link, {
                 'ids': self.ids,
                 '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 import testutils
 from misago.threads.models import Post, Thread
 from misago.threads.models import Post, Thread
+from misago.threads.test import patch_category_acl
 
 
 from .test_threads_api import ThreadsApiTestCase
 from .test_threads_api import ThreadsApiTestCase
 
 
@@ -33,24 +34,22 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "This action is not available to guests.",
             "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):
     def test_no_permission(self):
         """api validates permission to delete post"""
         """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)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't delete posts in this category.",
             "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):
     def test_delete_other_user_post_no_permission(self):
         """api valdiates if user can delete other users posts"""
         """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.poster = None
         self.post.save()
         self.post.save()
 
 
@@ -60,14 +59,13 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete other users posts in this category.",
             "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):
     def test_delete_protected_post_no_permission(self):
         """api validates if user can delete protected post"""
         """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.is_protected = True
         self.post.save()
         self.post.save()
 
 
@@ -77,14 +75,13 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "This post is protected. You can't delete it.",
             "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):
     def test_delete_protected_post_after_edit_time(self):
         """api validates if user can delete delete post after edit time"""
         """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.posted_on = timezone.now() - timedelta(minutes=10)
         self.post.save()
         self.post.save()
 
 
@@ -94,13 +91,14 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete posts that are older than 1 minute.",
             "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):
     def test_delete_post_closed_thread_no_permission(self):
         """api valdiates if user can delete posts in closed threads"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -110,13 +108,14 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "This thread is closed. You can't delete posts in it.",
             "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):
     def test_delete_post_closed_category_no_permission(self):
         """api valdiates if user can delete posts in closed categories"""
         """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.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -126,10 +125,9 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "This category is closed. You can't delete posts in it.",
             "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):
     def test_delete_first_post(self):
         """api disallows first post deletion"""
         """api disallows first post deletion"""
-        self.override_acl({'can_hide_own_posts': 2, 'can_hide_posts': 2})
-
         api_link = reverse(
         api_link = reverse(
             'misago:api:thread-post-detail',
             'misago:api:thread-post-detail',
             kwargs={
             kwargs={
@@ -144,10 +142,9 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             "detail": "You can't delete thread's first post.",
             "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):
     def test_delete_best_answer(self):
         """api disallows best answer deletion"""
         """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.set_best_answer(self.user, self.post)
         self.thread.save()
         self.thread.save()
 
 
@@ -157,14 +154,13 @@ class PostDeleteApiTests(ThreadsApiTestCase):
             'detail': "You can't delete this post because its marked as best answer.",
             '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):
     def test_delete_owned_post(self):
         """api deletes owned thread post"""
         """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)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -174,10 +170,9 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         with self.assertRaises(Post.DoesNotExist):
         with self.assertRaises(Post.DoesNotExist):
             self.thread.post_set.get(pk=self.post.pk)
             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):
     def test_delete_post(self):
         """api deletes thread post"""
         """api deletes thread post"""
-        self.override_acl({'can_hide_own_posts': 0, 'can_hide_posts': 2})
-
         response = self.client.delete(self.api_link)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -212,27 +207,27 @@ class EventDeleteApiTests(ThreadsApiTestCase):
             "detail": "This action is not available to guests.",
             "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):
     def test_no_permission(self):
         """api validates permission to delete event"""
         """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)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't delete events in this category.",
             "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):
     def test_delete_event_closed_thread_no_permission(self):
         """api valdiates if user can delete events in closed threads"""
         """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.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -242,13 +237,13 @@ class EventDeleteApiTests(ThreadsApiTestCase):
             "detail": "This thread is closed. You can't delete events in it.",
             "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):
     def test_delete_event_closed_category_no_permission(self):
         """api valdiates if user can delete events in closed categories"""
         """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.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -258,14 +253,9 @@ class EventDeleteApiTests(ThreadsApiTestCase):
             "detail": "This category is closed. You can't delete events in it.",
             "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):
     def test_delete_event(self):
         """api differs posts from events"""
         """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)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
         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 django.urls import reverse
 
 
 from misago.threads import testutils
 from misago.threads import testutils
+from misago.threads.test import patch_category_acl
 
 
 from .test_threads_api import ThreadsApiTestCase
 from .test_threads_api import ThreadsApiTestCase
 
 
@@ -19,8 +20,6 @@ class ThreadPostEditsApiTestCase(ThreadsApiTestCase):
             }
             }
         )
         )
 
 
-        self.override_acl()
-
     def mock_edit_record(self):
     def mock_edit_record(self):
         edits_record = [
         edits_record = [
             self.post.edits_record.create(
             self.post.edits_record.create(
@@ -135,18 +134,19 @@ class ThreadPostPostEditTests(ThreadPostEditsApiTestCase):
         super().setUp()
         super().setUp()
         self.edits = self.mock_edit_record()
         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):
     def test_empty_edit_id(self):
         """api handles empty edit in querystring"""
         """api handles empty edit in querystring"""
         response = self.client.post('%s?edit=' % self.api_link)
         response = self.client.post('%s?edit=' % self.api_link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_category_acl({"can_edit_posts": 2})
     def test_invalid_edit_id(self):
     def test_invalid_edit_id(self):
         """api handles invalid edit in querystring"""
         """api handles invalid edit in querystring"""
         response = self.client.post('%s?edit=dsa67d8sa68' % self.api_link)
         response = self.client.post('%s?edit=dsa67d8sa68' % self.api_link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_category_acl({"can_edit_posts": 2})
     def test_nonexistant_edit_id(self):
     def test_nonexistant_edit_id(self):
         """api handles nonexistant edit in querystring"""
         """api handles nonexistant edit in querystring"""
         response = self.client.post('%s?edit=1321' % self.api_link)
         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))
         response = self.client.post('%s?edit=%s' % (self.api_link, self.edits[0].id))
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
 
 
+    @patch_category_acl({"can_edit_posts": 0})
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates permission to revert post"""
         """api validates permission to revert post"""
-        self.override_acl({'can_edit_posts': 0})
-
         response = self.client.post('%s?edit=1321' % self.api_link)
         response = self.client.post('%s?edit=1321' % self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
 
 
+    @patch_category_acl({"can_edit_posts": 2})
     def test_revert_post(self):
     def test_revert_post(self):
         """api reverts post to version from before specified edit"""
         """api reverts post to version from before specified edit"""
         response = self.client.post('%s?edit=%s' % (self.api_link, self.edits[0].id))
         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 django.urls import reverse
 
 
 from misago.threads import testutils
 from misago.threads import testutils
+from misago.threads.test import patch_category_acl
 from misago.threads.serializers import PostLikeSerializer
 from misago.threads.serializers import PostLikeSerializer
 
 
 from .test_threads_api import ThreadsApiTestCase
 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):
     def test_no_permission(self):
         """api errors if user has no permission to see likes"""
         """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)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEquals(response.json(), {
         self.assertEquals(response.json(), {
             "detail": "You can't see who liked this post."
             "detail": "You can't see who liked this post."
         })
         })
 
 
+    @patch_category_acl({"can_see_posts_likes": 1})
     def test_no_permission_to_list(self):
     def test_no_permission_to_list(self):
         """api errors if user has no permission to see likes, but can see likes count"""
         """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)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEquals(response.json(), {
         self.assertEquals(response.json(), {
             "detail": "You can't see who liked this post."
             "detail": "You can't see who liked this post."
         })
         })
 
 
+    @patch_category_acl({"can_see_posts_likes": 2})
     def test_no_likes(self):
     def test_no_likes(self):
         """api returns empty list if post has no likes"""
         """api returns empty list if post has no likes"""
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), [])
         self.assertEqual(response.json(), [])
 
 
+    @patch_category_acl({"can_see_posts_likes": 2})
     def test_likes(self):
     def test_likes(self):
         """api returns list of likes"""
         """api returns list of likes"""
         like = testutils.like_post(self.post, self.user)
         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 django.urls import reverse
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.readtracker import poststracker
 from misago.readtracker import poststracker
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Post, Thread
 from misago.threads.models import Post, Thread
 from misago.threads.serializers.moderation import POSTS_LIMIT
 from misago.threads.serializers.moderation import POSTS_LIMIT
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 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):
     def test_anonymous_user(self):
         """you need to authenticate to merge posts"""
         """you need to authenticate to merge posts"""
         self.logout_user()
         self.logout_user()
@@ -61,10 +39,9 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "This action is not available to guests.",
             "detail": "This action is not available to guests.",
         })
         })
 
 
+    @patch_category_acl({"can_merge_posts": False})
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates permission to merge"""
         """api validates permission to merge"""
-        self.override_acl({'can_merge_posts': 0})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({}),
             json.dumps({}),
@@ -75,6 +52,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "You can't merge posts in this thread.",
             "detail": "You can't merge posts in this thread.",
         })
         })
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_empty_data_json(self):
     def test_empty_data_json(self):
         """api handles empty json data"""
         """api handles empty json data"""
         response = self.client.post(
         response = self.client.post(
@@ -85,6 +63,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to select at least two posts to merge.",
             "detail": "You have to select at least two posts to merge.",
         })
         })
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_empty_data_form(self):
     def test_empty_data_form(self):
         """api handles empty form data"""
         """api handles empty form data"""
         response = self.client.post(self.api_link, {})
         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.",
             "detail": "You have to select at least two posts to merge.",
         })
         })
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_invalid_data(self):
     def test_invalid_data(self):
         """api handles post that is invalid type"""
         """api handles post that is invalid type"""
-        self.override_acl()
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "Invalid data. Expected a dictionary, but got list.",
             "detail": "Invalid data. Expected a dictionary, but got list.",
         })
         })
 
 
-        self.override_acl()
         response = self.client.post(self.api_link, '123', content_type="application/json")
         response = self.client.post(self.api_link, '123', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "Invalid data. Expected a dictionary, but got int.",
             "detail": "Invalid data. Expected a dictionary, but got int.",
         })
         })
 
 
-        self.override_acl()
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "Invalid data. Expected a dictionary, but got str.",
             "detail": "Invalid data. Expected a dictionary, but got str.",
         })
         })
 
 
-        self.override_acl()
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
             "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):
     def test_no_posts_ids(self):
         """api rejects no posts ids"""
         """api rejects no posts ids"""
         response = self.client.post(
         response = self.client.post(
@@ -137,6 +114,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to select at least two posts to merge.",
             "detail": "You have to select at least two posts to merge.",
         })
         })
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_invalid_posts_data(self):
     def test_invalid_posts_data(self):
         """api handles invalid data"""
         """api handles invalid data"""
         response = self.client.post(
         response = self.client.post(
@@ -151,6 +129,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": 'Expected a list of items but got type "str".',
             "detail": 'Expected a list of items but got type "str".',
         })
         })
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_invalid_posts_ids(self):
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
         """api handles invalid post id"""
         response = self.client.post(
         response = self.client.post(
@@ -165,6 +144,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more post ids received were invalid.",
             "detail": "One or more post ids received were invalid.",
         })
         })
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_one_post_id(self):
     def test_one_post_id(self):
         """api rejects one post id"""
         """api rejects one post id"""
         response = self.client.post(
         response = self.client.post(
@@ -179,6 +159,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to select at least two posts to merge.",
             "detail": "You have to select at least two posts to merge.",
         })
         })
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_limit(self):
     def test_merge_limit(self):
         """api rejects more posts than merge limit"""
         """api rejects more posts than merge limit"""
         response = self.client.post(
         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,
             "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):
     def test_merge_event(self):
         """api recjects events"""
         """api recjects events"""
         event = testutils.reply_thread(self.thread, is_event=True, poster=self.user)
         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.",
             "detail": "Events can't be merged.",
         })
         })
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_notfound_pk(self):
     def test_merge_notfound_pk(self):
         """api recjects nonexistant pk's"""
         """api recjects nonexistant pk's"""
         response = self.client.post(
         response = self.client.post(
@@ -223,6 +206,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more posts to merge could not be found.",
             "detail": "One or more posts to merge could not be found.",
         })
         })
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_cross_threads(self):
     def test_merge_cross_threads(self):
         """api recjects attempt to merge with post made in other thread"""
         """api recjects attempt to merge with post made in other thread"""
         other_thread = testutils.post_thread(category=self.category)
         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.",
             "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):
     def test_merge_authenticated_with_guest_post(self):
         """api recjects attempt to merge with post made by deleted user"""
         """api recjects attempt to merge with post made by deleted user"""
         other_post = testutils.reply_thread(self.thread)
         other_post = testutils.reply_thread(self.thread)
@@ -256,6 +241,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "Posts made by different users can't be merged.",
             "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):
     def test_merge_guest_with_authenticated_post(self):
         """api recjects attempt to merge with post made by deleted user"""
         """api recjects attempt to merge with post made by deleted user"""
         other_post = testutils.reply_thread(self.thread)
         other_post = testutils.reply_thread(self.thread)
@@ -272,6 +258,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "Posts made by different users can't be merged.",
             "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):
     def test_merge_guest_posts_different_usernames(self):
         """api recjects attempt to merge posts made by different guests"""
         """api recjects attempt to merge posts made by different guests"""
         response = self.client.post(
         response = self.client.post(
@@ -289,10 +276,9 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "Posts made by different users can't be merged.",
             "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):
     def test_merge_different_visibility(self):
         """api recjects attempt to merge posts with different visibility"""
         """api recjects attempt to merge posts with different visibility"""
-        self.override_acl({'can_hide_posts': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
@@ -308,10 +294,9 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "Posts with different visibility can't be merged.",
             "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):
     def test_merge_different_approval(self):
         """api recjects attempt to merge posts with different approval"""
         """api recjects attempt to merge posts with different approval"""
-        self.override_acl({'can_approve_content': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
@@ -327,7 +312,8 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "Posts with different visibility can't be merged.",
             "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"""
         """api validates permission to merge in closed thread"""
         self.thread.is_closed = True
         self.thread.is_closed = True
         self.thread.save()
         self.thread.save()
@@ -347,8 +333,16 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "This thread is closed. You can't merge posts in it.",
             "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(
         response = self.client.post(
             self.api_link,
             self.api_link,
@@ -357,7 +351,8 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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"""
         """api validates permission to merge in closed category"""
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         self.category.save()
@@ -377,8 +372,16 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             "detail": "This category is closed. You can't merge posts in it.",
             "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(
         response = self.client.post(
             self.api_link,
             self.api_link,
@@ -387,6 +390,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_best_answer_first_post(self):
     def test_merge_best_answer_first_post(self):
         """api recjects attempt to merge best_answer with first post"""
         """api recjects attempt to merge best_answer with first post"""
         self.thread.first_post.poster = self.user
         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.",
             "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):
     def test_merge_posts(self):
         """api merges two posts"""
         """api merges two posts"""
         post_a = testutils.reply_thread(self.thread, poster=self.user, message="Battęry")
         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.assertEqual(response.status_code, 200)
 
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, thread_replies - 1)
         self.assertEqual(self.thread.replies, thread_replies - 1)
 
 
         with self.assertRaises(Post.DoesNotExist):
         with self.assertRaises(Post.DoesNotExist):
@@ -438,6 +443,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         merged_post = Post.objects.get(pk=post_a.pk)
         merged_post = Post.objects.get(pk=post_a.pk)
         self.assertEqual(merged_post.parsed, '%s\n%s' % (post_a.parsed, post_b.parsed))
         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):
     def test_merge_guest_posts(self):
         """api recjects attempt to merge posts made by same guest"""
         """api recjects attempt to merge posts made by same guest"""
         response = self.client.post(
         response = self.client.post(
@@ -452,10 +458,9 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
+    @patch_category_acl({"can_merge_posts": True, 'can_hide_posts': 1})
     def test_merge_hidden_posts(self):
     def test_merge_hidden_posts(self):
         """api merges two hidden posts"""
         """api merges two hidden posts"""
-        self.override_acl({'can_hide_posts': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
@@ -468,10 +473,9 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
+    @patch_category_acl({"can_merge_posts": True, 'can_approve_content': True})
     def test_merge_unapproved_posts(self):
     def test_merge_unapproved_posts(self):
         """api merges two unapproved posts"""
         """api merges two unapproved posts"""
-        self.override_acl({'can_approve_content': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
@@ -484,6 +488,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
+    @patch_category_acl({"can_merge_posts": True, 'can_hide_threads': True})
     def test_merge_with_hidden_thread(self):
     def test_merge_with_hidden_thread(self):
         """api excludes thread's first post from visibility checks"""
         """api excludes thread's first post from visibility checks"""
         self.thread.first_post.is_hidden = True
         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)
         post_visible = testutils.reply_thread(self.thread, poster=self.user, is_hidden=False)
 
 
-        self.override_acl({'can_hide_threads': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
@@ -503,6 +506,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_protected(self):
     def test_merge_protected(self):
         """api preserves protected status after merge"""
         """api preserves protected status after merge"""
         response = self.client.post(
         response = self.client.post(
@@ -520,6 +524,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         merged_post = self.thread.post_set.order_by('-id')[0]
         merged_post = self.thread.post_set.order_by('-id')[0]
         self.assertTrue(merged_post.is_protected)
         self.assertTrue(merged_post.is_protected)
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_best_answer(self):
     def test_merge_best_answer(self):
         """api merges best answer with other post"""
         """api merges best answer with other post"""
         best_answer = testutils.reply_thread(self.thread, poster="Bob")
         best_answer = testutils.reply_thread(self.thread, poster="Bob")
@@ -539,9 +544,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.best_answer, best_answer)
         self.assertEqual(self.thread.best_answer, best_answer)
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_best_answer_in(self):
     def test_merge_best_answer_in(self):
         """api merges best answer into other post"""
         """api merges best answer into other post"""
         other_post = testutils.reply_thread(self.thread, poster="Bob")
         other_post = testutils.reply_thread(self.thread, poster="Bob")
@@ -562,9 +568,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.best_answer, other_post)
         self.assertEqual(self.thread.best_answer, other_post)
 
 
+    @patch_category_acl({"can_merge_posts": True})
     def test_merge_best_answer_in_protected(self):
     def test_merge_best_answer_in_protected(self):
         """api merges best answer into protected post"""
         """api merges best answer into protected post"""
         best_answer = testutils.reply_thread(self.thread, poster="Bob")
         best_answer = testutils.reply_thread(self.thread, poster="Bob")
@@ -584,11 +591,14 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.best_answer, best_answer)
         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)
         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):
     def test_merge_remove_reads(self):
         """two posts merge removes read tracker from post"""
         """two posts merge removes read tracker from post"""
         post_a = testutils.reply_thread(self.thread, poster=self.user, message="Battęry")
         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 django.urls import reverse
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.readtracker import poststracker
 from misago.readtracker import poststracker
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Thread
 from misago.threads.models import Thread
 from misago.threads.serializers.moderation import POSTS_LIMIT
 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
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -25,66 +25,14 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         )
         )
 
 
         Category(
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other category',
+            slug='other-category',
         ).insert_at(
         ).insert_at(
             self.category,
             self.category,
             position='last-child',
             position='last-child',
             save=True,
             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):
     def test_anonymous_user(self):
         """you need to authenticate to move posts"""
         """you need to authenticate to move posts"""
@@ -96,46 +44,43 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "This action is not available to guests.",
             "detail": "This action is not available to guests.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_data(self):
     def test_invalid_data(self):
         """api handles post that is invalid type"""
         """api handles post that is invalid type"""
-        self.override_acl()
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "Invalid data. Expected a dictionary, but got list.",
             "detail": "Invalid data. Expected a dictionary, but got list.",
         })
         })
 
 
-        self.override_acl()
         response = self.client.post(self.api_link, '123', content_type="application/json")
         response = self.client.post(self.api_link, '123', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "Invalid data. Expected a dictionary, but got int.",
             "detail": "Invalid data. Expected a dictionary, but got int.",
         })
         })
 
 
-        self.override_acl()
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "Invalid data. Expected a dictionary, but got str.",
             "detail": "Invalid data. Expected a dictionary, but got str.",
         })
         })
 
 
-        self.override_acl()
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
             "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": False})
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates permission to move"""
         """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")
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't move posts in this thread.",
             "detail": "You can't move posts in this thread.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_move_no_new_thread_url(self):
     def test_move_no_new_thread_url(self):
         """api validates if new thread url was given"""
         """api validates if new thread url was given"""
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
@@ -144,6 +89,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "Enter link to new thread.",
             "detail": "Enter link to new thread.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_new_thread_url(self):
     def test_invalid_new_thread_url(self):
         """api validates new thread url"""
         """api validates new thread url"""
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
@@ -154,6 +100,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "This is not a valid thread link.",
             "detail": "This is not a valid thread link.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_current_new_thread_url(self):
     def test_current_new_thread_url(self):
         """api validates if new thread url points to current thread"""
         """api validates if new thread url points to current thread"""
         response = self.client.post(
         response = self.client.post(
@@ -166,16 +113,14 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "Thread to move posts to is same as current one.",
             "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):
     def test_other_thread_exists(self):
         """api validates if other thread exists"""
         """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, {
         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.status_code, 400)
         self.assertEqual(response.json(), {
         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):
     def test_other_thread_is_invisible(self):
         """api validates if other thread is visible"""
         """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(
         response = self.client.post(
             self.api_link, {
             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):
     def test_other_thread_isnt_replyable(self):
         """api validates if other thread can be replied"""
         """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(
         response = self.client.post(
             self.api_link, {
             self.api_link, {
@@ -220,6 +165,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "You can't move posts to threads you can't reply.",
             "detail": "You can't move posts to threads you can't reply.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_empty_data(self):
     def test_empty_data(self):
         """api handles empty data"""
         """api handles empty data"""
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
@@ -230,6 +176,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "Enter link to new thread.",
             "detail": "Enter link to new thread.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_empty_posts_data_json(self):
     def test_empty_posts_data_json(self):
         """api handles empty json data"""
         """api handles empty json data"""
         other_thread = testutils.post_thread(self.category)
         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.",
             "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):
     def test_empty_posts_data_form(self):
         """api handles empty form data"""
         """api handles empty form data"""
         other_thread = testutils.post_thread(self.category)
         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.",
             "detail": "You have to specify at least one post to move.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_no_posts_ids(self):
     def test_no_posts_ids(self):
         """api rejects no posts ids"""
         """api rejects no posts ids"""
         other_thread = testutils.post_thread(self.category)
         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.",
             "detail": "You have to specify at least one post to move.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_posts_data(self):
     def test_invalid_posts_data(self):
         """api handles invalid data"""
         """api handles invalid data"""
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
@@ -295,6 +245,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": 'Expected a list of items but got type "str".',
             "detail": 'Expected a list of items but got type "str".',
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_posts_ids(self):
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
         """api handles invalid post id"""
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
@@ -312,6 +263,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more post ids received were invalid.",
             "detail": "One or more post ids received were invalid.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_move_limit(self):
     def test_move_limit(self):
         """api rejects more posts than move limit"""
         """api rejects more posts than move limit"""
         other_thread = testutils.post_thread(self.category)
         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,
             "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):
     def test_move_invisible(self):
         """api validates posts visibility"""
         """api validates posts visibility"""
         other_thread = testutils.post_thread(self.category)
         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.",
             "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):
     def test_move_other_thread_posts(self):
         """api recjects attempt to move other thread's post"""
         """api recjects attempt to move other thread's post"""
         other_thread = testutils.post_thread(self.category)
         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.",
             "detail": "One or more posts to move could not be found.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_move_event(self):
     def test_move_event(self):
         """api rejects events move"""
         """api rejects events move"""
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
@@ -380,6 +335,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "Events can't be moved.",
             "detail": "Events can't be moved.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_move_first_post(self):
     def test_move_first_post(self):
         """api rejects first post move"""
         """api rejects first post move"""
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
@@ -397,6 +353,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "You can't move thread's first post.",
             "detail": "You can't move thread's first post.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_move_hidden_posts(self):
     def test_move_hidden_posts(self):
         """api recjects attempt to move urneadable hidden post"""
         """api recjects attempt to move urneadable hidden post"""
         other_thread = testutils.post_thread(self.category)
         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.",
             "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):
     def test_move_posts_closed_thread_no_permission(self):
         """api recjects attempt to move posts from closed thread"""
         """api recjects attempt to move posts from closed thread"""
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
@@ -421,8 +379,6 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         self.thread.is_closed = True
         self.thread.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
-        self.override_acl({'can_close_threads': 0})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
@@ -436,16 +392,15 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "This thread is closed. You can't move posts in it.",
             "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):
     def test_move_posts_closed_category_no_permission(self):
         """api recjects attempt to move posts from closed thread"""
         """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.is_closed = True
         self.category.save()
         self.category.save()
 
 
-        self.override_acl({'can_close_threads': 0})
-        self.override_other_acl({'can_reply_threads': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
@@ -459,11 +414,11 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             "detail": "This category is closed. You can't move posts in it.",
             "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):
     def test_move_posts(self):
         """api moves posts to other thread"""
         """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 = (
         posts = (
             testutils.reply_thread(self.thread).pk,
             testutils.reply_thread(self.thread).pk,
@@ -472,7 +427,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             testutils.reply_thread(self.thread).pk,
             testutils.reply_thread(self.thread).pk,
         )
         )
 
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 4)
         self.assertEqual(self.thread.replies, 4)
 
 
         response = self.client.post(
         response = self.client.post(
@@ -486,25 +441,25 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         # replies were moved
         # replies were moved
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 0)
         self.assertEqual(self.thread.replies, 0)
 
 
         other_thread = Thread.objects.get(pk=other_thread.pk)
         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.post_set.filter(pk__in=posts).count(), 4)
         self.assertEqual(other_thread.replies, 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):
     def test_move_best_answer(self):
         """api moves best answer to other thread"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
 
 
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.synchronize()
         self.thread.synchronize()
         self.thread.save()
         self.thread.save()
 
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.best_answer, best_answer)
         self.assertEqual(self.thread.best_answer, best_answer)
         self.assertEqual(self.thread.replies, 1)
         self.assertEqual(self.thread.replies, 1)
 
 
@@ -519,7 +474,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         # best_answer was moved and unmarked
         # best_answer was moved and unmarked
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 0)
         self.assertEqual(self.thread.replies, 0)
         self.assertIsNone(self.thread.best_answer)
         self.assertIsNone(self.thread.best_answer)
 
 
@@ -527,18 +482,19 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(other_thread.replies, 1)
         self.assertEqual(other_thread.replies, 1)
         self.assertIsNone(other_thread.best_answer)
         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):
     def test_move_posts_reads(self):
         """api moves posts reads together with posts"""
         """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 = (
         posts = (
             testutils.reply_thread(self.thread),
             testutils.reply_thread(self.thread),
             testutils.reply_thread(self.thread),
             testutils.reply_thread(self.thread),
         )
         )
 
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 2)
         self.assertEqual(self.thread.replies, 2)
 
 
         poststracker.save_read(self.user, self.thread.first_post)
         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.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Thread, Post
 from misago.threads.models import Thread, Post
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -30,27 +30,6 @@ class ThreadPostPatchApiTestCase(AuthenticatedUserTestCase):
     def patch(self, api_link, ops):
     def patch(self, api_link, ops):
         return self.client.patch(api_link, json.dumps(ops), content_type="application/json")
         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):
 class PostAddAclApiTests(ThreadPostPatchApiTestCase):
     def test_add_acl_true(self):
     def test_add_acl_true(self):
@@ -83,10 +62,9 @@ class PostAddAclApiTests(ThreadPostPatchApiTestCase):
 
 
 
 
 class PostProtectApiTests(ThreadPostPatchApiTestCase):
 class PostProtectApiTests(ThreadPostPatchApiTestCase):
+    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': True})
     def test_protect_post(self):
     def test_protect_post(self):
         """api makes it possible to protect post"""
         """api makes it possible to protect post"""
-        self.override_acl({'can_protect_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -101,16 +79,15 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         reponse_json = response.json()
         self.assertTrue(reponse_json['is_protected'])
         self.assertTrue(reponse_json['is_protected'])
 
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_protected)
         self.assertTrue(self.post.is_protected)
 
 
+    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': True})
     def test_unprotect_post(self):
     def test_unprotect_post(self):
         """api makes it possible to unprotect protected post"""
         """api makes it possible to unprotect protected post"""
         self.post.is_protected = True
         self.post.is_protected = True
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'can_protect_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -125,17 +102,16 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         reponse_json = response.json()
         self.assertFalse(reponse_json['is_protected'])
         self.assertFalse(reponse_json['is_protected'])
 
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_protected)
         self.assertFalse(self.post.is_protected)
 
 
+    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': True})
     def test_protect_best_answer(self):
     def test_protect_best_answer(self):
         """api makes it possible to protect post"""
         """api makes it possible to protect post"""
         self.thread.set_best_answer(self.user, self.post)
         self.thread.set_best_answer(self.user, self.post)
         self.thread.save()
         self.thread.save()
 
 
         self.assertFalse(self.thread.best_answer_is_protected)
         self.assertFalse(self.thread.best_answer_is_protected)
-        
-        self.override_acl({'can_protect_posts': 1})
 
 
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
@@ -151,12 +127,13 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         reponse_json = response.json()
         self.assertTrue(reponse_json['is_protected'])
         self.assertTrue(reponse_json['is_protected'])
 
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_protected)
         self.assertTrue(self.post.is_protected)
 
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertTrue(self.thread.best_answer_is_protected)
         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):
     def test_unprotect_best_answer(self):
         """api makes it possible to unprotect protected post"""
         """api makes it possible to unprotect protected post"""
         self.post.is_protected = True
         self.post.is_protected = True
@@ -167,8 +144,6 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
 
 
         self.assertTrue(self.thread.best_answer_is_protected)
         self.assertTrue(self.thread.best_answer_is_protected)
 
 
-        self.override_acl({'can_protect_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -183,16 +158,15 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         reponse_json = response.json()
         self.assertFalse(reponse_json['is_protected'])
         self.assertFalse(reponse_json['is_protected'])
 
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_protected)
         self.assertFalse(self.post.is_protected)
 
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertFalse(self.thread.best_answer_is_protected)
         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):
     def test_protect_post_no_permission(self):
         """api validates permission to protect post"""
         """api validates permission to protect post"""
-        self.override_acl({'can_protect_posts': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -207,16 +181,15 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't protect posts in this category.")
         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)
         self.assertFalse(self.post.is_protected)
 
 
+    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': False})
     def test_unprotect_post_no_permission(self):
     def test_unprotect_post_no_permission(self):
         """api validates permission to unprotect post"""
         """api validates permission to unprotect post"""
         self.post.is_protected = True
         self.post.is_protected = True
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'can_protect_posts': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -231,13 +204,12 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't protect posts in this category.")
         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)
         self.assertTrue(self.post.is_protected)
 
 
+    @patch_category_acl({'can_edit_posts': 0, 'can_protect_posts': True})
     def test_protect_post_not_editable(self):
     def test_protect_post_not_editable(self):
         """api validates if we can edit post we want to protect"""
         """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(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -252,16 +224,15 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't protect posts you can't edit.")
         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)
         self.assertFalse(self.post.is_protected)
 
 
+    @patch_category_acl({'can_edit_posts': 0, 'can_protect_posts': True})
     def test_unprotect_post_not_editable(self):
     def test_unprotect_post_not_editable(self):
         """api validates if we can edit post we want to protect"""
         """api validates if we can edit post we want to protect"""
         self.post.is_protected = True
         self.post.is_protected = True
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'can_edit_posts': 0, 'can_protect_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -276,18 +247,17 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't protect posts you can't edit.")
         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)
         self.assertTrue(self.post.is_protected)
 
 
 
 
 class PostApproveApiTests(ThreadPostPatchApiTestCase):
 class PostApproveApiTests(ThreadPostPatchApiTestCase):
+    @patch_category_acl({'can_approve_content': True})
     def test_approve_post(self):
     def test_approve_post(self):
         """api makes it possible to approve post"""
         """api makes it possible to approve post"""
         self.post.is_unapproved = True
         self.post.is_unapproved = True
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -302,13 +272,12 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         reponse_json = response.json()
         self.assertFalse(reponse_json['is_unapproved'])
         self.assertFalse(reponse_json['is_unapproved'])
 
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_unapproved)
         self.assertFalse(self.post.is_unapproved)
 
 
+    @patch_category_acl({'can_approve_content': True})
     def test_unapprove_post(self):
     def test_unapprove_post(self):
         """unapproving posts is not supported by api"""
         """unapproving posts is not supported by api"""
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -323,16 +292,15 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "Content approval can't be reversed.")
         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)
         self.assertFalse(self.post.is_unapproved)
 
 
+    @patch_category_acl({'can_approve_content': False})
     def test_approve_post_no_permission(self):
     def test_approve_post_no_permission(self):
         """api validates approval permission"""
         """api validates approval permission"""
         self.post.is_unapproved = True
         self.post.is_unapproved = True
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'can_approve_content': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -347,9 +315,10 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't approve posts in this category.")
         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)
         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):
     def test_approve_post_closed_thread_no_permission(self):
         """api validates approval permission in closed threads"""
         """api validates approval permission in closed threads"""
         self.post.is_unapproved = True
         self.post.is_unapproved = True
@@ -358,11 +327,6 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
-        self.override_acl({
-            'can_approve_content': 1,
-            'can_close_threads': 0,
-        })
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -380,9 +344,10 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
             "This thread is closed. You can't approve posts in it.",
             "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)
         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):
     def test_approve_post_closed_category_no_permission(self):
         """api validates approval permission in closed categories"""
         """api validates approval permission in closed categories"""
         self.post.is_unapproved = True
         self.post.is_unapproved = True
@@ -391,11 +356,6 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         self.category.save()
 
 
-        self.override_acl({
-            'can_approve_content': 1,
-            'can_close_threads': 0,
-        })
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -413,9 +373,10 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
             "This category is closed. You can't approve posts in it.",
             "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)
         self.assertTrue(self.post.is_unapproved)
 
 
+    @patch_category_acl({'can_approve_content': True})
     def test_approve_first_post(self):
     def test_approve_first_post(self):
         """api approve first post fails"""
         """api approve first post fails"""
         self.post.is_unapproved = True
         self.post.is_unapproved = True
@@ -424,8 +385,6 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.thread.set_first_post(self.post)
         self.thread.set_first_post(self.post)
         self.thread.save()
         self.thread.save()
 
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -440,17 +399,16 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't approve thread's first post.")
         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)
         self.assertTrue(self.post.is_unapproved)
 
 
+    @patch_category_acl({'can_approve_content': True})
     def test_approve_hidden_post(self):
     def test_approve_hidden_post(self):
         """api approve hidden post fails"""
         """api approve hidden post fails"""
         self.post.is_unapproved = True
         self.post.is_unapproved = True
         self.post.is_hidden = True
         self.post.is_hidden = True
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             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."
             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)
         self.assertTrue(self.post.is_unapproved)
 
 
 
 
 class PostHideApiTests(ThreadPostPatchApiTestCase):
 class PostHideApiTests(ThreadPostPatchApiTestCase):
+    @patch_category_acl({'can_hide_posts': 1})
     def test_hide_post(self):
     def test_hide_post(self):
         """api makes it possible to hide post"""
         """api makes it possible to hide post"""
-        self.override_acl({'can_hide_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -490,13 +447,12 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         reponse_json = response.json()
         self.assertTrue(reponse_json['is_hidden'])
         self.assertTrue(reponse_json['is_hidden'])
 
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
         self.assertTrue(self.post.is_hidden)
 
 
+    @patch_category_acl({'can_hide_posts': 1})
     def test_hide_own_post(self):
     def test_hide_own_post(self):
         """api makes it possible to hide owned post"""
         """api makes it possible to hide owned post"""
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -511,13 +467,12 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         reponse_json = response.json()
         self.assertTrue(reponse_json['is_hidden'])
         self.assertTrue(reponse_json['is_hidden'])
 
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
         self.assertTrue(self.post.is_hidden)
 
 
+    @patch_category_acl({'can_hide_posts': 0})
     def test_hide_post_no_permission(self):
     def test_hide_post_no_permission(self):
         """api hide post with no permission fails"""
         """api hide post with no permission fails"""
-        self.override_acl({'can_hide_posts': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -532,16 +487,15 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't hide posts in this category.")
         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)
         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):
     def test_hide_own_protected_post(self):
         """api validates if we are trying to hide protected post"""
         """api validates if we are trying to hide protected post"""
         self.post.is_protected = True
         self.post.is_protected = True
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'can_protect_posts': 0, 'can_hide_own_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -556,16 +510,15 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "This post is protected. You can't hide it.")
         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)
         self.assertFalse(self.post.is_hidden)
 
 
+    @patch_category_acl({'can_hide_own_posts': True})
     def test_hide_other_user_post(self):
     def test_hide_other_user_post(self):
         """api validates post ownership when hiding"""
         """api validates post ownership when hiding"""
         self.post.poster = None
         self.post.poster = None
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -582,16 +535,15 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "You can't hide other users posts in this category."
             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)
         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):
     def test_hide_own_post_after_edit_time(self):
         """api validates if we are trying to hide post after edit time"""
         """api validates if we are trying to hide post after edit time"""
         self.post.posted_on = timezone.now() - timedelta(minutes=10)
         self.post.posted_on = timezone.now() - timedelta(minutes=10)
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'post_edit_time': 1, 'can_hide_own_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             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."
             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)
         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):
     def test_hide_post_in_closed_thread(self):
         """api validates if we are trying to hide post in closed thread"""
         """api validates if we are trying to hide post in closed thread"""
         self.thread.is_closed = True
         self.thread.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             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."
             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)
         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):
     def test_hide_post_in_closed_category(self):
         """api validates if we are trying to hide post in closed category"""
         """api validates if we are trying to hide post in closed category"""
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         self.category.save()
 
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             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."
             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)
         self.assertFalse(self.post.is_hidden)
 
 
+    @patch_category_acl({'can_hide_posts': 1})
     def test_hide_first_post(self):
     def test_hide_first_post(self):
         """api hide first post fails"""
         """api hide first post fails"""
         self.thread.set_first_post(self.post)
         self.thread.set_first_post(self.post)
         self.thread.save()
         self.thread.save()
 
 
-        self.override_acl({'can_hide_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -684,13 +633,12 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't hide thread's first post.")
         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):
     def test_hide_best_answer(self):
         """api hide first post fails"""
         """api hide first post fails"""
         self.thread.set_best_answer(self.user, self.post)
         self.thread.set_best_answer(self.user, self.post)
         self.thread.save()
         self.thread.save()
 
 
-        self.override_acl({'can_hide_posts': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -708,16 +656,15 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
 
 
 
 
 class PostUnhideApiTests(ThreadPostPatchApiTestCase):
 class PostUnhideApiTests(ThreadPostPatchApiTestCase):
+    @patch_category_acl({'can_hide_posts': 1})
     def test_show_post(self):
     def test_show_post(self):
         """api makes it possible to unhide post"""
         """api makes it possible to unhide post"""
         self.post.is_hidden = True
         self.post.is_hidden = True
         self.post.save()
         self.post.save()
 
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
         self.assertTrue(self.post.is_hidden)
 
 
-        self.override_acl({'can_hide_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -732,19 +679,18 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         reponse_json = response.json()
         self.assertFalse(reponse_json['is_hidden'])
         self.assertFalse(reponse_json['is_hidden'])
 
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
         self.assertFalse(self.post.is_hidden)
 
 
+    @patch_category_acl({'can_hide_own_posts': 1})
     def test_show_own_post(self):
     def test_show_own_post(self):
         """api makes it possible to unhide owned post"""
         """api makes it possible to unhide owned post"""
         self.post.is_hidden = True
         self.post.is_hidden = True
         self.post.save()
         self.post.save()
 
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
         self.assertTrue(self.post.is_hidden)
 
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -759,19 +705,18 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         reponse_json = response.json()
         reponse_json = response.json()
         self.assertFalse(reponse_json['is_hidden'])
         self.assertFalse(reponse_json['is_hidden'])
 
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
         self.assertFalse(self.post.is_hidden)
 
 
+    @patch_category_acl({'can_hide_posts': 0})
     def test_show_post_no_permission(self):
     def test_show_post_no_permission(self):
         """api unhide post with no permission fails"""
         """api unhide post with no permission fails"""
         self.post.is_hidden = True
         self.post.is_hidden = True
         self.post.save()
         self.post.save()
 
 
-        self.refresh_post()
+        self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
         self.assertTrue(self.post.is_hidden)
 
 
-        self.override_acl({'can_hide_posts': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -786,16 +731,15 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['detail'][0], "You can't reveal posts in this category.")
         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)
         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):
     def test_show_own_protected_post(self):
         """api validates if we are trying to reveal protected post"""
         """api validates if we are trying to reveal protected post"""
         self.post.is_hidden = True
         self.post.is_hidden = True
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'can_protect_posts': 0, 'can_hide_own_posts': 1})
-
         self.post.is_protected = True
         self.post.is_protected = True
         self.post.save()
         self.post.save()
 
 
@@ -815,17 +759,16 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "This post is protected. You can't reveal it."
             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)
         self.assertTrue(self.post.is_hidden)
 
 
+    @patch_category_acl({'can_hide_own_posts': 1})
     def test_show_other_user_post(self):
     def test_show_other_user_post(self):
         """api validates post ownership when revealing"""
         """api validates post ownership when revealing"""
         self.post.is_hidden = True
         self.post.is_hidden = True
         self.post.poster = None
         self.post.poster = None
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -842,17 +785,16 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
             response_json['detail'][0], "You can't reveal other users posts in this category."
             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)
         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):
     def test_show_own_post_after_edit_time(self):
         """api validates if we are trying to reveal post after edit time"""
         """api validates if we are trying to reveal post after edit time"""
         self.post.is_hidden = True
         self.post.is_hidden = True
         self.post.posted_on = timezone.now() - timedelta(minutes=10)
         self.post.posted_on = timezone.now() - timedelta(minutes=10)
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'post_edit_time': 1, 'can_hide_own_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             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."
             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)
         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):
     def test_show_post_in_closed_thread(self):
         """api validates if we are trying to reveal post in closed thread"""
         """api validates if we are trying to reveal post in closed thread"""
         self.thread.is_closed = True
         self.thread.is_closed = True
@@ -880,8 +823,6 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         self.post.is_hidden = True
         self.post.is_hidden = True
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             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."
             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)
         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):
     def test_show_post_in_closed_category(self):
         """api validates if we are trying to reveal post in closed category"""
         """api validates if we are trying to reveal post in closed category"""
         self.category.is_closed = True
         self.category.is_closed = True
@@ -909,8 +851,6 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         self.post.is_hidden = True
         self.post.is_hidden = True
         self.post.save()
         self.post.save()
 
 
-        self.override_acl({'can_hide_own_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             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."
             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)
         self.assertTrue(self.post.is_hidden)
 
 
+    @patch_category_acl({'can_hide_posts': 1})
     def test_show_first_post(self):
     def test_show_first_post(self):
         """api unhide first post fails"""
         """api unhide first post fails"""
         self.thread.set_first_post(self.post)
         self.thread.set_first_post(self.post)
         self.thread.save()
         self.thread.save()
 
 
-        self.override_acl({'can_hide_posts': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -953,10 +892,9 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
 
 
 
 
 class PostLikeApiTests(ThreadPostPatchApiTestCase):
 class PostLikeApiTests(ThreadPostPatchApiTestCase):
+    @patch_category_acl({'can_see_posts_likes': 0})
     def test_like_no_see_permission(self):
     def test_like_no_see_permission(self):
         """api validates user's permission to see posts likes"""
         """api validates user's permission to see posts likes"""
-        self.override_acl({'can_see_posts_likes': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -972,10 +910,9 @@ class PostLikeApiTests(ThreadPostPatchApiTestCase):
             "detail": ["You can't like posts in this category."],
             "detail": ["You can't like posts in this category."],
         })
         })
 
 
+    @patch_category_acl({'can_like_posts': False})
     def test_like_no_like_permission(self):
     def test_like_no_like_permission(self):
         """api validates user's permission to see posts likes"""
         """api validates user's permission to see posts likes"""
-        self.override_acl({'can_like_posts': False})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1213,10 +1150,9 @@ class EventAddAclApiTests(ThreadEventPatchApiTestCase):
 
 
 
 
 class EventHideApiTests(ThreadEventPatchApiTestCase):
 class EventHideApiTests(ThreadEventPatchApiTestCase):
+    @patch_category_acl({'can_hide_events': 1})
     def test_hide_event(self):
     def test_hide_event(self):
         """api makes it possible to hide event"""
         """api makes it possible to hide event"""
-        self.override_acl({'can_hide_events': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1228,19 +1164,18 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertTrue(self.event.is_hidden)
         self.assertTrue(self.event.is_hidden)
 
 
+    @patch_category_acl({'can_hide_events': 1})
     def test_show_event(self):
     def test_show_event(self):
         """api makes it possible to unhide event"""
         """api makes it possible to unhide event"""
         self.event.is_hidden = True
         self.event.is_hidden = True
         self.event.save()
         self.event.save()
 
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertTrue(self.event.is_hidden)
         self.assertTrue(self.event.is_hidden)
 
 
-        self.override_acl({'can_hide_events': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1252,13 +1187,12 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertFalse(self.event.is_hidden)
         self.assertFalse(self.event.is_hidden)
 
 
+    @patch_category_acl({'can_hide_events': 0})
     def test_hide_event_no_permission(self):
     def test_hide_event_no_permission(self):
         """api hide event with no permission fails"""
         """api hide event with no permission fails"""
-        self.override_acl({'can_hide_events': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1275,16 +1209,12 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
             response_json['detail'][0], "You can't hide events in this category."
             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)
         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):
     def test_hide_event_closed_thread_no_permission(self):
         """api hide event in closed thread with no permission fails"""
         """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.is_closed = True
         self.thread.save()
         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."
             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)
         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):
     def test_hide_event_closed_category_no_permission(self):
         """api hide event in closed category with no permission fails"""
         """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.is_closed = True
         self.category.save()
         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."
             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)
         self.assertFalse(self.event.is_hidden)
 
 
+    @patch_category_acl({'can_hide_events': 0})
     def test_show_event_no_permission(self):
     def test_show_event_no_permission(self):
         """api unhide event with no permission fails"""
         """api unhide event with no permission fails"""
         self.event.is_hidden = True
         self.event.is_hidden = True
         self.event.save()
         self.event.save()
 
 
-        self.refresh_event()
+        self.event.refresh_from_db()
         self.assertTrue(self.event.is_hidden)
         self.assertTrue(self.event.is_hidden)
 
 
-        self.override_acl({'can_hide_events': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link, [
             self.api_link, [
                 {
                 {
@@ -1357,16 +1282,12 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         )
         )
         self.assertEqual(response.status_code, 404)
         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):
     def test_show_event_closed_thread_no_permission(self):
         """api show event in closed thread with no permission fails"""
         """api show event in closed thread with no permission fails"""
         self.event.is_hidden = True
         self.event.is_hidden = True
         self.event.save()
         self.event.save()
 
 
-        self.override_acl({
-            'can_hide_events': 1,
-            'can_close_threads': 0,
-        })
-
         self.thread.is_closed = True
         self.thread.is_closed = True
         self.thread.save()
         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."
             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)
         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):
     def test_show_event_closed_category_no_permission(self):
         """api show event in closed category with no permission fails"""
         """api show event in closed category with no permission fails"""
         self.event.is_hidden = True
         self.event.is_hidden = True
         self.event.save()
         self.event.save()
 
 
-        self.override_acl({
-            'can_hide_events': 1,
-            'can_close_threads': 0,
-        })
-
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         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."
             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)
         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 django.urls import reverse
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.readtracker import poststracker
 from misago.readtracker import poststracker
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Post, Thread
 from misago.threads.models import Post, Thread
 from misago.threads.serializers.moderation import POSTS_LIMIT
 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
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -29,66 +29,14 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         )
         )
 
 
         Category(
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other category',
+            slug='other-category',
         ).insert_at(
         ).insert_at(
             self.category,
             self.category,
             position='last-child',
             position='last-child',
             save=True,
             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):
     def test_anonymous_user(self):
         """you need to authenticate to split posts"""
         """you need to authenticate to split posts"""
@@ -100,16 +48,16 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "This action is not available to guests.",
             "detail": "This action is not available to guests.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": False})
     def test_no_permission(self):
     def test_no_permission(self):
         """api validates permission to split"""
         """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")
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't split posts from this thread.",
             "detail": "You can't split posts from this thread.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_empty_data(self):
     def test_empty_data(self):
         """api handles empty data"""
         """api handles empty data"""
         response = self.client.post(self.api_link)
         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.",
             "detail": "You have to specify at least one post to split.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_data(self):
     def test_invalid_data(self):
         """api handles post that is invalid type"""
         """api handles post that is invalid type"""
-        self.override_acl()
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         response = self.client.post(self.api_link, '[]', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "non_field_errors": ["Invalid data. Expected a dictionary, but got list."],
             "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")
         response = self.client.post(self.api_link, '123', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "non_field_errors": ["Invalid data. Expected a dictionary, but got int."],
             "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")
         response = self.client.post(self.api_link, '"string"', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "non_field_errors": ["Invalid data. Expected a dictionary, but got str."],
             "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")
         response = self.client.post(self.api_link, 'malformed', content_type="application/json")
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
             "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):
     def test_no_posts_ids(self):
         """api rejects no posts ids"""
         """api rejects no posts ids"""
         response = self.client.post(
         response = self.client.post(
@@ -159,6 +105,8 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You have to specify at least one post to split.",
             "detail": "You have to specify at least one post to split.",
         })
         })
+
+    @patch_category_acl({"can_move_posts": True})
     def test_empty_posts_ids(self):
     def test_empty_posts_ids(self):
         """api rejects empty posts ids list"""
         """api rejects empty posts ids list"""
         response = self.client.post(
         response = self.client.post(
@@ -173,6 +121,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "You have to specify at least one post to split.",
             "detail": "You have to specify at least one post to split.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_posts_data(self):
     def test_invalid_posts_data(self):
         """api handles invalid data"""
         """api handles invalid data"""
         response = self.client.post(
         response = self.client.post(
@@ -187,6 +136,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": 'Expected a list of items but got type "str".',
             "detail": 'Expected a list of items but got type "str".',
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_invalid_posts_ids(self):
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
         """api handles invalid post id"""
         response = self.client.post(
         response = self.client.post(
@@ -201,6 +151,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more post ids received were invalid.",
             "detail": "One or more post ids received were invalid.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_limit(self):
     def test_split_limit(self):
         """api rejects more posts than split limit"""
         """api rejects more posts than split limit"""
         response = self.client.post(
         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,
             "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):
     def test_split_invisible(self):
         """api validates posts visibility"""
         """api validates posts visibility"""
         response = self.client.post(
         response = self.client.post(
@@ -229,6 +181,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "One or more posts to split could not be found.",
             "detail": "One or more posts to split could not be found.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_event(self):
     def test_split_event(self):
         """api rejects events split"""
         """api rejects events split"""
         response = self.client.post(
         response = self.client.post(
@@ -243,6 +196,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "Events can't be split.",
             "detail": "Events can't be split.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_first_post(self):
     def test_split_first_post(self):
         """api rejects first post split"""
         """api rejects first post split"""
         response = self.client.post(
         response = self.client.post(
@@ -257,6 +211,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "You can't split thread's first post.",
             "detail": "You can't split thread's first post.",
         })
         })
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_hidden_posts(self):
     def test_split_hidden_posts(self):
         """api recjects attempt to split urneadable hidden post"""
         """api recjects attempt to split urneadable hidden post"""
         response = self.client.post(
         response = self.client.post(
@@ -271,13 +226,12 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "You can't split posts the content you can't see.",
             "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):
     def test_split_posts_closed_thread_no_permission(self):
         """api recjects attempt to split posts from closed thread"""
         """api recjects attempt to split posts from closed thread"""
         self.thread.is_closed = True
         self.thread.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
-        self.override_acl({'can_close_threads': 0})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
@@ -290,13 +244,12 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "This thread is closed. You can't split posts in it.",
             "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):
     def test_split_posts_closed_category_no_permission(self):
         """api recjects attempt to split posts from closed thread"""
         """api recjects attempt to split posts from closed thread"""
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         self.category.save()
 
 
-        self.override_acl({'can_close_threads': 0})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
@@ -309,6 +262,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             "detail": "This category is closed. You can't split posts in it.",
             "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):
     def test_split_other_thread_posts(self):
         """api recjects attempt to split other thread's post"""
         """api recjects attempt to split other thread's post"""
         other_thread = testutils.post_thread(self.category)
         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.",
             "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):
     def test_split_empty_new_thread_data(self):
         """api handles empty form data"""
         """api handles empty form data"""
         response = self.client.post(
         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):
     def test_split_invalid_final_title(self):
         """api rejects split because final thread title was invalid"""
         """api rejects split because final thread title was invalid"""
         response = self.client.post(
         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):
     def test_split_invalid_category(self):
         """api rejects split because final category was invalid"""
         """api rejects split because final category was invalid"""
-        self.override_other_acl({'can_see': 0})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
                 'posts': self.posts,
                 'posts': self.posts,
                 'title': 'Valid thread title',
                 'title': 'Valid thread title',
-                'category': self.category_b.id,
+                'category': self.other_category.id,
             }),
             }),
             content_type="application/json",
             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):
     def test_split_unallowed_start_thread(self):
         """api rejects split because category isn't allowing starting threads"""
         """api rejects split because category isn't allowing starting threads"""
-        self.override_acl({'can_start_threads': 0})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
@@ -408,6 +363,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split_invalid_weight(self):
     def test_split_invalid_weight(self):
         """api rejects split because final weight was invalid"""
         """api rejects split because final weight was invalid"""
         response = self.client.post(
         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):
     def test_split_unallowed_global_weight(self):
         """api rejects split because global weight was unallowed"""
         """api rejects split because global weight was unallowed"""
         response = self.client.post(
         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):
     def test_split_unallowed_local_weight(self):
         """api rejects split because local weight was unallowed"""
         """api rejects split because local weight was unallowed"""
         response = self.client.post(
         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):
     def test_split_allowed_local_weight(self):
         """api allows local weight"""
         """api allows local weight"""
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             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):
     def test_split_allowed_global_weight(self):
         """api allows global weight"""
         """api allows global weight"""
-        self.override_acl({'can_pin_threads': 2})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             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):
     def test_split_unallowed_close(self):
         """api rejects split because closing thread was unallowed"""
         """api rejects split because closing thread was unallowed"""
         response = self.client.post(
         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):
     def test_split_with_close(self):
         """api allows for closing thread"""
         """api allows for closing thread"""
-        self.override_acl({'can_close_threads': True})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             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):
     def test_split_unallowed_hidden(self):
         """api rejects split because hidden thread was unallowed"""
         """api rejects split because hidden thread was unallowed"""
         response = self.client.post(
         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):
     def test_split_with_hide(self):
         """api allows for hiding thread"""
         """api allows for hiding thread"""
-        self.override_acl({'can_hide_threads': True})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
@@ -607,9 +563,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_move_posts": True})
     def test_split(self):
     def test_split(self):
         """api splits posts to new thread"""
         """api splits posts to new thread"""
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 2)
         self.assertEqual(self.thread.replies, 2)
 
 
         response = self.client.post(
         response = self.client.post(
@@ -628,12 +585,13 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(split_thread.replies, 1)
         self.assertEqual(split_thread.replies, 1)
 
 
         # posts were removed from old thread
         # posts were removed from old thread
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 0)
         self.assertEqual(self.thread.replies, 0)
 
 
         # posts were moved to new thread
         # posts were moved to new thread
         self.assertEqual(split_thread.post_set.filter(pk__in=self.posts).count(), 2)
         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):
     def test_split_best_answer(self):
         """api splits best answer to new thread"""
         """api splits best answer to new thread"""
         best_answer = testutils.reply_thread(self.thread)
         best_answer = testutils.reply_thread(self.thread)
@@ -642,7 +600,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.thread.synchronize()
         self.thread.synchronize()
         self.thread.save()
         self.thread.save()
 
 
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.best_answer, best_answer)
         self.assertEqual(self.thread.best_answer, best_answer)
         self.assertEqual(self.thread.replies, 3)
         self.assertEqual(self.thread.replies, 3)
 
 
@@ -658,7 +616,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         # best_answer was moved and unmarked
         # best_answer was moved and unmarked
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 2)
         self.assertEqual(self.thread.replies, 2)
         self.assertIsNone(self.thread.best_answer)
         self.assertIsNone(self.thread.best_answer)
 
 
@@ -666,18 +624,18 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(split_thread.replies, 0)
         self.assertEqual(split_thread.replies, 0)
         self.assertIsNone(split_thread.best_answer)
         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):
     def test_split_kitchensink(self):
         """api splits posts with kitchensink"""
         """api splits posts with kitchensink"""
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 2)
         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)
         poststracker.save_read(self.user, self.thread.first_post)
         for post in self.posts:
         for post in self.posts:
             poststracker.save_read(self.user, Post.objects.select_related().get(pk=post))
             poststracker.save_read(self.user, Post.objects.select_related().get(pk=post))
@@ -687,7 +645,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             json.dumps({
             json.dumps({
                 'posts': self.posts,
                 'posts': self.posts,
                 'title': 'Split thread',
                 'title': 'Split thread',
-                'category': self.category_b.id,
+                'category': self.other_category.id,
                 'weight': 2,
                 'weight': 2,
                 'is_closed': 1,
                 'is_closed': 1,
                 'is_hidden': 1,
                 'is_hidden': 1,
@@ -697,14 +655,14 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         # thread was created
         # 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.replies, 1)
         self.assertEqual(split_thread.weight, 2)
         self.assertEqual(split_thread.weight, 2)
         self.assertTrue(split_thread.is_closed)
         self.assertTrue(split_thread.is_closed)
         self.assertTrue(split_thread.is_hidden)
         self.assertTrue(split_thread.is_hidden)
 
 
         # posts were removed from old thread
         # posts were removed from old thread
-        self.refresh_thread()
+        self.thread.refresh_from_db()
         self.assertEqual(self.thread.replies, 0)
         self.assertEqual(self.thread.replies, 0)
 
 
         # posts were moved to new thread
         # 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 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.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Thread
 from misago.threads.models import Thread
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 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):
     def test_cant_reply_thread_as_guest(self):
         """user has to be authenticated to be able to post reply"""
         """user has to be authenticated to be able to post reply"""
         self.logout_user()
         self.logout_user()
@@ -43,32 +30,30 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
 
 
     def test_thread_visibility(self):
     def test_thread_visibility(self):
         """thread's visibility is validated"""
         """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):
     def test_cant_reply_thread(self):
         """permission to reply thread is validated"""
         """permission to reply thread is validated"""
-        self.override_acl({'can_reply_threads': 0})
-
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't reply to threads in this category.",
             "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"""
         """permssion to reply in closed category is validated"""
-        self.override_acl({'can_close_threads': 0})
-
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -78,16 +63,18 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
             "detail": "This category is closed. You can't reply to threads in it.",
             "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)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
         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"""
         """permssion to reply in closed thread is validated"""
-        self.override_acl({'can_close_threads': 0})
-
         self.thread.is_closed = True
         self.thread.is_closed = True
         self.thread.save()
         self.thread.save()
 
 
@@ -97,26 +84,27 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
             "detail": "You can't reply to closed threads in this category.",
             "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)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_empty_data(self):
     def test_empty_data(self):
         """no data sent handling has no showstoppers"""
         """no data sent handling has no showstoppers"""
-        self.override_acl()
-
         response = self.client.post(self.api_link, data={})
         response = self.client.post(self.api_link, data={})
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "post": ["You have to enter a message."],
             "post": ["You have to enter a message."],
         })
         })
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_invalid_data(self):
     def test_invalid_data(self):
         """api errors for invalid request data"""
         """api errors for invalid request data"""
-        self.override_acl()
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             'false',
             'false',
@@ -127,10 +115,9 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
             'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
             'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
         })
         })
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_post_is_validated(self):
     def test_post_is_validated(self):
         """post is validated"""
         """post is validated"""
-        self.override_acl()
-
         response = self.client.post(
         response = self.client.post(
             self.api_link, data={
             self.api_link, data={
                 'post': "a",
                 'post': "a",
@@ -144,9 +131,9 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_can_reply_thread(self):
     def test_can_reply_thread(self):
         """endpoint creates new reply"""
         """endpoint creates new reply"""
-        self.override_acl()
         response = self.client.post(
         response = self.client.post(
             self.api_link, data={
             self.api_link, data={
                 'post': "This is test response!",
                 'post': "This is test response!",
@@ -156,7 +143,6 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
 
 
         thread = Thread.objects.get(pk=self.thread.pk)
         thread = Thread.objects.get(pk=self.thread.pk)
 
 
-        self.override_acl()
         response = self.client.get(self.thread.get_absolute_url())
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, "<p>This is test response!</p>")
         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_name, self.user.username)
         self.assertEqual(category.last_poster_slug, self.user.slug)
         self.assertEqual(category.last_poster_slug, self.user.slug)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_post_unicode(self):
     def test_post_unicode(self):
         """unicode characters can be posted"""
         """unicode characters can be posted"""
-        self.override_acl()
-
         response = self.client.post(
         response = self.client.post(
             self.api_link, data={
             self.api_link, data={
                 'post': "Chrzążczyżewoszyce, powiat Łękółody.",
                 'post': "Chrzążczyżewoszyce, powiat Łękółody.",
@@ -198,6 +183,7 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_category_moderation_queue(self):
     def test_category_moderation_queue(self):
         """reply thread in category that requires approval"""
         """reply thread in category that requires approval"""
         self.category.require_replies_approval = True
         self.category.require_replies_approval = True
@@ -222,10 +208,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts)
         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):
     def test_category_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         """bypass moderation queue due to user's acl"""
-        override_acl(self.user, {'can_approve_content': 1})
-
         self.category.require_replies_approval = True
         self.category.require_replies_approval = True
         self.category.save()
         self.category.save()
 
 
@@ -248,10 +234,9 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts + 1)
         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):
     def test_user_moderation_queue(self):
         """reply thread by user that requires approval"""
         """reply thread by user that requires approval"""
-        self.override_acl({'require_replies_approval': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link, data={
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
                 'post': "Lorem ipsum dolor met!",
@@ -271,12 +256,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts)
         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):
     def test_user_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         """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(
         response = self.client.post(
             self.api_link, data={
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
                 'post': "Lorem ipsum dolor met!",
@@ -296,17 +279,17 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts + 1)
         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):
     def test_omit_other_moderation_queues(self):
         """other queues are omitted"""
         """other queues are omitted"""
         self.category.require_threads_approval = True
         self.category.require_threads_approval = True
         self.category.require_edits_approval = True
         self.category.require_edits_approval = True
         self.category.save()
         self.category.save()
 
 
-        self.override_acl({
-            'require_threads_approval': 1,
-            'require_edits_approval': 1,
-        })
-
         response = self.client.post(
         response = self.client.post(
             self.api_link, data={
             self.api_link, data={
                 'post': "Lorem ipsum dolor met!",
                 '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 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.models import Category
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -12,30 +13,6 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.category = Category.objects.get(slug='first-category')
         self.category = Category.objects.get(slug='first-category')
         self.api_link = reverse('misago:api:thread-list')
         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):
     def test_cant_start_thread_as_guest(self):
         """user has to be authenticated to be able to post thread"""
         """user has to be authenticated to be able to post thread"""
         self.logout_user()
         self.logout_user()
@@ -43,10 +20,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link)
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
 
 
+    @patch_category_acl({"can_see": False})
     def test_cant_see(self):
     def test_cant_see(self):
         """has no permission to see selected category"""
         """has no permission to see selected category"""
-        self.override_acl({'can_see': 0})
-
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'category': self.category.pk,
             'category': self.category.pk,
         })
         })
@@ -57,10 +33,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'title': ['You have to enter thread title.'],
             'title': ['You have to enter thread title.'],
         })
         })
 
 
+    @patch_category_acl({"can_browse": False})
     def test_cant_browse(self):
     def test_cant_browse(self):
         """has no permission to browse selected category"""
         """has no permission to browse selected category"""
-        self.override_acl({'can_browse': 0})
-
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'category': self.category.pk,
             'category': self.category.pk,
         })
         })
@@ -71,10 +46,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'title': ['You have to enter thread title.'],
             'title': ['You have to enter thread title.'],
         })
         })
 
 
+    @patch_category_acl({"can_start_threads": False})
     def test_cant_start_thread(self):
     def test_cant_start_thread(self):
         """permission to start thread in category is validated"""
         """permission to start thread in category is validated"""
-        self.override_acl({'can_start_threads': 0})
-
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'category': self.category.pk,
             'category': self.category.pk,
         })
         })
@@ -85,13 +59,12 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'title': ['You have to enter thread title.'],
             '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):
     def test_cant_start_thread_in_locked_category(self):
         """can't post in closed category"""
         """can't post in closed category"""
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         self.category.save()
 
 
-        self.override_acl({'can_close_threads': 0})
-
         response = self.client.post(self.api_link, {
         response = self.client.post(self.api_link, {
             'category': self.category.pk,
             'category': self.category.pk,
         })
         })
@@ -104,11 +77,6 @@ class StartThreadTests(AuthenticatedUserTestCase):
 
 
     def test_cant_start_thread_in_invalid_category(self):
     def test_cant_start_thread_in_invalid_category(self):
         """can't post in invalid category"""
         """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})
         response = self.client.post(self.api_link, {'category': self.category.pk * 100000})
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
@@ -120,10 +88,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'title': ['You have to enter thread title.'],
             'title': ['You have to enter thread title.'],
         })
         })
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_empty_data(self):
     def test_empty_data(self):
         """no data sent handling has no showstoppers"""
         """no data sent handling has no showstoppers"""
-        self.override_acl()
-
         response = self.client.post(self.api_link, data={})
         response = self.client.post(self.api_link, data={})
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
         self.assertEqual(
@@ -134,10 +101,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_invalid_data(self):
     def test_invalid_data(self):
         """api errors for invalid request data"""
         """api errors for invalid request data"""
-        self.override_acl()
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             'false',
             'false',
@@ -148,10 +114,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
             'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
         })
         })
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_title_is_validated(self):
     def test_title_is_validated(self):
         """title is validated"""
         """title is validated"""
-        self.override_acl()
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -168,10 +133,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_post_is_validated(self):
     def test_post_is_validated(self):
         """post is validated"""
         """post is validated"""
-        self.override_acl()
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -188,9 +152,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_can_start_thread(self):
     def test_can_start_thread(self):
         """endpoint creates new thread"""
         """endpoint creates new thread"""
-        self.override_acl()
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -206,7 +170,6 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['url'], thread.get_absolute_url())
         self.assertEqual(response_json['url'], thread.get_absolute_url())
 
 
-        self.override_acl()
         response = self.client.get(thread.get_absolute_url())
         response = self.client.get(thread.get_absolute_url())
         self.assertContains(response, self.category.name)
         self.assertContains(response, self.category.name)
         self.assertContains(response, thread.title)
         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_name, self.user.username)
         self.assertEqual(category.last_poster_slug, self.user.slug)
         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):
     def test_start_closed_thread_no_permission(self):
         """permission is checked before thread is closed"""
         """permission is checked before thread is closed"""
-        self.override_acl({'can_close_threads': 0})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -263,10 +225,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         thread = self.user.thread_set.all()[:1][0]
         self.assertFalse(thread.is_closed)
         self.assertFalse(thread.is_closed)
 
 
+    @patch_category_acl({"can_start_threads": True, "can_close_threads": True})
     def test_start_closed_thread(self):
     def test_start_closed_thread(self):
         """can post closed thread"""
         """can post closed thread"""
-        self.override_acl({'can_close_threads': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -281,10 +242,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         thread = self.user.thread_set.all()[:1][0]
         self.assertTrue(thread.is_closed)
         self.assertTrue(thread.is_closed)
 
 
+    @patch_category_acl({"can_start_threads": True, "can_pin_threads": 1})
     def test_start_unpinned_thread(self):
     def test_start_unpinned_thread(self):
         """can post unpinned thread"""
         """can post unpinned thread"""
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -299,10 +259,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         thread = self.user.thread_set.all()[:1][0]
         self.assertEqual(thread.weight, 0)
         self.assertEqual(thread.weight, 0)
 
 
+    @patch_category_acl({"can_start_threads": True, "can_pin_threads": 1})
     def test_start_locally_pinned_thread(self):
     def test_start_locally_pinned_thread(self):
         """can post locally pinned thread"""
         """can post locally pinned thread"""
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -317,10 +276,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         thread = self.user.thread_set.all()[:1][0]
         self.assertEqual(thread.weight, 1)
         self.assertEqual(thread.weight, 1)
 
 
+    @patch_category_acl({"can_start_threads": True, "can_pin_threads": 2})
     def test_start_globally_pinned_thread(self):
     def test_start_globally_pinned_thread(self):
         """can post globally pinned thread"""
         """can post globally pinned thread"""
-        self.override_acl({'can_pin_threads': 2})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -335,10 +293,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         thread = self.user.thread_set.all()[:1][0]
         self.assertEqual(thread.weight, 2)
         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):
     def test_start_globally_pinned_thread_no_permission(self):
         """cant post globally pinned thread without permission"""
         """cant post globally pinned thread without permission"""
-        self.override_acl({'can_pin_threads': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -353,10 +310,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         thread = self.user.thread_set.all()[:1][0]
         self.assertEqual(thread.weight, 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):
     def test_start_locally_pinned_thread_no_permission(self):
         """cant post locally pinned thread without permission"""
         """cant post locally pinned thread without permission"""
-        self.override_acl({'can_pin_threads': 0})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -371,10 +327,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         thread = self.user.thread_set.all()[:1][0]
         self.assertEqual(thread.weight, 0)
         self.assertEqual(thread.weight, 0)
 
 
+    @patch_category_acl({"can_start_threads": True, "can_hide_threads": 1})
     def test_start_hidden_thread(self):
     def test_start_hidden_thread(self):
         """can post hidden thread"""
         """can post hidden thread"""
-        self.override_acl({'can_hide_threads': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -392,10 +347,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         category = Category.objects.get(pk=self.category.pk)
         category = Category.objects.get(pk=self.category.pk)
         self.assertNotEqual(category.last_thread_id, thread.id)
         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):
     def test_start_hidden_thread_no_permission(self):
         """cant post hidden thread without permission"""
         """cant post hidden thread without permission"""
-        self.override_acl({'can_hide_threads': 0})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -410,10 +364,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         thread = self.user.thread_set.all()[:1][0]
         thread = self.user.thread_set.all()[:1][0]
         self.assertFalse(thread.is_hidden)
         self.assertFalse(thread.is_hidden)
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_post_unicode(self):
     def test_post_unicode(self):
         """unicode characters can be posted"""
         """unicode characters can be posted"""
-        self.override_acl()
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -424,6 +377,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_category_moderation_queue(self):
     def test_category_moderation_queue(self):
         """start unapproved thread in category that requires approval"""
         """start unapproved thread in category that requires approval"""
         self.category.require_threads_approval = True
         self.category.require_threads_approval = True
@@ -451,10 +405,10 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.posts, self.category.posts)
         self.assertEqual(category.posts, self.category.posts)
         self.assertFalse(category.last_thread_id == thread.id)
         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):
     def test_category_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         """bypass moderation queue due to user's acl"""
-        override_acl(self.user, {'can_approve_content': 1})
-
         self.category.require_threads_approval = True
         self.category.require_threads_approval = True
         self.category.save()
         self.category.save()
 
 
@@ -480,10 +434,9 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.posts, self.category.posts + 1)
         self.assertEqual(category.posts, self.category.posts + 1)
         self.assertEqual(category.last_thread_id, thread.id)
         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):
     def test_user_moderation_queue(self):
         """start unapproved thread in category that requires approval"""
         """start unapproved thread in category that requires approval"""
-        self.override_acl({'require_threads_approval': 1})
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -506,12 +459,10 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.posts, self.category.posts)
         self.assertEqual(category.posts, self.category.posts)
         self.assertFalse(category.last_thread_id == thread.id)
         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):
     def test_user_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         """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(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             data={
@@ -534,17 +485,17 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(category.posts, self.category.posts + 1)
         self.assertEqual(category.posts, self.category.posts + 1)
         self.assertEqual(category.last_thread_id, thread.id)
         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):
     def test_omit_other_moderation_queues(self):
         """other queues are omitted"""
         """other queues are omitted"""
         self.category.require_replies_approval = True
         self.category.require_replies_approval = True
         self.category.require_edits_approval = True
         self.category.require_edits_approval = True
         self.category.save()
         self.category.save()
 
 
-        self.override_acl({
-            'require_replies_approval': 1,
-            'require_edits_approval': 1,
-        })
-
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             data={
             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.utils import timezone
 from django.urls import reverse
 from django.urls import reverse
 
 
-from misago.acl.testutils import override_acl
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Thread
 from misago.threads.models import Thread
+from misago.threads.test import patch_category_acl
 from misago.threads.threadtypes import trees_map
 from misago.threads.threadtypes import trees_map
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -24,45 +24,6 @@ class ThreadsApiTestCase(AuthenticatedUserTestCase):
         self.thread = testutils.post_thread(category=self.category)
         self.thread = testutils.post_thread(category=self.category)
         self.api_link = self.thread.get_api_url()
         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):
     def get_thread_json(self):
         response = self.client.get(self.thread.get_api_url())
         response = self.client.get(self.thread.get_api_url())
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
@@ -80,11 +41,10 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
             '%sposts/?page=1' % self.api_link,
             '%sposts/?page=1' % self.api_link,
         ]
         ]
 
 
+    @patch_category_acl()
     def test_api_returns_thread(self):
     def test_api_returns_thread(self):
         """api has no showstoppers"""
         """api has no showstoppers"""
         for link in self.tested_links:
         for link in self.tested_links:
-            self.override_acl()
-
             response = self.client.get(link)
             response = self.client.get(link)
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.status_code, 200)
 
 
@@ -95,11 +55,10 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
             if 'posts' in link:
             if 'posts' in link:
                 self.assertIn('post_set', response_json)
                 self.assertIn('post_set', response_json)
 
 
+    @patch_category_acl({"can_see_all_threads": False})
     def test_api_shows_owned_thread(self):
     def test_api_shows_owned_thread(self):
         """api handles "owned threads only"""
         """api handles "owned threads only"""
         for link in self.tested_links:
         for link in self.tested_links:
-            self.override_acl({'can_see_all_threads': 0})
-
             response = self.client.get(link)
             response = self.client.get(link)
             self.assertEqual(response.status_code, 404)
             self.assertEqual(response.status_code, 404)
 
 
@@ -107,49 +66,41 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
         self.thread.save()
         self.thread.save()
 
 
         for link in self.tested_links:
         for link in self.tested_links:
-            self.override_acl({'can_see_all_threads': 0})
-
             response = self.client.get(link)
             response = self.client.get(link)
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.status_code, 200)
 
 
+    @patch_category_acl({"can_see": False})
     def test_api_validates_category_see_permission(self):
     def test_api_validates_category_see_permission(self):
         """api validates category visiblity"""
         """api validates category visiblity"""
         for link in self.tested_links:
         for link in self.tested_links:
-            self.override_acl({'can_see': 0})
-
             response = self.client.get(link)
             response = self.client.get(link)
             self.assertEqual(response.status_code, 404)
             self.assertEqual(response.status_code, 404)
 
 
+    @patch_category_acl({"can_browse": False})
     def test_api_validates_category_browse_permission(self):
     def test_api_validates_category_browse_permission(self):
         """api validates category browsability"""
         """api validates category browsability"""
         for link in self.tested_links:
         for link in self.tested_links:
-            self.override_acl({'can_browse': 0})
-
             response = self.client.get(link)
             response = self.client.get(link)
             self.assertEqual(response.status_code, 404)
             self.assertEqual(response.status_code, 404)
 
 
     def test_api_validates_posts_visibility(self):
     def test_api_validates_posts_visibility(self):
         """api validates posts visiblity"""
         """api validates posts visiblity"""
-        self.override_acl({'can_hide_posts': 0})
-
         hidden_post = testutils.reply_thread(
         hidden_post = testutils.reply_thread(
             self.thread,
             self.thread,
             is_hidden=True,
             is_hidden=True,
             message="I'am hidden test message!",
             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
         # 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 posts shouldn't show at all
         unapproved_post = testutils.reply_thread(
         unapproved_post = testutils.reply_thread(
@@ -157,41 +108,39 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
             is_unapproved=True,
             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
         # 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):
     def test_api_validates_has_unapproved_posts_visibility(self):
         """api checks acl before exposing unapproved flag"""
         """api checks acl before exposing unapproved flag"""
         self.thread.has_unapproved_posts = True
         self.thread.has_unapproved_posts = True
         self.thread.save()
         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):
 class ThreadDeleteApiTests(ThreadsApiTestCase):
@@ -203,82 +152,68 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
 
 
     def test_delete_thread_no_permission(self):
     def test_delete_thread_no_permission(self):
         """api tests permission to delete threads"""
         """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):
     def test_delete_other_user_thread_no_permission(self):
         """api tests thread owner when deleting own thread"""
         """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)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-
         self.assertEqual(
         self.assertEqual(
             response.json()['detail'], "You can't delete other users theads in this category."
             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):
     def test_delete_thread_closed_category_no_permission(self):
         """api tests category's closed state"""
         """api tests category's closed state"""
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         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)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-
         self.assertEqual(
         self.assertEqual(
             response.json()['detail'], "This category is closed. You can't delete threads in it."
             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):
     def test_delete_thread_closed_no_permission(self):
         """api tests thread's closed state"""
         """api tests thread's closed state"""
         self.last_thread.is_closed = True
         self.last_thread.is_closed = True
         self.last_thread.save()
         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)
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-
         self.assertEqual(
         self.assertEqual(
             response.json()['detail'], "This thread is closed. You can't delete it."
             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):
     def test_delete_owned_thread_no_time(self):
         """api tests permission to delete owned thread within time limit"""
         """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.starter = self.user
         self.last_thread.started_on = timezone.now() - timedelta(minutes=10)
         self.last_thread.started_on = timezone.now() - timedelta(minutes=10)
         self.last_thread.save()
         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."
             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):
     def test_delete_thread(self):
         """DELETE to API link with permission deletes thread"""
         """DELETE to API link with permission deletes thread"""
-        self.override_acl({'can_hide_threads': 2})
-
         category = Category.objects.get(slug='first-category')
         category = Category.objects.get(slug='first-category')
         self.assertEqual(category.last_thread_id, self.last_thread.pk)
         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)
         self.assertEqual(category.last_thread_id, self.thread.pk)
 
 
         # test that last thread's deletion triggers category sync
         # 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())
         response = self.client.delete(self.thread.get_api_url())
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -318,14 +250,13 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
         category = Category.objects.get(slug='first-category')
         category = Category.objects.get(slug='first-category')
         self.assertIsNone(category.last_thread_id)
         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):
     def test_delete_owned_thread(self):
         """api lets owner to delete owned thread within time limit"""
         """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.starter = self.user
         self.last_thread.started_on = timezone.now() - timedelta(minutes=10)
         self.last_thread.started_on = timezone.now() - timedelta(minutes=10)
         self.last_thread.save()
         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 django.urls import reverse
 
 
-from misago.acl.testutils import override_acl
 from misago.categories import PRIVATE_THREADS_ROOT_NAME
 from misago.categories import PRIVATE_THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Thread
 from misago.threads.models import Thread
 from misago.threads.serializers.moderation import THREADS_LIMIT
 from misago.threads.serializers.moderation import THREADS_LIMIT
+from misago.threads.test import patch_category_acl
 from misago.threads.threadtypes import trees_map
 from misago.threads.threadtypes import trees_map
 
 
 from .test_threads_api import ThreadsApiTestCase
 from .test_threads_api import ThreadsApiTestCase
@@ -44,26 +44,17 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "This action is not available to guests.",
             "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):
     def test_delete_no_ids(self):
         """api requires ids to delete"""
         """api requires ids to delete"""
-        self.override_acl({
-            'can_hide_own_threads': 0,
-            'can_hide_threads': 0,
-        })
-
         response = self.delete(self.api_link)
         response = self.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You have to specify at least one thread to delete.",
             "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):
     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)
         response = self.delete(self.api_link, True)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
@@ -82,26 +73,18 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
             "detail": "One or more thread ids received were invalid.",
             "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):
     def test_validate_ids_length(self):
         """api validates that ids are list of ints"""
         """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)))
         response = self.delete(self.api_link, list(range(THREADS_LIMIT + 1)))
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "No more than %s threads can be deleted at single time." % THREADS_LIMIT,
             "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):
     def test_validate_thread_visibility(self):
         """api valdiates if user can see deleted thread"""
         """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 = self.threads[1]
 
 
         unapproved_thread.is_unapproved = True
         unapproved_thread.is_unapproved = True
@@ -119,17 +102,12 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         for thread in self.threads:
         for thread in self.threads:
             Thread.objects.get(pk=thread.pk)
             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):
     def test_delete_other_user_thread_no_permission(self):
         """api valdiates if user can delete other users threads"""
         """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]
         other_thread = self.threads[1]
 
 
         response = self.delete(self.api_link, [p.id for p in self.threads])
         response = self.delete(self.api_link, [p.id for p in self.threads])
-
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), [
         self.assertEqual(response.json(), [
             {
             {
@@ -145,17 +123,16 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         for thread in self.threads:
         for thread in self.threads:
             Thread.objects.get(pk=thread.pk)
             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):
     def test_delete_thread_closed_category_no_permission(self):
         """api tests category's closed state"""
         """api tests category's closed state"""
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         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])
         response = self.delete(self.api_link, [p.id for p in self.threads])
 
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.status_code, 400)
@@ -169,18 +146,17 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
             } for thread in sorted(self.threads, key=lambda i: i.pk)
             } 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):
     def test_delete_thread_closed_no_permission(self):
         """api tests thread's closed state"""
         """api tests thread's closed state"""
         closed_thread = self.threads[1]
         closed_thread = self.threads[1]
         closed_thread.is_closed = True
         closed_thread.is_closed = True
         closed_thread.save()
         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])
         response = self.delete(self.api_link, [p.id for p in self.threads])
 
 
         self.assertEqual(response.status_code, 400)
         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):
     def test_delete_private_thread(self):
         """attempt to delete private thread fails"""
         """attempt to delete private thread fails"""
         private_thread = self.threads[0]
         private_thread = self.threads[0]
@@ -208,11 +185,6 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
             is_owner=True,
             is_owner=True,
         )
         )
 
 
-        self.override_acl({
-            'can_hide_own_threads': 2,
-            'can_hide_threads': 2,
-        })
-
         threads_ids = [p.id for p in self.threads]
         threads_ids = [p.id for p in self.threads]
 
 
         response = self.delete(self.api_link, threads_ids)
         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 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.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Attachment
 from misago.threads.models import Attachment
 from misago.threads.serializers import AttachmentSerializer
 from misago.threads.serializers import AttachmentSerializer
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
 TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
 
 
+cache_versions = {"acl": "abcdefgh"}
+
 
 
 class EditorApiTestCase(AuthenticatedUserTestCase):
 class EditorApiTestCase(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
@@ -21,53 +23,6 @@ class EditorApiTestCase(AuthenticatedUserTestCase):
 
 
         self.category = Category.objects.get(slug='first-category')
         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):
 class ThreadPostEditorApiTests(EditorApiTestCase):
     def setUp(self):
     def setUp(self):
@@ -85,30 +40,27 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
             "detail": "You need to be signed in to start threads.",
             "detail": "You need to be signed in to start threads.",
         })
         })
 
 
+    @patch_category_acl({'can_browse': False})
     def test_category_visibility_validation(self):
     def test_category_visibility_validation(self):
         """endpoint omits non-browseable categories"""
         """endpoint omits non-browseable categories"""
-        self.override_acl({'can_browse': 0})
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "No categories that allow new threads are available to you at the moment.",
             "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):
     def test_category_disallowing_new_threads(self):
         """endpoint omits category disallowing starting threads"""
         """endpoint omits category disallowing starting threads"""
-        self.override_acl({'can_start_threads': 0})
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "No categories that allow new threads are available to you at the moment.",
             "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):
     def test_category_closed_disallowing_new_threads(self):
         """endpoint omits closed category"""
         """endpoint omits closed category"""
-        self.override_acl({'can_start_threads': 2, 'can_close_threads': 0})
-
         self.category.is_closed = True
         self.category.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -118,10 +70,9 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
             "detail": "No categories that allow new threads are available to you at the moment.",
             "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):
     def test_category_closed_allowing_new_threads(self):
         """endpoint adds closed category that allows new threads"""
         """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.is_closed = True
         self.category.save()
         self.category.save()
 
 
@@ -142,10 +93,9 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({'can_start_threads': True})
     def test_category_allowing_new_threads(self):
     def test_category_allowing_new_threads(self):
         """endpoint adds category that allows new threads"""
         """endpoint adds category that allows new threads"""
-        self.override_acl({'can_start_threads': 2})
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -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):
     def test_category_allowing_closing_threads(self):
         """endpoint adds category that allows new closed threads"""
         """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)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         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):
     def test_category_allowing_locally_pinned_threads(self):
         """endpoint adds category that allows locally pinned threads"""
         """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)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         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):
     def test_category_allowing_globally_pinned_threads(self):
         """endpoint adds category that allows globally pinned threads"""
         """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)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         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)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         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)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -260,7 +207,7 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
                 'level': 0,
                 'level': 0,
                 'post': {
                 'post': {
                     'close': False,
                     'close': False,
-                    'hide': True,
+                    'hide': 1,
                     'pin': 0,
                     'pin': 0,
                 },
                 },
             }
             }
@@ -290,22 +237,21 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
 
 
     def test_thread_visibility(self):
     def test_thread_visibility(self):
         """thread's visibility is validated"""
         """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):
     def test_no_reply_permission(self):
         """permssion to reply is validated"""
         """permssion to reply is validated"""
-        self.override_acl({'can_reply_threads': 0})
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
@@ -314,72 +260,63 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
 
 
     def test_closed_category(self):
     def test_closed_category(self):
         """permssion to reply in closed category is validated"""
         """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.is_closed = True
         self.category.save()
         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
         # 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):
     def test_closed_thread(self):
         """permssion to reply in closed thread is validated"""
         """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.is_closed = True
         self.thread.save()
         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
         # 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):
     def test_allow_reply_thread(self):
         """api returns 200 code if thread reply is allowed"""
         """api returns 200 code if thread reply is allowed"""
-        self.override_acl({'can_reply_threads': 1})
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
     def test_reply_to_visibility(self):
     def test_reply_to_visibility(self):
         """api validates replied post visibility"""
         """api validates replied post visibility"""
-        self.override_acl({'can_reply_threads': 1})
 
 
         # unapproved reply can't be replied to
         # 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
         # hidden reply can't be replied to
-        self.override_acl({'can_reply_threads': 1})
-
         hidden_reply = testutils.reply_thread(self.thread, is_hidden=True)
         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):
     def test_reply_to_other_thread_post(self):
         """api validates is replied post belongs to same thread"""
         """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))
         response = self.client.get('%s?reply=%s' % (self.api_link, reply_to.pk))
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_category_acl({'can_reply_threads': True})
     def test_reply_to_event(self):
     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)
         reply_to = testutils.reply_thread(self.thread, is_event=True)
 
 
         response = self.client.get('%s?reply=%s' % (self.api_link, reply_to.pk))
         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.",
             "detail": "You can't reply to events.",
         })
         })
 
 
+    @patch_category_acl({'can_reply_threads': True})
     def test_reply_to(self):
     def test_reply_to(self):
         """api includes replied to post details in response"""
         """api includes replied to post details in response"""
-        self.override_acl({'can_reply_threads': 1})
-
         reply_to = testutils.reply_thread(self.thread)
         reply_to = testutils.reply_thread(self.thread)
 
 
         response = self.client.get('%s?reply=%s' % (self.api_link, reply_to.pk))
         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):
     def test_thread_visibility(self):
         """thread's visibility is validated"""
         """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):
     def test_no_edit_permission(self):
         """permssion to edit is validated"""
         """permssion to edit is validated"""
-        self.override_acl({'can_edit_posts': 0})
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
@@ -470,103 +404,90 @@ class EditReplyEditorApiTests(EditorApiTestCase):
 
 
     def test_closed_category(self):
     def test_closed_category(self):
         """permssion to edit in closed category is validated"""
         """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.is_closed = True
         self.category.save()
         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
         # 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):
     def test_closed_thread(self):
         """permssion to edit in closed thread is validated"""
         """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.is_closed = True
         self.thread.save()
         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
         # 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):
     def test_protected_post(self):
         """permssion to edit protected post is validated"""
         """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.is_protected = True
         self.post.save()
         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
         # 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):
     def test_post_visibility(self):
         """edited posts visibility is validated"""
         """edited posts visibility is validated"""
-        self.override_acl({'can_edit_posts': 1})
-
         self.post.is_hidden = True
         self.post.is_hidden = True
         self.post.save()
         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
         # 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
         # test unapproved post
+        self.post.is_unapproved = True
         self.post.is_hidden = False
         self.post.is_hidden = False
         self.post.poster = None
         self.post.poster = None
         self.post.save()
         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
         # 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):
     def test_post_is_event(self):
         """events can't be edited"""
         """events can't be edited"""
-        self.override_acl()
-
         self.post.is_event = True
         self.post.is_event = True
         self.post.save()
         self.post.save()
 
 
@@ -578,27 +499,24 @@ class EditReplyEditorApiTests(EditorApiTestCase):
 
 
     def test_other_user_post(self):
     def test_other_user_post(self):
         """api validates if other user's post can be edited"""
         """api validates if other user's post can be edited"""
-        self.override_acl({'can_edit_posts': 1})
-
         self.post.poster = None
         self.post.poster = None
         self.post.save()
         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
         # 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):
     def test_edit_first_post_hidden(self):
         """endpoint returns valid configuration for editor of hidden thread's first post"""
         """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.is_hidden = True
         self.thread.save()
         self.thread.save()
         self.thread.first_post.is_hidden = True
         self.thread.first_post.is_hidden = True
@@ -615,18 +533,18 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         response = self.client.get(api_link)
         response = self.client.get(api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
+    @patch_category_acl({'can_edit_posts': 1})
     def test_edit(self):
     def test_edit(self):
         """endpoint returns valid configuration for editor"""
         """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'))
         attachments = list(Attachment.objects.order_by('id'))
 
 
@@ -637,11 +555,10 @@ class EditReplyEditorApiTests(EditorApiTestCase):
             attachment.post = self.post
             attachment.post = self.post
             attachment.save()
             attachment.save()
 
 
-        self.override_acl({'can_edit_posts': 1})
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
-
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
         for attachment in attachments:
         for attachment in attachments:
-            add_acl(self.user, attachment)
+            add_acl_to_obj(user_acl, attachment)
 
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
         self.assertEqual(

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

@@ -2,17 +2,20 @@ import json
 
 
 from django.urls import reverse
 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.categories.models import Category
 from misago.readtracker import poststracker
 from misago.readtracker import poststracker
 from misago.threads import testutils
 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.models import Poll, PollVote, Post, Thread
 from misago.threads.serializers import ThreadsListSerializer
 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
 from .test_threads_api import ThreadsApiTestCase
 
 
+cache_versions = {"acl": "abcdefgh"}
+
 
 
 class ThreadsMergeApiTests(ThreadsApiTestCase):
 class ThreadsMergeApiTests(ThreadsApiTestCase):
     def setUp(self):
     def setUp(self):
@@ -20,40 +23,14 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.api_link = reverse('misago:api:thread-merge')
         self.api_link = reverse('misago:api:thread-merge')
 
 
         Category(
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other Category',
+            slug='other-category',
         ).insert_at(
         ).insert_at(
             self.category,
             self.category,
             position='last-child',
             position='last-child',
             save=True,
             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):
     def test_merge_no_threads(self):
         """api validates if we are trying to merge no threads"""
         """api validates if we are trying to merge no threads"""
@@ -143,7 +120,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
 
     def test_merge_with_invisible_thread(self):
     def test_merge_with_invisible_thread(self):
         """api validates if we are trying to merge with inaccesible thread"""
         """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(
         response = self.client.post(
             self.api_link,
             self.api_link,
@@ -166,7 +143,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
-                'category': self.category.pk,
+                'category': self.category.id,
                 'title': 'Lorem ipsum dolor',
                 'title': 'Lorem ipsum dolor',
                 'threads': [self.thread.id, thread.id],
                 '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):
     def test_thread_category_is_closed(self):
         """api validates if thread's category is open"""
         """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)
         other_thread = testutils.post_thread(self.category)
 
 
         self.category.is_closed = True
         self.category.is_closed = True
@@ -204,7 +177,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
-                'category': self.category_b.pk,
+                'category': self.other_category.id,
                 'title': 'Lorem ipsum dolor',
                 'title': 'Lorem ipsum dolor',
                 'threads': [self.thread.id, other_thread.id],
                 '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):
     def test_thread_is_closed(self):
         """api validates if thread is open"""
         """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 = testutils.post_thread(self.category)
 
 
         other_thread.is_closed = True
         other_thread.is_closed = True
@@ -240,7 +209,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
-                'category': self.category_b.pk,
+                'category': self.other_category.id,
                 'title': 'Lorem ipsum dolor',
                 'title': 'Lorem ipsum dolor',
                 'threads': [self.thread.id, other_thread.id],
                 '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):
     def test_merge_too_many_threads(self):
         """api rejects too many threads to merge"""
         """api rejects too many threads to merge"""
         threads = []
         threads = []
         for _ in range(THREADS_LIMIT + 1):
         for _ in range(THREADS_LIMIT + 1):
             threads.append(testutils.post_thread(category=self.category).pk)
             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(
         response = self.client.post(
             self.api_link,
             self.api_link,
             json.dumps({
             json.dumps({
@@ -282,15 +245,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_no_final_thread(self):
     def test_merge_no_final_thread(self):
         """api rejects merge because no data to merge threads was specified"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         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):
     def test_merge_invalid_final_title(self):
         """api rejects merge because final thread title was invalid"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         response = self.client.post(
@@ -335,15 +286,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_invalid_category(self):
     def test_merge_invalid_category(self):
         """api rejects merge because final category was invalid"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         response = self.client.post(
@@ -351,7 +296,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             json.dumps({
             json.dumps({
                 'threads': [self.thread.id, thread.id],
                 'threads': [self.thread.id, thread.id],
                 'title': 'Valid thread title',
                 'title': 'Valid thread title',
-                'category': self.category_b.id,
+                'category': self.other_category.id,
             }),
             }),
             content_type="application/json",
             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):
     def test_merge_unallowed_start_thread(self):
         """api rejects merge because category isn't allowing starting threads"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         response = self.client.post(
@@ -390,15 +328,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_invalid_weight(self):
     def test_merge_invalid_weight(self):
         """api rejects merge because final weight was invalid"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         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):
     def test_merge_unallowed_global_weight(self):
         """api rejects merge because global weight was unallowed"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         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):
     def test_merge_unallowed_local_weight(self):
         """api rejects merge because local weight was unallowed"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         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):
     def test_merge_allowed_local_weight(self):
         """api allows local weight"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         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):
     def test_merge_allowed_global_weight(self):
         """api allows global weight"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         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):
     def test_merge_unallowed_close(self):
         """api rejects merge because closing thread was unallowed"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         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):
     def test_merge_with_close(self):
         """api allows for closing thread"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         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):
     def test_merge_unallowed_hidden(self):
         """api rejects merge because hidden thread was unallowed"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         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):
     def test_merge_with_hide(self):
         """api allows for hiding thread"""
         """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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         response = self.client.post(
@@ -648,17 +528,10 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge(self):
     def test_merge(self):
         """api performs basic merge"""
         """api performs basic merge"""
         posts_ids = [p.id for p in Post.objects.all()]
         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)
         thread = testutils.post_thread(category=self.category)
 
 
         response = self.client.post(
         response = self.client.post(
@@ -679,8 +552,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread.is_read = False
         new_thread.is_read = False
         new_thread.subscription = None
         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)
         self.assertEqual(response_json, ThreadsListSerializer(new_thread).data)
 
 
@@ -691,17 +565,15 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         # are old threads gone?
         # are old threads gone?
         self.assertEqual([t.pk for t in Thread.objects.all()], [new_thread.pk])
         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):
     def test_merge_kitchensink(self):
         """api performs merge"""
         """api performs merge"""
         posts_ids = [p.id for p in Post.objects.all()]
         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)
         thread = testutils.post_thread(category=self.category)
 
 
         poststracker.save_read(self.user, self.thread.first_post)
         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_closed)
         self.assertTrue(new_thread.is_hidden)
         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)
         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(thread=new_thread)
         self.user.subscription_set.get(category=self.category)
         self.user.subscription_set.get(category=self.category)
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_merged_best_answer(self):
     def test_merge_threads_merged_best_answer(self):
         """api merges two threads successfully, moving best answer to old thread"""
         """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)
         other_thread = testutils.post_thread(self.category)
 
 
         best_answer = testutils.reply_thread(self.thread)
         best_answer = testutils.reply_thread(self.thread)
@@ -797,10 +669,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread = Thread.objects.get(pk=response.json()['id'])
         new_thread = Thread.objects.get(pk=response.json()['id'])
         self.assertEqual(new_thread.best_answer_id, best_answer.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):
     def test_merge_threads_merge_conflict_best_answer(self):
         """api errors on merge conflict, returning list of available best answers"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         self.thread.save()
@@ -835,10 +706,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(
         self.assertEqual(
             Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
             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):
     def test_threads_merge_conflict_best_answer_invalid_resolution(self):
         """api errors on invalid merge conflict resolution"""
         """api errors on invalid merge conflict resolution"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         self.thread.save()
@@ -868,10 +738,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(
         self.assertEqual(
             Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
             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):
     def test_threads_merge_conflict_unmark_all_best_answers(self):
         """api unmarks all best answers when unmark all choice is selected"""
         """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)
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         self.thread.save()
@@ -898,10 +767,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertFalse(new_thread.has_best_answer)
         self.assertFalse(new_thread.has_best_answer)
         self.assertIsNone(new_thread.best_answer_id)
         self.assertIsNone(new_thread.best_answer_id)
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_first_best_answer(self):
     def test_threads_merge_conflict_keep_first_best_answer(self):
         """api unmarks other best answer on merge"""
         """api unmarks other best answer on merge"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         self.thread.save()
@@ -927,10 +795,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread = Thread.objects.get(pk=response.json()['id'])
         new_thread = Thread.objects.get(pk=response.json()['id'])
         self.assertEqual(new_thread.best_answer_id, best_answer.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):
     def test_threads_merge_conflict_keep_other_best_answer(self):
         """api unmarks first best answer on merge"""
         """api unmarks first best answer on merge"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
         self.thread.save()
@@ -956,10 +823,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread = Thread.objects.get(pk=response.json()['id'])
         new_thread = Thread.objects.get(pk=response.json()['id'])
         self.assertEqual(new_thread.best_answer_id, other_best_answer.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):
     def test_merge_threads_kept_poll(self):
         """api merges two threads successfully, keeping poll from other thread"""
         """api merges two threads successfully, keeping poll from other thread"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(other_thread, self.user)
         poll = testutils.post_poll(other_thread, self.user)
 
 
@@ -983,10 +849,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 1)
         self.assertEqual(Poll.objects.count(), 1)
         self.assertEqual(PollVote.objects.count(), 4)
         self.assertEqual(PollVote.objects.count(), 4)
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_moved_poll(self):
     def test_merge_threads_moved_poll(self):
         """api merges two threads successfully, moving poll from old thread"""
         """api merges two threads successfully, moving poll from old thread"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         poll = testutils.post_poll(self.thread, self.user)
 
 
@@ -1010,10 +875,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 1)
         self.assertEqual(Poll.objects.count(), 1)
         self.assertEqual(PollVote.objects.count(), 4)
         self.assertEqual(PollVote.objects.count(), 4)
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_poll(self):
     def test_threads_merge_conflict_poll(self):
         """api errors on merge conflict, returning list of available polls"""
         """api errors on merge conflict, returning list of available polls"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_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(Poll.objects.count(), 2)
         self.assertEqual(PollVote.objects.count(), 8)
         self.assertEqual(PollVote.objects.count(), 8)
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_poll_invalid_resolution(self):
     def test_threads_merge_conflict_poll_invalid_resolution(self):
         """api errors on invalid merge conflict resolution"""
         """api errors on invalid merge conflict resolution"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
 
 
         testutils.post_poll(self.thread, self.user)
         testutils.post_poll(self.thread, self.user)
@@ -1078,10 +941,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 2)
         self.assertEqual(Poll.objects.count(), 2)
         self.assertEqual(PollVote.objects.count(), 8)
         self.assertEqual(PollVote.objects.count(), 8)
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_delete_all_polls(self):
     def test_threads_merge_conflict_delete_all_polls(self):
         """api deletes all polls when delete all choice is selected"""
         """api deletes all polls when delete all choice is selected"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
 
 
         testutils.post_poll(self.thread, self.user)
         testutils.post_poll(self.thread, self.user)
@@ -1103,10 +965,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 0)
         self.assertEqual(Poll.objects.count(), 0)
         self.assertEqual(PollVote.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):
     def test_threads_merge_conflict_keep_first_poll(self):
         """api deletes other poll on merge"""
         """api deletes other poll on merge"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
@@ -1131,10 +992,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         with self.assertRaises(Poll.DoesNotExist):
         with self.assertRaises(Poll.DoesNotExist):
             Poll.objects.get(pk=other_poll.pk)
             Poll.objects.get(pk=other_poll.pk)
 
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_other_poll(self):
     def test_threads_merge_conflict_keep_other_poll(self):
         """api deletes first poll on merge"""
         """api deletes first poll on merge"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_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 import timezone
 from django.utils.encoding import smart_str
 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.categories.models import Category
 from misago.conf import settings
 from misago.conf import settings
 from misago.readtracker import poststracker
 from misago.readtracker import poststracker
@@ -12,10 +12,48 @@ from misago.threads import testutils
 from misago.users.models import AnonymousUser
 from misago.users.models import AnonymousUser
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-
 LISTS_URLS = ('', 'my/', 'new/', 'unread/', 'subscribed/', )
 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):
 class ThreadsListTestCase(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
         """
         """
@@ -120,46 +158,6 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
         self.category_e = Category.objects.get(slug='category-e')
         self.category_e = Category.objects.get(slug='category-e')
         self.category_f = Category.objects.get(slug='category-f')
         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):
     def assertContainsThread(self, response, thread):
         self.assertContains(response, ' href="%s"' % thread.get_absolute_url())
         self.assertContains(response, ' href="%s"' % thread.get_absolute_url())
 
 
@@ -185,11 +183,10 @@ class ApiTests(ThreadsListTestCase):
 
 
 
 
 class AllThreadsListTests(ThreadsListTestCase):
 class AllThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_empty(self):
     def test_list_renders_empty(self):
         """empty threads list renders"""
         """empty threads list renders"""
         for url in LISTS_URLS:
         for url in LISTS_URLS:
-            self.access_all_categories()
-
             response = self.client.get('/' + url)
             response = self.client.get('/' + url)
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.status_code, 200)
             self.assertContains(response, "empty-message")
             self.assertContains(response, "empty-message")
@@ -198,8 +195,6 @@ class AllThreadsListTests(ThreadsListTestCase):
             else:
             else:
                 self.assertContains(response, "There are no threads on this forum")
                 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)
             response = self.client.get(self.category_b.get_absolute_url() + url)
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.status_code, 200)
             self.assertContains(response, self.category_b.name)
             self.assertContains(response, self.category_b.name)
@@ -209,8 +204,6 @@ class AllThreadsListTests(ThreadsListTestCase):
             else:
             else:
                 self.assertContains(response, "There are no threads in this category")
                 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'))
             response = self.client.get('%s?list=%s' % (self.api_link, url.strip('/') or 'all'))
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.status_code, 200)
 
 
@@ -221,46 +214,34 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.logout_user()
         self.logout_user()
         self.user = self.get_anonymous_user()
         self.user = self.get_anonymous_user()
 
 
-        self.access_all_categories()
-
         response = self.client.get('/')
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "empty-message")
         self.assertContains(response, "There are no threads on this forum")
         self.assertContains(response, "There are no threads on this forum")
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_b.get_absolute_url())
         response = self.client.get(self.category_b.get_absolute_url())
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.category_b.name)
         self.assertContains(response, self.category_b.name)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "empty-message")
         self.assertContains(response, "There are no threads in this category")
         self.assertContains(response, "There are no threads in this category")
 
 
-        self.access_all_categories()
-
         response = self.client.get('%s?list=all' % self.api_link)
         response = self.client.get('%s?list=all' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl()
     def test_list_authenticated_only_views(self):
     def test_list_authenticated_only_views(self):
         """authenticated only views return 403 for guests"""
         """authenticated only views return 403 for guests"""
         for url in LISTS_URLS:
         for url in LISTS_URLS:
-            self.access_all_categories()
-
             response = self.client.get('/' + url)
             response = self.client.get('/' + url)
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.status_code, 200)
 
 
-            self.access_all_categories()
-
             response = self.client.get(self.category_b.get_absolute_url() + url)
             response = self.client.get(self.category_b.get_absolute_url() + url)
             self.assertEqual(response.status_code, 200)
             self.assertEqual(response.status_code, 200)
             self.assertContains(response, self.category_b.name)
             self.assertContains(response, self.category_b.name)
 
 
-            self.access_all_categories()
-
-            self.access_all_categories()
             response = self.client.get(
             response = self.client.get(
                 '%s?category=%s&list=%s' %
                 '%s?category=%s&list=%s' %
                 (self.api_link, self.category_b.pk, url.strip('/') or 'all', )
                 (self.api_link, self.category_b.pk, url.strip('/') or 'all', )
@@ -270,22 +251,19 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.logout_user()
         self.logout_user()
         self.user = self.get_anonymous_user()
         self.user = self.get_anonymous_user()
         for url in LISTS_URLS[1:]:
         for url in LISTS_URLS[1:]:
-            self.access_all_categories()
-
             response = self.client.get('/' + url)
             response = self.client.get('/' + url)
             self.assertEqual(response.status_code, 403)
             self.assertEqual(response.status_code, 403)
 
 
-            self.access_all_categories()
             response = self.client.get(self.category_b.get_absolute_url() + url)
             response = self.client.get(self.category_b.get_absolute_url() + url)
             self.assertEqual(response.status_code, 403)
             self.assertEqual(response.status_code, 403)
 
 
-            self.access_all_categories()
             response = self.client.get(
             response = self.client.get(
                 '%s?category=%s&list=%s' %
                 '%s?category=%s&list=%s' %
                 (self.api_link, self.category_b.pk, url.strip('/') or 'all', )
                 (self.api_link, self.category_b.pk, url.strip('/') or 'all', )
             )
             )
             self.assertEqual(response.status_code, 403)
             self.assertEqual(response.status_code, 403)
 
 
+    @patch_categories_acl()
     def test_list_renders_categories_picker(self):
     def test_list_renders_categories_picker(self):
         """categories picker renders valid categories"""
         """categories picker renders valid categories"""
         Category(
         Category(
@@ -316,7 +294,6 @@ class AllThreadsListTests(ThreadsListTestCase):
         # hidden category
         # hidden category
         self.assertNotContains(response, 'subcategory-%s' % test_category.css_class)
         self.assertNotContains(response, 'subcategory-%s' % test_category.css_class)
 
 
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -325,11 +302,8 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.assertNotIn(self.category_b.pk, response_json['subcategories'])
         self.assertNotIn(self.category_b.pk, response_json['subcategories'])
 
 
         # test category view
         # test category view
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url())
         response = self.client.get(self.category_a.get_absolute_url())
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
-
         self.assertContains(response, 'subcategory-%s' % self.category_b.css_class)
         self.assertContains(response, 'subcategory-%s' % self.category_b.css_class)
 
 
         # readable categories, but non-accessible directly
         # 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_d.css_class)
         self.assertNotContains(response, 'subcategory-%s' % self.category_f.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))
         response = self.client.get('%s?category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -462,7 +435,7 @@ class CategoryThreadsListTests(ThreadsListTestCase):
             response = self.client.get(test_category.get_absolute_url() + url)
             response = self.client.get(test_category.get_absolute_url() + url)
             self.assertEqual(response.status_code, 404)
             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)
             self.assertEqual(response.status_code, 404)
 
 
     def test_access_protected_category(self):
     def test_access_protected_category(self):
@@ -478,37 +451,23 @@ class CategoryThreadsListTests(ThreadsListTestCase):
         test_category = Category.objects.get(slug='hidden-category')
         test_category = Category.objects.get(slug='hidden-category')
 
 
         for url in LISTS_URLS:
         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):
     def test_display_pinned_threads(self):
         """
         """
@@ -550,7 +509,7 @@ class CategoryThreadsListTests(ThreadsListTestCase):
         self.assertTrue(positions['s'] > positions['g'])
         self.assertTrue(positions['s'] > positions['g'])
 
 
         # API behaviour is identic
         # 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)
         self.assertEqual(response.status_code, 200)
 
 
         content = smart_str(response.content)
         content = smart_str(response.content)
@@ -574,6 +533,7 @@ class CategoryThreadsListTests(ThreadsListTestCase):
 
 
 
 
 class ThreadsVisibilityTests(ThreadsListTestCase):
 class ThreadsVisibilityTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_test_thread(self):
     def test_list_renders_test_thread(self):
         """list renders test thread with valid top category"""
         """list renders test thread with valid top category"""
         test_thread = testutils.post_thread(
         test_thread = testutils.post_thread(
@@ -592,7 +552,6 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertContains(response, 'thread-detail-category-%s' % self.category_c.css_class)
         self.assertContains(response, 'thread-detail-category-%s' % self.category_c.css_class)
 
 
         # api displays same data
         # api displays same data
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -602,7 +561,6 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertIn(self.category_a.pk, response_json['subcategories'])
         self.assertIn(self.category_a.pk, response_json['subcategories'])
 
 
         # test category view
         # test category view
-        self.access_all_categories()
         response = self.client.get(self.category_b.get_absolute_url())
         response = self.client.get(self.category_b.get_absolute_url())
         self.assertEqual(response.status_code, 200)
         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)
         self.assertContains(response, 'thread-detail-category-%s' % self.category_c.css_class)
 
 
         # api displays same data
         # api displays same data
-        self.access_all_categories()
         response = self.client.get('%s?category=%s' % (self.api_link, self.category_b.pk))
         response = self.client.get('%s?category=%s' % (self.api_link, self.category_b.pk))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -664,6 +621,7 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl()
     def test_list_user_see_own_unapproved_thread(self):
     def test_list_user_see_own_unapproved_thread(self):
         """list renders unapproved thread that belongs to viewer"""
         """list renders unapproved thread that belongs to viewer"""
         test_thread = testutils.post_thread(
         test_thread = testutils.post_thread(
@@ -677,13 +635,13 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
 
+    @patch_categories_acl()
     def test_list_user_cant_see_unapproved_thread(self):
     def test_list_user_cant_see_unapproved_thread(self):
         """list hides unapproved thread that belongs to other user"""
         """list hides unapproved thread that belongs to other user"""
         test_thread = testutils.post_thread(
         test_thread = testutils.post_thread(
@@ -696,13 +654,13 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl()
     def test_list_user_cant_see_hidden_thread(self):
     def test_list_user_cant_see_hidden_thread(self):
         """list hides hidden thread that belongs to other user"""
         """list hides hidden thread that belongs to other user"""
         test_thread = testutils.post_thread(
         test_thread = testutils.post_thread(
@@ -715,13 +673,13 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl()
     def test_list_user_cant_see_own_hidden_thread(self):
     def test_list_user_cant_see_own_hidden_thread(self):
         """list hides hidden thread that belongs to viewer"""
         """list hides hidden thread that belongs to viewer"""
         test_thread = testutils.post_thread(
         test_thread = testutils.post_thread(
@@ -735,13 +693,13 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl({'can_hide_threads': 1})
     def test_list_user_can_see_own_hidden_thread(self):
     def test_list_user_can_see_own_hidden_thread(self):
         """list shows hidden thread that belongs to viewer due to permission"""
         """list shows hidden thread that belongs to viewer due to permission"""
         test_thread = testutils.post_thread(
         test_thread = testutils.post_thread(
@@ -750,21 +708,18 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
             is_hidden=True,
             is_hidden=True,
         )
         )
 
 
-        self.access_all_categories({'can_hide_threads': 1})
-
         response = self.client.get('/')
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories({'can_hide_threads': 1})
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
         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):
     def test_list_user_can_see_hidden_thread(self):
         """list shows hidden thread that belongs to other user due to permission"""
         """list shows hidden thread that belongs to other user due to permission"""
         test_thread = testutils.post_thread(
         test_thread = testutils.post_thread(
@@ -772,21 +727,18 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
             is_hidden=True,
             is_hidden=True,
         )
         )
 
 
-        self.access_all_categories({'can_hide_threads': 1})
-
         response = self.client.get('/')
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories({'can_hide_threads': 1})
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
         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):
     def test_list_user_can_see_unapproved_thread(self):
         """list shows hidden thread that belongs to other user due to permission"""
         """list shows hidden thread that belongs to other user due to permission"""
         test_thread = testutils.post_thread(
         test_thread = testutils.post_thread(
@@ -794,15 +746,11 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
             is_unapproved=True,
             is_unapproved=True,
         )
         )
 
 
-        self.access_all_categories({'can_approve_content': 1})
-
         response = self.client.get('/')
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories({'can_approve_content': 1})
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -811,34 +759,30 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
 
 
 
 
 class MyThreadsListTests(ThreadsListTestCase):
 class MyThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_empty(self):
     def test_list_renders_empty(self):
         """list renders empty"""
         """list renders empty"""
-        self.access_all_categories()
-
         response = self.client.get('/my/')
         response = self.client.get('/my/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "empty-message")
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'my/')
         response = self.client.get(self.category_a.get_absolute_url() + 'my/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "empty-message")
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=my' % self.api_link)
         response = self.client.get('%s?list=my' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         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 = self.client.get('%s?list=my&category=%s' % (self.api_link, self.category_a.pk))
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl()
     def test_list_renders_test_thread(self):
     def test_list_renders_test_thread(self):
         """list renders only threads posted by user"""
         """list renders only threads posted by user"""
         test_thread = testutils.post_thread(
         test_thread = testutils.post_thread(
@@ -848,22 +792,17 @@ class MyThreadsListTests(ThreadsListTestCase):
 
 
         other_thread = testutils.post_thread(category=self.category_a)
         other_thread = testutils.post_thread(category=self.category_a)
 
 
-        self.access_all_categories()
-
         response = self.client.get('/my/')
         response = self.client.get('/my/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
         self.assertNotContainsThread(response, other_thread)
         self.assertNotContainsThread(response, other_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'my/')
         response = self.client.get(self.category_a.get_absolute_url() + 'my/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
         self.assertNotContainsThread(response, other_thread)
         self.assertNotContainsThread(response, other_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=my' % self.api_link)
         response = self.client.get('%s?list=my' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -871,7 +810,6 @@ class MyThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
         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))
         response = self.client.get('%s?list=my&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -881,52 +819,43 @@ class MyThreadsListTests(ThreadsListTestCase):
 
 
 
 
 class NewThreadsListTests(ThreadsListTestCase):
 class NewThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_empty(self):
     def test_list_renders_empty(self):
         """list renders empty"""
         """list renders empty"""
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "empty-message")
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "empty-message")
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         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 = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl()
     def test_list_renders_new_thread(self):
     def test_list_renders_new_thread(self):
         """list renders new thread"""
         """list renders new thread"""
         test_thread = testutils.post_thread(category=self.category_a)
         test_thread = testutils.post_thread(category=self.category_a)
 
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -934,7 +863,6 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
         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))
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -942,6 +870,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
 
+    @patch_categories_acl()
     def test_list_renders_thread_bumped_after_user_cutoff(self):
     def test_list_renders_thread_bumped_after_user_cutoff(self):
         """list renders new thread bumped after user cutoff"""
         """list renders new thread bumped after user cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=10)
         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),
             posted_on=self.user.joined_on + timedelta(days=4),
         )
         )
 
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -978,7 +902,6 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
         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))
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -986,6 +909,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
 
+    @patch_categories_acl()
     def test_list_hides_global_cutoff_thread(self):
     def test_list_hides_global_cutoff_thread(self):
         """list hides thread started before global cutoff"""
         """list hides thread started before global cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=10)
         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),
             started_on=timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF + 1),
         )
         )
 
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         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 = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl()
     def test_list_hides_user_cutoff_thread(self):
     def test_list_hides_user_cutoff_thread(self):
         """list hides thread started before users cutoff"""
         """list hides thread started before users cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
         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),
             started_on=self.user.joined_on - timedelta(minutes=1),
         )
         )
 
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         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 = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl()
     def test_list_hides_user_read_thread(self):
     def test_list_hides_user_read_thread(self):
         """list hides thread already read by user"""
         """list hides thread already read by user"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
         self.user.save()
 
 
         test_thread = testutils.post_thread(category=self.category_a)
         test_thread = testutils.post_thread(category=self.category_a)
-
         poststracker.save_read(self.user, test_thread.first_post)
         poststracker.save_read(self.user, test_thread.first_post)
 
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         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 = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -1098,29 +1005,24 @@ class NewThreadsListTests(ThreadsListTestCase):
 
 
 
 
 class UnreadThreadsListTests(ThreadsListTestCase):
 class UnreadThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_empty(self):
     def test_list_renders_empty(self):
         """list renders empty"""
         """list renders empty"""
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "empty-message")
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "empty-message")
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
-        self.access_all_categories()
         response = self.client.get(
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
         )
@@ -1129,31 +1031,25 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl()
     def test_list_renders_unread_thread(self):
     def test_list_renders_unread_thread(self):
         """list renders thread with unread posts"""
         """list renders thread with unread posts"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
         self.user.save()
 
 
         test_thread = testutils.post_thread(category=self.category_a)
         test_thread = testutils.post_thread(category=self.category_a)
-
         poststracker.save_read(self.user, test_thread.first_post)
         poststracker.save_read(self.user, test_thread.first_post)
-
         testutils.reply_thread(test_thread)
         testutils.reply_thread(test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -1161,7 +1057,6 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
 
-        self.access_all_categories()
         response = self.client.get(
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
             '%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(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
 
+    @patch_categories_acl()
     def test_list_hides_never_read_thread(self):
     def test_list_hides_never_read_thread(self):
         """list hides never read thread"""
         """list hides never read thread"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
         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)
         test_thread = testutils.post_thread(category=self.category_a)
 
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
-        self.access_all_categories()
         response = self.client.get(
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
         )
@@ -1207,36 +1097,30 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl()
     def test_list_hides_read_thread(self):
     def test_list_hides_read_thread(self):
         """list hides read thread"""
         """list hides read thread"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
         self.user.save()
 
 
         test_thread = testutils.post_thread(category=self.category_a)
         test_thread = testutils.post_thread(category=self.category_a)
-
         poststracker.save_read(self.user, test_thread.first_post)
         poststracker.save_read(self.user, test_thread.first_post)
 
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
-        self.access_all_categories()
         response = self.client.get(
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
         )
@@ -1245,6 +1129,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl()
     def test_list_hides_global_cutoff_thread(self):
     def test_list_hides_global_cutoff_thread(self):
         """list hides thread replied before global cutoff"""
         """list hides thread replied before global cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=10)
         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)
         poststracker.save_read(self.user, test_thread.first_post)
-
         testutils.reply_thread(test_thread, posted_on=test_thread.started_on + timedelta(days=1))
         testutils.reply_thread(test_thread, posted_on=test_thread.started_on + timedelta(days=1))
 
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
-        self.access_all_categories()
         response = self.client.get(
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
         )
@@ -1288,6 +1166,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
+    @patch_categories_acl()
     def test_list_hides_user_cutoff_thread(self):
     def test_list_hides_user_cutoff_thread(self):
         """list hides thread replied before user cutoff"""
         """list hides thread replied before user cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=10)
         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),
             posted_on=test_thread.started_on + timedelta(days=1),
         )
         )
 
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
 
 
-        self.access_all_categories()
         response = self.client.get(
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
         )
@@ -1336,6 +1209,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
 
 
 
 
 class SubscribedThreadsListTests(ThreadsListTestCase):
 class SubscribedThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_shows_subscribed_thread(self):
     def test_list_shows_subscribed_thread(self):
         """list shows subscribed thread"""
         """list shows subscribed thread"""
         test_thread = testutils.post_thread(category=self.category_a)
         test_thread = testutils.post_thread(category=self.category_a)
@@ -1345,20 +1219,15 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
             last_read_on=test_thread.last_post_on,
             last_read_on=test_thread.last_post_on,
         )
         )
 
 
-        self.access_all_categories()
-
         response = self.client.get('/subscribed/')
         response = self.client.get('/subscribed/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'subscribed/')
         response = self.client.get(self.category_a.get_absolute_url() + 'subscribed/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=subscribed' % self.api_link)
         response = self.client.get('%s?list=subscribed' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -1366,7 +1235,6 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(len(response_json['results']), 1)
         self.assertContains(response, test_thread.get_absolute_url())
         self.assertContains(response, test_thread.get_absolute_url())
 
 
-        self.access_all_categories()
         response = self.client.get(
         response = self.client.get(
             '%s?list=subscribed&category=%s' % (self.api_link, self.category_a.pk)
             '%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.assertEqual(len(response_json['results']), 1)
         self.assertContains(response, test_thread.get_absolute_url())
         self.assertContains(response, test_thread.get_absolute_url())
 
 
+    @patch_categories_acl()
     def test_list_hides_unsubscribed_thread(self):
     def test_list_hides_unsubscribed_thread(self):
         """list shows subscribed thread"""
         """list shows subscribed thread"""
         test_thread = testutils.post_thread(category=self.category_a)
         test_thread = testutils.post_thread(category=self.category_a)
 
 
-        self.access_all_categories()
-
         response = self.client.get('/subscribed/')
         response = self.client.get('/subscribed/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'subscribed/')
         response = self.client.get(self.category_a.get_absolute_url() + 'subscribed/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
         # test api
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=subscribed' % self.api_link)
         response = self.client.get('%s?list=subscribed' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
@@ -1401,7 +1265,6 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 0)
         self.assertEqual(len(response_json['results']), 0)
         self.assertNotContainsThread(response, test_thread)
         self.assertNotContainsThread(response, test_thread)
 
 
-        self.access_all_categories()
         response = self.client.get(
         response = self.client.get(
             '%s?list=subscribed&category=%s' % (self.api_link, self.category_a.pk)
             '%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,
             '%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
         # 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
         # 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):
     def test_list_shows_all_threads_for_approving_user(self):
         """list shows all threads with unapproved posts when user has perm"""
         """list shows all threads with unapproved posts when user has perm"""
         visible_thread = testutils.post_thread(
         visible_thread = testutils.post_thread(
@@ -1455,40 +1318,23 @@ class UnapprovedListTests(ThreadsListTestCase):
             is_unapproved=False,
             is_unapproved=False,
         )
         )
 
 
-        self.access_all_categories({
-            'can_approve_content': True,
-        }, {
-            'can_see_unapproved_content_lists': True,
-        })
-
         response = self.client.get('/unapproved/')
         response = self.client.get('/unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_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/')
         response = self.client.get(self.category_a.get_absolute_url() + 'unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
 
         # test api
         # 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)
         response = self.client.get('%s?list=unapproved' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, visible_thread.get_absolute_url())
         self.assertContains(response, visible_thread.get_absolute_url())
         self.assertNotContains(response, hidden_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):
     def test_list_shows_owned_threads_for_unapproving_user(self):
         """list shows owned threads with unapproved posts for user without perm"""
         """list shows owned threads with unapproved posts for user without perm"""
         visible_thread = testutils.post_thread(
         visible_thread = testutils.post_thread(
@@ -1502,49 +1348,41 @@ class UnapprovedListTests(ThreadsListTestCase):
             is_unapproved=True,
             is_unapproved=True,
         )
         )
 
 
-        self.access_all_categories(base_acl={
-            'can_see_unapproved_content_lists': True,
-        })
         response = self.client.get('/unapproved/')
         response = self.client.get('/unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_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/')
         response = self.client.get(self.category_a.get_absolute_url() + 'unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
 
         # test api
         # test api
-        self.access_all_categories(base_acl={
-            'can_see_unapproved_content_lists': True,
-        })
         response = self.client.get('%s?list=unapproved' % self.api_link)
         response = self.client.get('%s?list=unapproved' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, visible_thread.get_absolute_url())
         self.assertContains(response, visible_thread.get_absolute_url())
         self.assertNotContains(response, hidden_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):
 class OwnerOnlyThreadsVisibilityTests(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
 
 
         self.category = Category.objects.get(slug='first-category')
         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):
     def test_owned_threads_visibility(self):
         """only user-posted threads are visible in category"""
         """only user-posted threads are visible in category"""
-        self.override_acl(self.user)
-
         visible_thread = testutils.post_thread(
         visible_thread = testutils.post_thread(
             poster=self.user,
             poster=self.user,
             category=self.category,
             category=self.category,
@@ -1556,18 +1394,16 @@ class OwnerOnlyThreadsVisibilityTests(AuthenticatedUserTestCase):
             is_unapproved=True,
             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):
     def test_owned_threads_visibility_anonymous(self):
         """anons can't see any threads in limited visibility category"""
         """anons can't see any threads in limited visibility category"""
         self.logout_user()
         self.logout_user()
 
 
-        self.override_acl(AnonymousUser())
-
         user_thread = testutils.post_thread(
         user_thread = testutils.post_thread(
             poster=self.user,
             poster=self.user,
             category=self.category,
             category=self.category,
@@ -1579,8 +1415,8 @@ class OwnerOnlyThreadsVisibilityTests(AuthenticatedUserTestCase):
             is_unapproved=True,
             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.categories.models import Category
 from misago.conf import settings
 from misago.conf import settings
 from misago.threads import testutils
 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.threads.moderation import hide_post
 from misago.users.testutils import AuthenticatedUserTestCase
 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({
         category_acl.update({
             'can_see': 1,
             'can_see': 1,
             'can_browse': 1,
             'can_browse': 1,
@@ -39,14 +35,18 @@ class ThreadViewTestCase(AuthenticatedUserTestCase):
             'can_hide_events': 0,
             '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):
 class ThreadVisibilityTests(ThreadViewTestCase):
@@ -57,66 +57,57 @@ class ThreadVisibilityTests(ThreadViewTestCase):
 
 
     def test_view_shows_owner_thread(self):
     def test_view_shows_owner_thread(self):
         """view handles "owned threads" only"""
         """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):
     def test_view_validates_category_permissions(self):
         """view validates category visiblity"""
         """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):
     def test_view_shows_unapproved_thread(self):
         """view handles unapproved thread"""
         """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
         # 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):
     def test_view_shows_hidden_thread(self):
         """view handles hidden thread"""
         """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
         # threads owners are not extempt from hidden threads check
         self.thread.starter = self.user
         self.thread.starter = self.user
@@ -126,10 +117,9 @@ class ThreadVisibilityTests(ThreadViewTestCase):
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
         # grant permission to see hidden content
         # 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):
 class ThreadPostsVisibilityTests(ThreadViewTestCase):
@@ -172,23 +162,21 @@ class ThreadPostsVisibilityTests(ThreadViewTestCase):
         self.assertNotContains(response, post.parsed)
         self.assertNotContains(response, post.parsed)
 
 
         # permission to hide own posts isn't enought to see post content
         # 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
         # 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):
     def test_unapproved_post_visibility(self):
         """unapproved post renders for its author and users with perm to approve content"""
         """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())
         self.assertNotContains(response, post.get_absolute_url())
 
 
         # post displays because we have permission to approve unapproved content
         # 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 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):
 class ThreadEventVisibilityTests(ThreadViewTestCase):
@@ -236,51 +222,50 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         self.thread.save()
         self.thread.save()
 
 
         for action, message in TEST_ACTIONS:
         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()
             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
             # hidden event renders with permission
-            hide_post(self.user, event)
-            self.override_acl({
+            with patch_category_acl({
                 'can_approve_content': 1,
                 'can_approve_content': 1,
                 'can_hide_threads': 1,
                 'can_hide_threads': 1,
                 'can_hide_events': 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
             # 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_approve_content': 1,
                 'can_hide_threads': 1,
                 'can_hide_threads': 1,
                 'can_hide_events': 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):
     def test_events_limit(self):
         """forum will trim oldest events if theres more than allowed by config"""
         """forum will trim oldest events if theres more than allowed by config"""
@@ -288,7 +273,8 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         events = []
         events = []
 
 
         for _ in range(events_limit + 5):
         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)
             events.append(event)
 
 
         # test that only events within limits were rendered
         # test that only events within limits were rendered
@@ -306,7 +292,8 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         events = []
         events = []
 
 
         for _ in range(events_limit + 5):
         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)
             events.append(event)
 
 
         posts = []
         posts = []
@@ -326,7 +313,8 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         for _ in range(posts_limit):
         for _ in range(posts_limit):
             post = testutils.reply_thread(self.thread)
             post = testutils.reply_thread(self.thread)
         for _ in range(events_limit):
         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)
             events.append(event)
 
 
         # see first page
         # see first page
@@ -346,8 +334,9 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
 
 
     def test_changed_thread_title_event_renders(self):
     def test_changed_thread_title_event_renders(self):
         """changed thread title event renders"""
         """changed thread title event renders"""
+        request = Mock(user=self.user, user_ip="127.0.0.1")
         threads_moderation.change_thread_title(
         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]
         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.category = self.thread.category.parent
         self.thread.save()
         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]
         event = self.thread.post_set.filter(is_event=True)[0]
         self.assertEqual(event.event_type, 'moved')
         self.assertEqual(event.event_type, 'moved')
@@ -376,8 +366,9 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
 
 
     def test_thread_merged_event_renders(self):
     def test_thread_merged_event_renders(self):
         """merged thread event renders"""
         """merged thread event renders"""
+        request = Mock(user=self.user, user_ip="127.0.0.1")
         other_thread = testutils.post_thread(category=self.category)
         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]
         event = self.thread.post_set.filter(is_event=True)[0]
         self.assertEqual(event.event_type, 'merged')
         self.assertEqual(event.event_type, 'merged')
@@ -494,21 +485,22 @@ class ThreadLikedPostsViewTests(ThreadViewTestCase):
         """
         """
         testutils.like_post(self.thread.first_post, self.user)
         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):
 class ThreadAnonViewTests(ThreadViewTestCase):
     def test_anonymous_user_view_no_showstoppers_display(self):
     def test_anonymous_user_view_no_showstoppers_display(self):
         """kitchensink thread view has no showstoppers for anons"""
         """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)
         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)
         hide_post(self.user, hidden_event)
 
 
         unapproved_post = testutils.reply_thread(self.thread, is_unapproved=True)
         unapproved_post = testutils.reply_thread(self.thread, is_unapproved=True)
@@ -528,26 +520,21 @@ class ThreadUnicodeSupportTests(ThreadViewTestCase):
     def test_category_name(self):
     def test_category_name(self):
         """unicode in category name causes no showstopper"""
         """unicode in category name causes no showstopper"""
         self.category.name = 'Łódź'
         self.category.name = 'Łódź'
-        self.category.slug = 'Lodz'
-
         self.category.save()
         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):
     def test_thread_title(self):
         """unicode in thread title causes no showstopper"""
         """unicode in thread title causes no showstopper"""
         self.thread.title = 'Łódź'
         self.thread.title = 'Łódź'
         self.thread.slug = 'Lodz'
         self.thread.slug = 'Lodz'
-
         self.thread.save()
         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):
     def test_post_content(self):
         """unicode in thread title causes no showstopper"""
         """unicode in thread title causes no showstopper"""
@@ -555,24 +542,20 @@ class ThreadUnicodeSupportTests(ThreadViewTestCase):
         self.thread.first_post.parsed = '<p>Łódź</p>'
         self.thread.first_post.parsed = '<p>Łódź</p>'
 
 
         update_post_checksum(self.thread.first_post)
         update_post_checksum(self.thread.first_post)
-
         self.thread.first_post.save()
         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):
     def test_user_rank(self):
         """unicode in user rank causes no showstopper"""
         """unicode in user rank causes no showstopper"""
         self.user.title = 'Łódź'
         self.user.title = 'Łódź'
         self.user.rank.name = 'Łódź'
         self.user.rank.name = 'Łódź'
         self.user.rank.title = 'Łódź'
         self.user.rank.title = 'Łódź'
-
         self.user.rank.save()
         self.user.rank.save()
         self.user.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
 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:
     try:
         threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
         threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
         category = Category.objects.get(
         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:
     if allow_root and category and not category.level:
         return category
         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."))
         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."))
         raise ValidationError(_("You don't have permission to access this category."))
     return category
     return category
 
 

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

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

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

@@ -1,6 +1,6 @@
 from django.shortcuts import get_object_or_404
 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.core.viewmodel import ViewModel as BaseViewModel
 from misago.threads.permissions import exclude_invisible_posts
 from misago.threads.permissions import exclude_invisible_posts
 
 
@@ -12,7 +12,7 @@ class ViewModel(BaseViewModel):
     def __init__(self, request, thread, pk):
     def __init__(self, request, thread, pk):
         model = self.get_post(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
         self._model = model
 
 
@@ -36,7 +36,7 @@ class ViewModel(BaseViewModel):
         return post
         return post
 
 
     def get_queryset(self, request, thread):
     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):
 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.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.readtracker.poststracker import make_read_aware
 from misago.readtracker.poststracker import make_read_aware
@@ -61,7 +61,7 @@ class ViewModel(object):
             posts.sort(key=lambda p: p.pk)
             posts.sort(key=lambda p: p.pk)
 
 
         # make posts and events ACL and reads aware
         # 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)
         make_read_aware(request.user, posts)
 
 
         self._user = request.user
         self._user = request.user
@@ -77,7 +77,7 @@ class ViewModel(object):
             'poster__ban_cache',
             'poster__ban_cache',
             'poster__online_tracker',
             'poster__online_tracker',
         ).filter(is_event=False).order_by('id')
         ).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):
     def get_events_queryset(self, request, thread, limit, first_post=None, last_post=None):
         queryset = thread.post_set.select_related(
         queryset = thread.post_set.select_related(
@@ -93,7 +93,7 @@ class ViewModel(object):
         if last_post:
         if last_post:
             queryset = queryset.filter(pk__lt=last_post.pk)
             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])
         return list(queryset.order_by('-id')[:limit])
 
 
     def get_frontend_context(self):
     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.shortcuts import get_object_or_404
 from django.utils.translation import gettext as _
 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 import PRIVATE_THREADS_ROOT_NAME, THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.core.shortcuts import validate_slug
 from misago.core.shortcuts import validate_slug
@@ -44,11 +44,11 @@ class ViewModel(BaseViewModel):
         if path_aware:
         if path_aware:
             model.path = self.get_thread_path(model.category)
             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:
         if read_aware:
-            make_read_aware(request.user, model)
+            make_read_aware(request.user, request.user_acl, model)
         if subscription_aware:
         if subscription_aware:
             make_subscription_aware(request.user, model)
             make_subscription_aware(request.user, model)
 
 
@@ -56,7 +56,7 @@ class ViewModel(BaseViewModel):
 
 
         try:
         try:
             self._poll = model.poll
             self._poll = model.poll
-            add_acl(request.user, self._poll)
+            add_acl_to_obj(request.user_acl, self._poll)
 
 
             if poll_votes_aware:
             if poll_votes_aware:
                 self._poll.make_choices_votes_aware(request.user)
                 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),
             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:
         if slug:
             validate_slug(thread, slug)
             validate_slug(thread, slug)
         return thread
         return thread
@@ -123,7 +123,7 @@ class ForumThread(ViewModel):
 
 
 class PrivateThread(ViewModel):
 class PrivateThread(ViewModel):
     def get_thread(self, request, pk, slug=None):
     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 = get_object_or_404(
             Thread.objects.select_related(*BASE_RELATIONS),
             Thread.objects.select_related(*BASE_RELATIONS),
@@ -132,7 +132,7 @@ class PrivateThread(ViewModel):
         )
         )
 
 
         make_participants_aware(request.user, thread)
         make_participants_aware(request.user, thread)
-        allow_see_private_thread(request.user, thread)
+        allow_see_private_thread(request.user_acl, thread)
 
 
         if slug:
         if slug:
             validate_slug(thread, 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 as _
 from django.utils.translation import gettext_lazy
 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.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.readtracker import threadstracker
 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.subscriptions import make_subscription_aware
 from misago.threads.utils import add_categories_to_items
 from misago.threads.utils import add_categories_to_items
 
 
-
 __all__ = ['ForumThreads', 'PrivateThreads', 'filter_read_threads_queryset']
 __all__ = ['ForumThreads', 'PrivateThreads', 'filter_read_threads_queryset']
 
 
 LISTS_NAMES = {
 LISTS_NAMES = {
@@ -69,7 +68,7 @@ class ViewModel(object):
             threads = list(pinned_threads) + list(list_page.object_list)
             threads = list(pinned_threads) + list(list_page.object_list)
 
 
         add_categories_to_items(category_model, category.categories, threads)
         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)
         make_subscription_aware(request.user, threads)
 
 
         if list_type in ('new', 'unread'):
         if list_type in ('new', 'unread'):
@@ -78,7 +77,7 @@ class ViewModel(object):
                 thread.is_read = False
                 thread.is_read = False
                 thread.is_new = True
                 thread.is_new = True
         else:
         else:
-            threadstracker.make_read_aware(request.user, threads)
+            threadstracker.make_read_aware(request.user, request.user_acl, threads)
 
 
         self.filter_threads(request, threads)
         self.filter_threads(request, threads)
 
 
@@ -96,7 +95,7 @@ class ViewModel(object):
             if list_type in LIST_DENIED_MESSAGES:
             if list_type in LIST_DENIED_MESSAGES:
                 raise PermissionDenied(LIST_DENIED_MESSAGES[list_type])
                 raise PermissionDenied(LIST_DENIED_MESSAGES[list_type])
         else:
         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:
             if list_type == 'unapproved' and not has_permission:
                 raise PermissionDenied(
                 raise PermissionDenied(
                     _("You don't have permission to see unapproved content lists.")
                     _("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):
     def get_base_queryset(self, request, threads_categories, list_type):
         return get_threads_queryset(
         return get_threads_queryset(
-            request.user,
+            request,
             threads_categories,
             threads_categories,
             list_type,
             list_type,
         ).order_by('-last_post_id')
         ).order_by('-last_post_id')
@@ -169,7 +168,7 @@ class PrivateThreads(ViewModel):
         # limit queryset to threads we are participant of
         # limit queryset to threads we are participant of
         participated_threads = request.user.threadparticipant_set.values('thread_id')
         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))
             queryset = queryset.filter(Q(id__in=participated_threads) | Q(has_reported_posts=True))
         else:
         else:
             queryset = queryset.filter(id__in=participated_threads)
             queryset = queryset.filter(id__in=participated_threads)
@@ -183,35 +182,37 @@ class PrivateThreads(ViewModel):
         make_participants_aware(request.user, threads)
         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':
     if list_type == 'all':
         return queryset
         return queryset
     else:
     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':
     if list_type == 'my':
-        return queryset.filter(starter=user)
+        return queryset.filter(starter=request.user)
     elif list_type == 'subscribed':
     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)
         return queryset.filter(id__in=subscribed_threads)
     elif list_type == 'unapproved':
     elif list_type == 'unapproved':
         return queryset.filter(has_unapproved_posts=True)
         return queryset.filter(has_unapproved_posts=True)
     elif list_type in ('new', 'unread'):
     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:
     else:
         return queryset
         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
     # grab cutoffs for categories
+    user = request.user
+
     cutoff_date = get_cutoff_date(user)
     cutoff_date = get_cutoff_date(user)
 
 
     visible_posts = Post.objects.filter(posted_on__gt=cutoff_date)
     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'))
     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 is_authenticated or request.user.id != attachment.uploader_id:
         if not attachment.post_id:
         if not attachment.post_id:
             raise Http404()
             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()
             raise PermissionDenied()
 
 
     allowed_roles = set(r.pk for r in attachment.filetype.limit_downloads_to.all())
     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()
         thread = self.get_thread(request, pk, slug).unwrap()
         self.test_permissions(request, thread)
         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)
         target_page = self.compute_post_page(target_post, posts_queryset)
 
 
         return self.get_redirect(thread, target_post, target_page)
         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):
     def compute_post_page(self, target_post, posts_queryset):
         # filter out events, order queryset
         # filter out events, order queryset
         posts_queryset = posts_queryset.filter(is_event=False).order_by('id')
         posts_queryset = posts_queryset.filter(is_event=False).order_by('id')
-
         thread_length = posts_queryset.count()
         thread_length = posts_queryset.count()
 
 
         # is target an event?
         # is target an event?

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

@@ -56,11 +56,12 @@ def login(request):
 def session_user(request):
 def session_user(request):
     """GET /auth/ will return current auth user, either User or AnonymousUser"""
     """GET /auth/ will return current auth user, either User or AnonymousUser"""
     if request.user.is_authenticated:
     if request.user.is_authenticated:
-        UserSerializer = AuthenticatedUserSerializer
+        serializer = AuthenticatedUserSerializer
     else:
     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'])
 @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):
 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."))
         raise PermissionDenied(_("You don't have permission to change signature."))
 
 
+    user = request.user
+    
     if user.is_signature_locked:
     if user.is_signature_locked:
         if user.signature_lock_user_message:
         if user.signature_lock_user_message:
             reason = format_plaintext_for_html(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):
 def edit_signature(request, user):
     serializer = EditSignatureSerializer(user, data=request.data)
     serializer = EditSignatureSerializer(user, data=request.data)
     if serializer.is_valid():
     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'])
         user.save(update_fields=['signature', 'signature_parsed', 'signature_checksum'])
         return get_signature_options(user)
         return get_signature_options(user)
     else:
     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 django.utils.translation import gettext as _
 
 
 from misago.conf import settings
 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
 from misago.users.serializers import ChangeUsernameSerializer
 
 
 
 
@@ -13,17 +13,14 @@ def username_endpoint(request):
     if request.method == 'POST':
     if request.method == 'POST':
         return change_username(request)
         return change_username(request)
     else:
     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):
 def options_response(options):
@@ -33,34 +30,46 @@ def options_response(options):
 
 
 
 
 def change_username(request):
 def change_username(request):
-    options = get_username_options(request.user)
+    options = get_username_options_from_request(request)
     if not options['changes_left']:
     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():
     if serializer.is_valid():
         try:
         try:
             serializer.change_username(changed_by=request.user)
             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({
             return Response({
                 'username': request.user.username,
                 'username': request.user.username,
                 'slug': request.user.slug,
                 'slug': request.user.slug,
-                'options': get_username_options(request.user)
+                'options': updated_options,
             })
             })
         except IntegrityError:
         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:
     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):
 def moderate_username_endpoint(request, profile):
@@ -75,15 +84,19 @@ def moderate_username_endpoint(request, profile):
                     'slug': profile.slug,
                     'slug': profile.slug,
                 })
                 })
             except IntegrityError:
             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:
         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:
     else:
         return Response({
         return Response({
             'length_min': settings.username_length_min,
             '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:
         if user_pk == request.user.pk:
             return True
             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."))
             raise PermissionDenied(_("You don't have permission to see other users name history."))
         return True
         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.shortcuts import get_object_or_404
 from django.utils.translation import gettext as _
 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.categories.models import Category
 from misago.conf import settings
 from misago.conf import settings
 from misago.core.rest_permissions import IsAuthenticatedOrReadOnly
 from misago.core.rest_permissions import IsAuthenticatedOrReadOnly
@@ -75,7 +75,7 @@ class UserViewSet(viewsets.GenericViewSet):
         return user
         return user
 
 
     def list(self, request):
     def list(self, request):
-        allow_browse_users_list(request.user)
+        allow_browse_users_list(request.user_acl)
         return list_endpoint(request)
         return list_endpoint(request)
 
 
     def create(self, request):
     def create(self, request):
@@ -84,10 +84,10 @@ class UserViewSet(viewsets.GenericViewSet):
     def retrieve(self, request, pk=None):
     def retrieve(self, request, pk=None):
         profile = self.get_user(request, pk)
         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)
         profile.status = get_user_status(request, profile)
 
 
-        serializer = UserProfileSerializer(profile, context={'user': request.user})
+        serializer = UserProfileSerializer(profile, context={'request': request})
         profile_json = serializer.data
         profile_json = serializer.data
 
 
         if not profile.is_active:
         if not profile.is_active:
@@ -153,7 +153,7 @@ class UserViewSet(viewsets.GenericViewSet):
     @detail_route(methods=['get', 'post'])
     @detail_route(methods=['get', 'post'])
     def edit_details(self, request, pk=None):
     def edit_details(self, request, pk=None):
         profile = self.get_user(request, pk)
         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)
         return edit_details_endpoint(request, profile)
 
 
     @detail_route(methods=['post'])
     @detail_route(methods=['post'])
@@ -169,7 +169,7 @@ class UserViewSet(viewsets.GenericViewSet):
     @detail_route(methods=['post'])
     @detail_route(methods=['post'])
     def follow(self, request, pk=None):
     def follow(self, request, pk=None):
         profile = self.get_user(request, pk)
         profile = self.get_user(request, pk)
-        allow_follow_user(request.user, profile)
+        allow_follow_user(request.user_acl, profile)
 
 
         profile_followers = profile.followers
         profile_followers = profile.followers
 
 
@@ -197,7 +197,7 @@ class UserViewSet(viewsets.GenericViewSet):
     @detail_route()
     @detail_route()
     def ban(self, request, pk=None):
     def ban(self, request, pk=None):
         profile = self.get_user(request, pk)
         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)
         ban = get_user_ban(profile, request.cache_versions)
         if ban:
         if ban:
@@ -208,14 +208,14 @@ class UserViewSet(viewsets.GenericViewSet):
     @detail_route(methods=['get', 'post'])
     @detail_route(methods=['get', 'post'])
     def moderate_avatar(self, request, pk=None):
     def moderate_avatar(self, request, pk=None):
         profile = self.get_user(request, pk)
         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)
         return moderate_avatar_endpoint(request, profile)
 
 
     @detail_route(methods=['get', 'post'])
     @detail_route(methods=['get', 'post'])
     def moderate_username(self, request, pk=None):
     def moderate_username(self, request, pk=None):
         profile = self.get_user(request, pk)
         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)
         return moderate_username_endpoint(request, profile)
 
 
@@ -238,7 +238,7 @@ class UserViewSet(viewsets.GenericViewSet):
     @detail_route(methods=['get', 'post'])
     @detail_route(methods=['get', 'post'])
     def delete(self, request, pk=None):
     def delete(self, request, pk=None):
         profile = self.get_user(request, pk)
         profile = self.get_user(request, pk)
-        allow_delete_user(request.user, profile)
+        allow_delete_user(request.user_acl, profile)
 
 
         if request.method == 'POST':
         if request.method == 'POST':
             with transaction.atomic():
             with transaction.atomic():

+ 2 - 2
misago/users/apps.py

@@ -71,14 +71,14 @@ class MisagoUsersConfig(AppConfig):
         def can_see_names_history(request, profile):
         def can_see_names_history(request, profile):
             if request.user.is_authenticated:
             if request.user.is_authenticated:
                 is_account_owner = profile.pk == request.user.pk
                 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
                 return is_account_owner or has_permission
             else:
             else:
                 return False
                 return False
 
 
         def can_see_ban_details(request, profile):
         def can_see_ban_details(request, profile):
             if request.user.is_authenticated:
             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
                     from .bans import get_user_ban
                     return bool(get_user_ban(profile, request.cache_versions))
                     return bool(get_user_ban(profile, request.cache_versions))
                 else:
                 else:

+ 5 - 2
misago/users/context_processors.py

@@ -36,8 +36,11 @@ def preload_user_json(request):
     })
     })
 
 
     if request.user.is_authenticated:
     if request.user.is_authenticated:
-        request.frontend_context.update({'user': AuthenticatedUserSerializer(request.user).data})
+        serializer = AuthenticatedUserSerializer
     else:
     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 {}
     return {}

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

@@ -1,7 +1,7 @@
 from django.db import models, transaction
 from django.db import models, transaction
 from django.urls import reverse
 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
 from misago.core.utils import slugify
 
 
 
 
@@ -39,11 +39,11 @@ class Rank(models.Model):
         if not self.pk:
         if not self.pk:
             self.set_order()
             self.set_order()
         else:
         else:
-            acl_version.invalidate()
+            clear_acl_cache()
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)
 
 
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
-        acl_version.invalidate()
+        clear_acl_cache()
         return super().delete(*args, **kwargs)
         return super().delete(*args, **kwargs)
 
 
     def get_absolute_url(self):
     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 import timezone
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from misago.acl import get_user_acl
 from misago.acl.models import Role
 from misago.acl.models import Role
 from misago.conf import settings
 from misago.conf import settings
 from misago.core.pgutils import PgPartialIndex
 from misago.core.pgutils import PgPartialIndex
@@ -329,22 +328,6 @@ class User(AbstractBaseUser, PermissionsMixin):
         anonymize_user_data.send(sender=self)
         anonymize_user_data.send(sender=self)
 
 
     @property
     @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):
     def requires_activation_by_admin(self):
         return self.requires_activation == self.ACTIVATION_ADMIN
         return self.requires_activation == self.ACTIVATION_ADMIN
 
 
@@ -401,13 +384,17 @@ class User(AbstractBaseUser, PermissionsMixin):
 
 
             if self.pk:
             if self.pk:
                 changed_by = changed_by or self
                 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
                 from misago.users.signals import username_changed
                 username_changed.send(sender=self)
                 username_changed.send(sender=self)
 
 
+                return namechange
+
     def record_name_change(self, changed_by, new_username, old_username):
     def record_name_change(self, changed_by, new_username, old_username):
-        self.namechanges.create(
+        return self.namechanges.create(
             new_username=new_username,
             new_username=new_username,
             old_username=old_username,
             old_username=old_username,
             changed_by=changed_by,
             changed_by=changed_by,
@@ -453,16 +440,26 @@ class User(AbstractBaseUser, PermissionsMixin):
         """sends an email to this user (for compat with Django)"""
         """sends an email to this user (for compat with Django)"""
         send_mail(subject, message, from_email, [self.email], **kwargs)
         send_mail(subject, message, from_email, [self.email], **kwargs)
 
 
-    def is_following(self, user):
+    def is_following(self, user_or_id):
         try:
         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
             return True
         except User.DoesNotExist:
         except User.DoesNotExist:
             return False
             return False
 
 
-    def is_blocking(self, user):
+    def is_blocking(self, user_or_id):
         try:
         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
             return True
         except User.DoesNotExist:
         except User.DoesNotExist:
             return False
             return False
@@ -515,11 +512,7 @@ class AnonymousUser(DjangoAnonymousUser):
 
 
     @property
     @property
     def acl_cache(self):
     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
     @acl_cache.setter
     def acl_cache(self, value):
     def acl_cache(self, value):

+ 41 - 29
misago/users/namechanges.py

@@ -8,32 +8,44 @@ from django.utils import timezone
 from .models import UsernameChange
 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:
     try:
         online_tracker = user.online_tracker
         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 and not is_hidden:
             if online_tracker.last_click >= timezone.now() - ACTIVITY_CUTOFF:
             if online_tracker.last_click >= timezone.now() - ACTIVITY_CUTOFF:
@@ -58,7 +58,7 @@ def get_user_status(request, user):
         pass
         pass
 
 
     if user_status['is_hidden']:
     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
             user_status['is_hidden'] = False
             if user_status['is_online']:
             if user_status['is_online']:
                 user_status['is_online_hidden'] = True
                 user_status['is_online_hidden'] = True

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

@@ -9,22 +9,24 @@ __all__ = [
 
 
 
 
 def authenticated_only(f):
 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
     return perm_decorator
 
 
 
 
 def anonymous_only(f):
 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:
         else:
-            messsage = _("Only guests can perform this action.")
-            raise PermissionDenied(messsage)
+            raise PermissionDenied(
+                _("Only guests can perform this action.")
+            )
 
 
     return perm_decorator
     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']:
     if target.acl['can_delete']:
         target.acl['can_moderate'] = True
         target.acl['can_moderate'] = True
 
 
@@ -71,13 +71,13 @@ def register_with(registry):
     registry.acl_annotator(get_user_model(), add_acl_to_user)
     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:
     if not newer_than and not less_posts_than:
         raise PermissionDenied(_("You can't delete users."))
         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."))
         raise PermissionDenied(_("You can't delete your account."))
     if target.is_staff or target.is_superuser:
     if target.is_staff or target.is_superuser:
         raise PermissionDenied(_("You can't delete administrators."))
         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):
 def allow_delete_own_account(user, target):
     if not settings.MISAGO_ENABLE_DELETE_OWN_ACCOUNT and not user.is_deleting_account:
     if not settings.MISAGO_ENABLE_DELETE_OWN_ACCOUNT and not user.is_deleting_account:
         raise PermissionDenied(_("You can't delete your 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."))
         raise PermissionDenied(_("You can't delete other users accounts."))
     if user.is_staff or user.is_superuser:
     if user.is_staff or user.is_superuser:
         raise PermissionDenied(
         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 = [
     mod_permissions = [
         'can_rename',
         'can_rename',
@@ -113,30 +113,30 @@ def register_with(registry):
     registry.acl_annotator(get_user_model(), add_acl_to_user)
     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."))
         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."))
         raise PermissionDenied(_("You can't rename administrators."))
 
 
 
 
 can_rename_user = return_boolean(allow_rename_user)
 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."))
         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."))
         raise PermissionDenied(_("You can't moderate administrators avatars."))
 
 
 
 
 can_moderate_avatar = return_boolean(allow_moderate_avatar)
 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."))
         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.")
         message = _("You can't moderate administrators signatures.")
         raise PermissionDenied(message)
         raise PermissionDenied(message)
 
 
@@ -144,12 +144,12 @@ def allow_moderate_signature(user, target):
 can_moderate_signature = return_boolean(allow_moderate_signature)
 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."))
         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."))
         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.")
         message = _("You can't edit administrators details.")
         raise PermissionDenied(message)
         raise PermissionDenied(message)
 
 
@@ -157,8 +157,8 @@ def allow_edit_profile_details(user, target):
 can_edit_profile_details = return_boolean(allow_edit_profile_details)
 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."))
         raise PermissionDenied(_("You can't ban users."))
     if target.is_staff or target.is_superuser:
     if target.is_staff or target.is_superuser:
         raise PermissionDenied(_("You can't ban administrators."))
         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)
 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."))
         raise PermissionDenied(_("You can't lift bans."))
-    ban = get_user_ban(target)
+    ban = get_user_ban(target, user_acl["cache_versions"])
     if not ban:
     if not ban:
         raise PermissionDenied(_("This user is not banned."))
         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()
         lift_cutoff = (timezone.now() + expiration_limit).date()
         if not ban.valid_until:
         if not ban.valid_until:
             raise PermissionDenied(_("You can't lift permanent bans."))
             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_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', )
     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)
     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."))
         raise PermissionDenied(_("You can't browse users list."))
 
 
 
 
@@ -118,10 +118,10 @@ can_browse_users_list = return_boolean(allow_browse_users_list)
 
 
 
 
 @authenticated_only
 @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."))
         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."))
         raise PermissionDenied(_("You can't add yourself to followed."))
 
 
 
 
@@ -129,22 +129,20 @@ can_follow_user = return_boolean(allow_follow_user)
 
 
 
 
 @authenticated_only
 @authenticated_only
-def allow_block_user(user, target):
+def allow_block_user(user_acl, target):
     if target.is_staff or target.is_superuser:
     if target.is_staff or target.is_superuser:
         raise PermissionDenied(_("You can't block administrators."))
         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."))
         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)
 can_block_user = return_boolean(allow_block_user)
 
 
 
 
 @authenticated_only
 @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."))
         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
     readonly = True
 
 
     def get_value_display_data(self, request, user, value):
     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
             return None
 
 
         if not user.joined_from_ip:
         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,
         '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
     has_editable_fields = False
 
 
     for group in profilefields.get_fields_groups():
     for group in profilefields.get_fields_groups():

+ 1 - 1
misago/users/search.py

@@ -20,7 +20,7 @@ class SearchUsers(SearchProvider):
     url = 'users'
     url = 'users'
 
 
     def allow_search(self):
     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."))
             raise PermissionDenied(_("You don't have permission to search users."))
 
 
     def search(self, query, page=1):
     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.contrib.auth import get_user_model
 from django.urls import reverse
 from django.urls import reverse
 
 
-from misago.acl import serialize_acl
+from misago.acl.useracl import serialize_user_acl
 
 
 from .user import UserSerializer
 from .user import UserSerializer
 
 
-
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
 __all__ = [
 __all__ = [
@@ -43,7 +42,10 @@ class AuthenticatedUserSerializer(UserSerializer, AuthFlags):
         ]
         ]
 
 
     def get_acl(self, obj):
     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):
     def get_email(self, obj):
         return obj.email
         return obj.email
@@ -81,7 +83,7 @@ class AnonymousUserSerializer(serializers.Serializer, AuthFlags):
     is_anonymous = serializers.SerializerMethodField()
     is_anonymous = serializers.SerializerMethodField()
 
 
     def get_acl(self, obj):
     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
         return obj.acl
 
 
     def get_email(self, obj):
     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
             return obj.email
         else:
         else:
             return None
             return None
 
 
     def get_is_followed(self, obj):
     def get_is_followed(self, obj):
+        request = self.context['request']
         if obj.acl['can_follow']:
         if obj.acl['can_follow']:
-            return self.context['user'].is_following(obj)
+            return request.user.is_following(obj)
         else:
         else:
             return False
             return False
 
 
     def get_is_blocked(self, obj):
     def get_is_blocked(self, obj):
+        request = self.context['request']
         if obj.acl['can_block']:
         if obj.acl['can_block']:
-            return self.context['user'].is_blocking(obj)
+            return request.user.is_blocking(obj)
         else:
         else:
             return False
             return False
 
 

+ 2 - 2
misago/users/signatures.py

@@ -1,11 +1,11 @@
 from misago.markup import checksums, signature_flavour
 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
     user.signature = signature
 
 
     if 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)
         user.signature_checksum = make_signature_checksum(user.signature_parsed, user)
     else:
     else:
         user.signature_parsed = ''
         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.test import TestCase
 from django.utils import timezone
 from django.utils import timezone
 
 
-from misago.cache.versions import get_cache_versions_from_db
-
 from misago.users.bans import (
 from misago.users.bans import (
     ban_ip, ban_user, get_email_ban, get_ip_ban, get_request_ip_ban, get_user_ban, get_username_ban)
     ban_ip, ban_user, get_email_ban, get_ip_ban, get_request_ip_ban, get_user_ban, get_username_ban)
 from misago.users.constants import BANS_CACHE
 from misago.users.constants import BANS_CACHE
 from misago.users.models import Ban
 from misago.users.models import Ban
 
 
-
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
+cache_versions = {"bans": "abcdefgh"}
+
 
 
 class GetBanTests(TestCase):
 class GetBanTests(TestCase):
     def test_get_username_ban(self):
     def test_get_username_ban(self):
@@ -134,7 +133,7 @@ class UserBansTests(TestCase):
 
 
     def test_no_ban(self):
     def test_no_ban(self):
         """user is not caught by ban"""
         """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)
         self.assertFalse(self.user.ban_cache.is_banned)
 
 
     def test_permanent_ban(self):
     def test_permanent_ban(self):
@@ -145,7 +144,7 @@ class UserBansTests(TestCase):
             staff_message='Staff reason',
             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.assertIsNotNone(user_ban)
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
@@ -160,7 +159,7 @@ class UserBansTests(TestCase):
             expires_on=timezone.now() + timedelta(days=7),
             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.assertIsNotNone(user_ban)
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
@@ -173,7 +172,7 @@ class UserBansTests(TestCase):
             expires_on=timezone.now() - timedelta(days=7),
             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)
         self.assertFalse(self.user.ban_cache.is_banned)
 
 
     def test_expired_non_flagged_ban(self):
     def test_expired_non_flagged_ban(self):
@@ -184,7 +183,7 @@ class UserBansTests(TestCase):
         )
         )
         Ban.objects.update(is_checked=True)
         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)
         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.user_message, 'User reason')
         self.assertEqual(ban.staff_message, 'Staff 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)
         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.contrib.auth import get_user_model
 from django.urls import reverse
 from django.urls import reverse
 
 
+from misago.acl.test import patch_user_acl
 from misago.admin.testutils import AdminTestCase
 from misago.admin.testutils import AdminTestCase
-from misago.acl.testutils import override_acl
 
 
 
 
 UserModel = get_user_model()
 UserModel = get_user_model()
@@ -74,6 +74,7 @@ class JoinIpProfileFieldTests(AdminTestCase):
         self.assertContains(response, "Join IP")
         self.assertContains(response, "Join IP")
         self.assertContains(response, "127.0.0.1")
         self.assertContains(response, "127.0.0.1")
 
 
+    @patch_user_acl({'can_see_users_ips': 0})
     def test_field_hidden_no_permission(self):
     def test_field_hidden_no_permission(self):
         """field is hidden on user profile if user has no permission"""
         """field is hidden on user profile if user has no permission"""
         test_link = reverse(
         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)
         response = self.client.get(test_link)
         self.assertNotContains(response, "IP address")
         self.assertNotContains(response, "IP address")
         self.assertNotContains(response, "Join IP")
         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):
     def test_field_hidden_no_permission_json(self):
         """field is not included in display json if user has no permission"""
         """field is not included in display json if user has no permission"""
         test_link = reverse('misago:api:user-details', kwargs={'pk': self.user.pk})
         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)
         response = self.client.get(test_link)
         self.assertEqual(response.json()['groups'], [])
         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.contrib.auth import get_user_model
 from django.urls import reverse
 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.models import Category
 from misago.threads.testutils import post_thread
 from misago.threads.testutils import post_thread
 from misago.users.activepostersranking import build_active_posters_ranking
 from misago.users.activepostersranking import build_active_posters_ranking
@@ -13,20 +13,13 @@ UserModel = get_user_model()
 
 
 
 
 class UsersListTestCase(AuthenticatedUserTestCase):
 class UsersListTestCase(AuthenticatedUserTestCase):
-    def setUp(self):
-        super().setUp()
-        override_acl(self.user, {
-            'can_browse_users_list': 1,
-        })
+    pass
 
 
 
 
 class UsersListLanderTests(UsersListTestCase):
 class UsersListLanderTests(UsersListTestCase):
+    @patch_user_acl({'can_browse_users_list': 0})
     def test_lander_no_permission(self):
     def test_lander_no_permission(self):
         """lander returns 403 if user has no permission"""
         """lander returns 403 if user has no permission"""
-        override_acl(self.user, {
-            'can_browse_users_list': 0,
-        })
-
         response = self.client.get(reverse('misago:users'))
         response = self.client.get(reverse('misago:users'))
         self.assertEqual(response.status_code, 403)
         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.contrib.auth import get_user_model
 from django.test import TestCase
 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):
 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.contrib.auth import get_user_model
 from django.urls import reverse
 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.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.users.models import Ban
 from misago.users.models import Ban
@@ -184,46 +184,37 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
 
 
     def test_user_ban_details(self):
     def test_user_ban_details(self):
         """user ban details page has no showstoppers"""
         """user ban details page has no showstoppers"""
-        override_acl(self.user, {
-            'can_see_ban_details': 0,
-        })
-
         test_user = UserModel.objects.create_user("Bob", "bob@bob.com", 'pass.123')
         test_user = UserModel.objects.create_user("Bob", "bob@bob.com", 'pass.123')
         link_kwargs = {'slug': test_user.slug, 'pk': test_user.pk}
         link_kwargs = {'slug': test_user.slug, 'pk': test_user.pk}
 
 
-        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(
         Ban.objects.create(
             banned_value=test_user.username,
             banned_value=test_user.username,
             user_message="User m3ss4ge.",
             user_message="User m3ss4ge.",
             staff_message="Staff m3ss4ge.",
             staff_message="Staff m3ss4ge.",
             is_checked=True,
             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 django.urls import reverse
 
 
+from misago.acl import ACL_CACHE
 from misago.acl.models import Role
 from misago.acl.models import Role
 from misago.admin.testutils import AdminTestCase
 from misago.admin.testutils import AdminTestCase
+from misago.cache.test import assert_invalidates_cache
 from misago.users.models import Rank
 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_a not in test_rank.roles.all())
         self.assertTrue(test_role_c 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):
     def test_default_view(self):
         """default rank view has no showstoppers"""
         """default rank view has no showstoppers"""
         self.client.post(
         self.client.post(
@@ -260,6 +291,30 @@ class RankAdminViewsTests(AdminTestCase):
 
 
         self.assertNotContains(response, test_rank.name)
         self.assertNotContains(response, test_rank.name)
         self.assertNotContains(response, test_rank.title)
         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):
     def test_uniquess(self):
         """rank slug uniqueness is enforced by admin forms"""
         """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.contrib.auth import get_user_model
 from django.urls import reverse
 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
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -14,10 +14,9 @@ class SearchApiTests(AuthenticatedUserTestCase):
 
 
         self.api_link = reverse('misago:api:search')
         self.api_link = reverse('misago:api:search')
 
 
+    @patch_user_acl({'can_search_users': 0})
     def test_no_permission(self):
     def test_no_permission(self):
         """api respects permission to search users"""
         """api respects permission to search users"""
-        override_acl(self.user, {'can_search_users': 0})
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertNotIn('users', [p['id'] for p in response.json()])
         self.assertNotIn('users', [p['id'] for p in response.json()])
@@ -27,10 +26,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
-        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':
             if provider['id'] == 'users':
                 self.assertEqual(provider['results']['results'], [])
                 self.assertEqual(provider['results']['results'], [])
 
 
@@ -39,10 +38,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get('%s?q=' % self.api_link)
         response = self.client.get('%s?q=' % self.api_link)
         self.assertEqual(response.status_code, 200)
         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':
             if provider['id'] == 'users':
                 self.assertEqual(provider['results']['results'], [])
                 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]))
         response = self.client.get('%s?q=%s' % (self.api_link, self.user.username[0]))
         self.assertEqual(response.status_code, 200)
         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':
             if provider['id'] == 'users':
                 results = provider['results']['results']
                 results = provider['results']['results']
                 self.assertEqual(len(results), 1)
                 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))
         response = self.client.get('%s?q=%s' % (self.api_link, self.user.username))
         self.assertEqual(response.status_code, 200)
         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':
             if provider['id'] == 'users':
                 results = provider['results']['results']
                 results = provider['results']['results']
                 self.assertEqual(len(results), 1)
                 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:]))
         response = self.client.get('%s?q=%s' % (self.api_link, self.user.username[-3:]))
         self.assertEqual(response.status_code, 200)
         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':
             if provider['id'] == 'users':
                 results = provider['results']['results']
                 results = provider['results']['results']
                 self.assertEqual(len(results), 1)
                 self.assertEqual(len(results), 1)
@@ -93,10 +92,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get('%s?q=BobBoberson' % self.api_link)
         response = self.client.get('%s?q=BobBoberson' % self.api_link)
         self.assertEqual(response.status_code, 200)
         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':
             if provider['id'] == 'users':
                 self.assertEqual(provider['results']['results'], [])
                 self.assertEqual(provider['results']['results'], [])
 
 
@@ -112,10 +111,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get('%s?q=DisabledUser' % self.api_link)
         response = self.client.get('%s?q=DisabledUser' % self.api_link)
         self.assertEqual(response.status_code, 200)
         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':
             if provider['id'] == 'users':
                 self.assertEqual(provider['results']['results'], [])
                 self.assertEqual(provider['results']['results'], [])
 
 
@@ -126,10 +125,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         response = self.client.get('%s?q=DisabledUser' % self.api_link)
         response = self.client.get('%s?q=DisabledUser' % self.api_link)
         self.assertEqual(response.status_code, 200)
         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':
             if provider['id'] == 'users':
                 results = provider['results']['results']
                 results = provider['results']['results']
                 self.assertEqual(len(results), 1)
                 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.contrib.auth import get_user_model
 from django.test import TestCase
 from django.test import TestCase
 
 
+from misago.acl.useracl import get_user_acl
 from misago.users import signatures
 from misago.users import signatures
 
 
-
-UserModel = get_user_model()
+User = get_user_model()
+cache_versions = {"acl": "abcdefg"}
 
 
 
 
 class MockRequest(object):
 class MockRequest(object):
@@ -14,22 +17,45 @@ class MockRequest(object):
         return '127.0.0.1:8000'
         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_core.backends.github import GithubOAuth2
 from social_django.utils import load_strategy
 from social_django.utils import load_strategy
 
 
+from misago.acl import ACL_CACHE
+from misago.acl.useracl import get_user_acl
 from misago.core.exceptions import SocialAuthFailed, SocialAuthBanned
 from misago.core.exceptions import SocialAuthFailed, SocialAuthBanned
 from misago.legal.models import Agreement
 from misago.legal.models import Agreement
 
 
@@ -28,10 +30,11 @@ def create_request(user_ip='0.0.0.0', data=None):
     else:
     else:
         request = factory.post('/', data=json.dumps(data), content_type='application/json')
         request = factory.post('/', data=json.dumps(data), content_type='application/json')
     request.include_frontend_context = True
     request.include_frontend_context = True
-    request.cache_versions = {BANS_CACHE: "abcdefgh"}
+    request.cache_versions = {BANS_CACHE: "abcdefgh", ACL_CACHE: "abcdefgh"}
     request.frontend_context = {}
     request.frontend_context = {}
     request.session = {}
     request.session = {}
     request.user = AnonymousUser()
     request.user = AnonymousUser()
+    request.user_acl = get_user_acl(request.user, request.cache_versions)
     request.user_ip = user_ip
     request.user_ip = user_ip
     return request
     return request
 
 

+ 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 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.conf import settings
 from misago.users.avatars import gallery, store
 from misago.users.avatars import gallery, store
 from misago.users.models import AvatarGallery
 from misago.users.models import AvatarGallery
@@ -351,24 +351,18 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
 
 
         self.link = '/api/users/%s/moderate-avatar/' % self.other_user.pk
         self.link = '/api/users/%s/moderate-avatar/' % self.other_user.pk
 
 
+    @patch_user_acl({'can_moderate_avatars': 0})
     def test_no_permission(self):
     def test_no_permission(self):
         """no permission to moderate avatar"""
         """no permission to moderate avatar"""
-        override_acl(self.user, {
-            'can_moderate_avatars': 0,
-        })
-
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't moderate avatars.",
             "detail": "You can't moderate avatars.",
         })
         })
 
 
+    @patch_user_acl({'can_moderate_avatars': 1})
     def test_moderate_avatar(self):
     def test_moderate_avatar(self):
         """moderate avatar"""
         """moderate avatar"""
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
         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
             options['avatar_lock_staff_message'], self.other_user.avatar_lock_staff_message
         )
         )
 
 
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.post(
         response = self.client.post(
             self.link,
             self.link,
             json.dumps({
             json.dumps({
@@ -410,10 +400,6 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
         )
         )
 
 
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.post(
         response = self.client.post(
             self.link,
             self.link,
             json.dumps({
             json.dumps({
@@ -438,10 +424,6 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
         )
         )
 
 
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.post(
         response = self.client.post(
             self.link,
             self.link,
             json.dumps({
             json.dumps({
@@ -466,10 +448,6 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
         )
         )
 
 
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.post(
         response = self.client.post(
             self.link,
             self.link,
             json.dumps({
             json.dumps({
@@ -492,11 +470,8 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
         )
         )
 
 
+    @patch_user_acl({'can_moderate_avatars': 1})
     def test_moderate_own_avatar(self):
     def test_moderate_own_avatar(self):
         """moderate own avatar"""
         """moderate own avatar"""
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.get('/api/users/%s/moderate-avatar/' % self.user.pk)
         response = self.client.get('/api/users/%s/moderate-avatar/' % self.user.pk)
         self.assertEqual(response.status_code, 200)
         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.contrib.auth import get_user_model
 from django.urls import reverse
 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
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -56,22 +55,16 @@ class UserDetailsApiTests(AuthenticatedUserTestCase):
         )
         )
 
 
         # moderator has permission to edit details
         # 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
         # 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):
     def test_nonexistant_user(self):
         """api handles nonexistant users"""
         """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.contrib.auth import get_user_model
 from django.urls import reverse
 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
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -45,20 +44,14 @@ class UserEditDetailsApiTests(AuthenticatedUserTestCase):
         )
         )
 
 
         # moderator has permission to edit details
         # 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
         # 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):
     def test_nonexistant_user(self):
         """api handles nonexistant users"""
         """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
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -9,24 +9,18 @@ class UserSignatureTests(AuthenticatedUserTestCase):
         super().setUp()
         super().setUp()
         self.link = '/api/users/%s/signature/' % self.user.pk
         self.link = '/api/users/%s/signature/' % self.user.pk
 
 
+    @patch_user_acl({'can_have_signature': 0})
     def test_signature_no_permission(self):
     def test_signature_no_permission(self):
         """edit signature api with no ACL returns 403"""
         """edit signature api with no ACL returns 403"""
-        override_acl(self.user, {
-            'can_have_signature': 0,
-        })
-
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You don't have permission to change signature.",
             "detail": "You don't have permission to change signature.",
         })
         })
 
 
+    @patch_user_acl({'can_have_signature': 1})
     def test_signature_locked(self):
     def test_signature_locked(self):
         """locked edit signature returns 403"""
         """locked edit signature returns 403"""
-        override_acl(self.user, {
-            'can_have_signature': 1,
-        })
-
         self.user.is_signature_locked = True
         self.user.is_signature_locked = True
         self.user.signature_lock_user_message = 'Your siggy is banned.'
         self.user.signature_lock_user_message = 'Your siggy is banned.'
         self.user.save()
         self.user.save()
@@ -38,12 +32,9 @@ class UserSignatureTests(AuthenticatedUserTestCase):
             "reason": "<p>Your siggy is banned.</p>",
             "reason": "<p>Your siggy is banned.</p>",
         })
         })
 
 
+    @patch_user_acl({'can_have_signature': 1})
     def test_get_signature(self):
     def test_get_signature(self):
         """GET to api returns json with no signature"""
         """GET to api returns json with no signature"""
-        override_acl(self.user, {
-            'can_have_signature': 1,
-        })
-
         self.user.is_signature_locked = False
         self.user.is_signature_locked = False
         self.user.save()
         self.user.save()
 
 
@@ -52,12 +43,9 @@ class UserSignatureTests(AuthenticatedUserTestCase):
 
 
         self.assertFalse(response.json()['signature'])
         self.assertFalse(response.json()['signature'])
 
 
+    @patch_user_acl({'can_have_signature': 1})
     def test_post_empty_signature(self):
     def test_post_empty_signature(self):
         """empty POST empties user signature"""
         """empty POST empties user signature"""
-        override_acl(self.user, {
-            'can_have_signature': 1,
-        })
-
         self.user.is_signature_locked = False
         self.user.is_signature_locked = False
         self.user.save()
         self.user.save()
 
 
@@ -71,12 +59,9 @@ class UserSignatureTests(AuthenticatedUserTestCase):
 
 
         self.assertFalse(response.json()['signature'])
         self.assertFalse(response.json()['signature'])
 
 
+    @patch_user_acl({'can_have_signature': 1})
     def test_post_too_long_signature(self):
     def test_post_too_long_signature(self):
         """too long new signature errors"""
         """too long new signature errors"""
-        override_acl(self.user, {
-            'can_have_signature': 1,
-        })
-
         self.user.is_signature_locked = False
         self.user.is_signature_locked = False
         self.user.save()
         self.user.save()
 
 
@@ -91,12 +76,9 @@ class UserSignatureTests(AuthenticatedUserTestCase):
             "detail": "Signature is too long.",
             "detail": "Signature is too long.",
         })
         })
 
 
+    @patch_user_acl({'can_have_signature': 1})
     def test_post_good_signature(self):
     def test_post_good_signature(self):
         """POST with good signature changes user signature"""
         """POST with good signature changes user signature"""
-        override_acl(self.user, {
-            'can_have_signature': 1,
-        })
-
         self.user.is_signature_locked = False
         self.user.is_signature_locked = False
         self.user.save()
         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 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.conf import settings
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -117,34 +117,24 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
 
 
         self.link = '/api/users/%s/moderate-username/' % self.other_user.pk
         self.link = '/api/users/%s/moderate-username/' % self.other_user.pk
 
 
+    @patch_user_acl({'can_rename_users': 0})
     def test_no_permission(self):
     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)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't rename users.",
             "detail": "You can't rename users.",
         })
         })
 
 
-        override_acl(self.user, {
-            'can_rename_users': 0,
-        })
-
         response = self.client.post(self.link)
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't rename users.",
             "detail": "You can't rename users.",
         })
         })
 
 
+    @patch_user_acl({'can_rename_users': 1})
     def test_moderate_username(self):
     def test_moderate_username(self):
         """moderate username"""
         """moderate username"""
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
         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_min'], settings.username_length_min)
         self.assertEqual(options['length_max'], settings.username_length_max)
         self.assertEqual(options['length_max'], settings.username_length_max)
 
 
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.post(
         response = self.client.post(
             self.link,
             self.link,
             json.dumps({
             json.dumps({
@@ -168,10 +154,6 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             "detail": "Enter new username.",
             "detail": "Enter new username.",
         })
         })
 
 
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.post(
         response = self.client.post(
             self.link,
             self.link,
             json.dumps({
             json.dumps({
@@ -184,10 +166,6 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             "detail": "Username can only contain latin alphabet letters and digits.",
             "detail": "Username can only contain latin alphabet letters and digits.",
         })
         })
 
 
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.post(
         response = self.client.post(
             self.link,
             self.link,
             json.dumps({
             json.dumps({
@@ -200,10 +178,6 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             "detail": "Username must be at least 3 characters long.",
             "detail": "Username must be at least 3 characters long.",
         })
         })
 
 
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.post(
         response = self.client.post(
             self.link,
             self.link,
             json.dumps({
             json.dumps({
@@ -223,11 +197,8 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
         self.assertEqual(options['username'], other_user.username)
         self.assertEqual(options['username'], other_user.username)
         self.assertEqual(options['slug'], other_user.slug)
         self.assertEqual(options['slug'], other_user.slug)
 
 
+    @patch_user_acl({'can_rename_users': 1})
     def test_moderate_own_username(self):
     def test_moderate_own_username(self):
         """moderate own username"""
         """moderate own username"""
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.get('/api/users/%s/moderate-username/' % self.user.pk)
         response = self.client.get('/api/users/%s/moderate-username/' % self.user.pk)
         self.assertEqual(response.status_code, 200)
         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
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -7,40 +7,33 @@ class UsernameChangesApiTests(AuthenticatedUserTestCase):
         super().setUp()
         super().setUp()
         self.link = '/api/username-changes/'
         self.link = '/api/username-changes/'
 
 
+    @patch_user_acl({'can_see_users_name_history': False})
     def test_user_can_always_see_his_name_changes(self):
     def test_user_can_always_see_his_name_changes(self):
         """list returns own username changes"""
         """list returns own username changes"""
         self.user.set_username('NewUsername', self.user)
         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))
         response = self.client.get('%s?user=%s' % (self.link, self.user.pk))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.user.username)
         self.assertContains(response, self.user.username)
 
 
+    @patch_user_acl({'can_see_users_name_history': True})
     def test_list_handles_invalid_filter(self):
     def test_list_handles_invalid_filter(self):
         """list raises 404 for invalid filter"""
         """list raises 404 for invalid filter"""
         self.user.set_username('NewUsername', self.user)
         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)
         response = self.client.get('%s?user=abcd' % self.link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_user_acl({'can_see_users_name_history': True})
     def test_list_handles_nonexisting_user(self):
     def test_list_handles_nonexisting_user(self):
         """list raises 404 for invalid user id"""
         """list raises 404 for invalid user id"""
         self.user.set_username('NewUsername', self.user)
         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)
         response = self.client.get('%s?user=142141' % self.link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @patch_user_acl({'can_see_users_name_history': False})
     def test_list_handles_search(self):
     def test_list_handles_search(self):
         """list returns found username changes"""
         """list returns found username changes"""
         self.user.set_username('NewUsername', self.user)
         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))
         response = self.client.get('%s?user=%s&search=new' % (self.link, self.user.pk))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.user.username)
         self.assertContains(response, self.user.username)
@@ -49,10 +42,9 @@ class UsernameChangesApiTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json()["count"], 0)
         self.assertEqual(response.json()["count"], 0)
 
 
+    @patch_user_acl({'can_see_users_name_history': False})
     def test_list_denies_permission(self):
     def test_list_denies_permission(self):
         """list denies permission for other user (or all) if no access"""
         """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))
         response = self.client.get('%s?user=%s' % (self.link, self.user.pk + 1))
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         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.urls import reverse
 from django.utils.encoding import smart_str
 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.categories.models import Category
 from misago.core import threadstore
 from misago.core import threadstore
 from misago.core.cache import cache
 from misago.core.cache import cache
@@ -421,12 +421,9 @@ class UserFollowTests(AuthenticatedUserTestCase):
             "detail": "You can't add yourself to followed.",
             "detail": "You can't add yourself to followed.",
         })
         })
 
 
+    @patch_user_acl({'can_follow_users': 0})
     def test_cant_follow(self):
     def test_cant_follow(self):
         """no permission to follow users"""
         """no permission to follow users"""
-        override_acl(self.user, {
-            'can_follow_users': 0,
-        })
-
         response = self.client.post(self.link)
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
@@ -476,28 +473,25 @@ class UserBanTests(AuthenticatedUserTestCase):
 
 
         self.link = '/api/users/%s/ban/' % self.other_user.pk
         self.link = '/api/users/%s/ban/' % self.other_user.pk
 
 
+    @patch_user_acl({'can_see_ban_details': 0})
     def test_no_permission(self):
     def test_no_permission(self):
         """user has no permission to access ban"""
         """user has no permission to access ban"""
-        override_acl(self.user, {'can_see_ban_details': 0})
-
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't see users bans details.",
             "detail": "You can't see users bans details.",
         })
         })
 
 
+    @patch_user_acl({'can_see_ban_details': 1})
     def test_no_ban(self):
     def test_no_ban(self):
         """api returns empty json"""
         """api returns empty json"""
-        override_acl(self.user, {'can_see_ban_details': 1})
-
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), {})
         self.assertEqual(response.json(), {})
 
 
+    @patch_user_acl({'can_see_ban_details': 1})
     def test_ban_details(self):
     def test_ban_details(self):
         """api returns ban json"""
         """api returns ban json"""
-        override_acl(self.user, {'can_see_ban_details': 1})
-
         Ban.objects.create(
         Ban.objects.create(
             check_type=Ban.USERNAME,
             check_type=Ban.USERNAME,
             banned_value=self.other_user.username,
             banned_value=self.other_user.username,
@@ -604,30 +598,24 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         self.other_user.threads = 1
         self.other_user.threads = 1
         self.other_user.save()
         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):
     def test_delete_no_permission(self):
         """raises 403 error when no permission to delete"""
         """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)
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             'detail': "You can't delete users.",
             '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):
     def test_delete_too_many_posts(self):
         """raises 403 error when user has too many posts"""
         """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.posts = 6
         self.other_user.save()
         self.other_user.save()
 
 
@@ -637,15 +625,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete users that made more than 5 posts.",
             '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):
     def test_delete_too_old_member(self):
         """raises 403 error when user is too old"""
         """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.joined_on -= timedelta(days=6)
         self.other_user.save()
         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.",
             '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):
     def test_delete_self(self):
         """raises 403 error when attempting to delete oneself"""
         """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)
         response = self.client.post('/api/users/%s/delete/' % self.user.pk)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             'detail': "You can't delete your account.",
             '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):
     def test_delete_admin(self):
         """raises 403 error when attempting to delete admin"""
         """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.is_staff = True
         self.other_user.save()
         self.other_user.save()
 
 
@@ -689,15 +668,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete administrators.",
             '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):
     def test_delete_superadmin(self):
         """raises 403 error when attempting to delete superadmin"""
         """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.is_superuser = True
         self.other_user.save()
         self.other_user.save()
 
 
@@ -707,15 +683,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete administrators.",
             '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):
     def test_delete_with_content(self):
         """returns 200 and deletes user with content"""
         """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(
         response = self.client.post(
             self.link,
             self.link,
             json.dumps({
             json.dumps({
@@ -731,15 +704,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         self.assertEqual(Thread.objects.count(), self.threads)
         self.assertEqual(Thread.objects.count(), self.threads)
         self.assertEqual(Post.objects.count(), self.posts)
         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):
     def test_delete_without_content(self):
         """returns 200 and deletes user without content"""
         """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(
         response = self.client.post(
             self.link,
             self.link,
             json.dumps({
             json.dumps({

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

@@ -6,7 +6,7 @@ from .threads import UserThreads
 
 
 class UserPosts(UserThreads):
 class UserPosts(UserThreads):
     def get_threads_queryset(self, request, threads_categories, profile):
     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):
     def get_posts_queryset(self, user, profile, threads_queryset):
         return profile.post_set.select_related('thread', 'poster').filter(
         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.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.threads.permissions import exclude_invisible_threads
 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_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
         self._user = request.user
 
 
@@ -42,7 +42,7 @@ class UserThreads(object):
         self.paginator = paginator
         self.paginator = paginator
 
 
     def get_threads_queryset(self, request, threads_categories, profile):
     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):
     def get_posts_queryset(self, user, profile, threads_queryset):
         return profile.post_set.select_related('thread', 'poster').filter(
         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.shortcuts import redirect
 from django.utils.translation import gettext_lazy as _
 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.auth import start_admin_session
 from misago.admin.views import generic
 from misago.admin.views import generic
 from misago.categories.models import Category
 from misago.categories.models import Category
@@ -325,7 +326,10 @@ class EditUser(UserAdmin, generic.ModelFormView):
         target.roles.clear()
         target.roles.clear()
         target.roles.add(*form.cleaned_data['roles'])
         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)
         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):
 class ListView(View):
     def get(self, request, *args, **kwargs):
     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)
         context_data = self.get_context_data(request, *args, **kwargs)
 
 
@@ -62,7 +62,7 @@ class ListView(View):
 
 
 
 
 def landing(request):
 def landing(request):
-    allow_browse_users_list(request.user)
+    allow_browse_users_list(request.user_acl)
     return redirect(users_list.get_default_link())
     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.shortcuts import get_object_or_404, redirect, render
 from django.views import View
 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.core.shortcuts import paginate, pagination_dict, validate_slug
 from misago.users.bans import get_user_ban
 from misago.users.bans import get_user_ban
 from misago.users.online.utils import get_user_status
 from misago.users.online.utils import get_user_status
@@ -44,7 +44,7 @@ class ProfileView(View):
             raise Http404()
             raise Http404()
 
 
         validate_slug(profile, slug)
         validate_slug(profile, slug)
-        add_acl(request.user, profile)
+        add_acl_to_obj(request.user_acl, profile)
 
 
         return profile
         return profile
 
 
@@ -67,7 +67,7 @@ class ProfileView(View):
             })
             })
 
 
         request.frontend_context['PROFILE'] = UserProfileSerializer(
         request.frontend_context['PROFILE'] = UserProfileSerializer(
-            profile, context={'user': request.user}
+            profile, context={'request': request}
         ).data
         ).data
 
 
         if not profile.is_active:
         if not profile.is_active:
@@ -92,7 +92,7 @@ class ProfileView(View):
             })
             })
 
 
             if not context['show_email']:
             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:
         else:
             context.update({
             context.update({
                 'is_authenticated_user': False,
                 'is_authenticated_user': False,