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

Merge pull request #1141 from rafalp/remove-global-state

Remove global state
Rafał Pitoń 6 лет назад
Родитель
Сommit
948eb54805
300 измененных файлов с 5802 добавлено и 5797 удалено
  1. 6 3
      devproject/settings.py
  2. 1 2
      devproject/test_settings.py
  3. 2 2
      misago/acl/__init__.py
  4. 0 65
      misago/acl/api.py
  5. 1 0
      misago/acl/apps.py
  6. 0 0
      misago/acl/buildacl.py
  7. 23 0
      misago/acl/cache.py
  8. 0 1
      misago/acl/constants.py
  9. 2 0
      misago/acl/context_processors.py
  10. 12 0
      misago/acl/middleware.py
  11. 2 10
      misago/acl/migrations/0002_acl_version_tracker.py
  12. 17 0
      misago/acl/migrations/0004_cache_version.py
  13. 3 3
      misago/acl/models.py
  14. 18 0
      misago/acl/objectacl.py
  15. 1 1
      misago/acl/panels.py
  16. 15 13
      misago/acl/providers.py
  17. 56 0
      misago/acl/test.py
  18. 0 26
      misago/acl/tests/test_api.py
  19. 94 0
      misago/acl/tests/test_getting_user_acl.py
  20. 79 0
      misago/acl/tests/test_patching_user_acl.py
  21. 46 73
      misago/acl/tests/test_providers.py
  22. 37 0
      misago/acl/tests/test_roleadmin_views.py
  23. 24 0
      misago/acl/tests/test_serializing_user_acl.py
  24. 0 1
      misago/acl/tests/test_testutils.py
  25. 12 0
      misago/acl/tests/test_user_acl_context_processor.py
  26. 30 0
      misago/acl/tests/test_user_acl_middleware.py
  27. 0 19
      misago/acl/testutils.py
  28. 30 0
      misago/acl/useracl.py
  29. 0 15
      misago/acl/version.py
  30. 17 18
      misago/admin/views/generic/list.py
  31. 1 0
      misago/cache/__init__.py
  32. 7 0
      misago/cache/apps.py
  33. 0 0
      misago/cache/management/__init__.py
  34. 0 0
      misago/cache/management/commands/__init__.py
  35. 11 0
      misago/cache/management/commands/invalidateversionedcaches.py
  36. 10 0
      misago/cache/middleware.py
  37. 22 0
      misago/cache/migrations/0001_initial.py
  38. 0 0
      misago/cache/migrations/__init__.py
  39. 8 0
      misago/cache/models.py
  40. 29 0
      misago/cache/operations.py
  41. 21 0
      misago/cache/test.py
  42. 0 0
      misago/cache/tests/__init__.py
  43. 5 0
      misago/cache/tests/conftest.py
  44. 25 0
      misago/cache/tests/test_assert_invalidates_cache.py
  45. 22 0
      misago/cache/tests/test_cache_versions_middleware.py
  46. 45 0
      misago/cache/tests/test_getting_cache_versions.py
  47. 11 0
      misago/cache/tests/test_invalidate_caches_management_command.py
  48. 38 0
      misago/cache/tests/test_invalidating_caches.py
  49. 5 0
      misago/cache/utils.py
  50. 38 0
      misago/cache/versions.py
  51. 1 1
      misago/categories/api.py
  52. 2 2
      misago/categories/management/commands/fixcategoriestree.py
  53. 2 2
      misago/categories/models.py
  54. 11 13
      misago/categories/permissions.py
  55. 58 0
      misago/categories/tests/test_categories_admin_views.py
  56. 4 3
      misago/categories/tests/test_category_model.py
  57. 7 0
      misago/categories/tests/test_fixcategoriestree.py
  58. 67 0
      misago/categories/tests/test_permissions_admin_views.py
  59. 19 13
      misago/categories/tests/test_utils.py
  60. 26 46
      misago/categories/tests/test_views.py
  61. 6 6
      misago/categories/utils.py
  62. 2 2
      misago/categories/views/categoriesadmin.py
  63. 1 1
      misago/categories/views/categorieslist.py
  64. 3 3
      misago/categories/views/permsadmin.py
  65. 5 1
      misago/conf/__init__.py
  66. 23 0
      misago/conf/cache.py
  67. 22 24
      misago/conf/context_processors.py
  68. 0 96
      misago/conf/dbsettings.py
  69. 63 0
      misago/conf/dynamicsettings.py
  70. 1 2
      misago/conf/forms.py
  71. 0 22
      misago/conf/gateway.py
  72. 14 0
      misago/conf/middleware.py
  73. 17 0
      misago/conf/migrations/0002_cache_version.py
  74. 0 7
      misago/conf/migrationutils.py
  75. 18 0
      misago/conf/staticsettings.py
  76. 21 0
      misago/conf/test.py
  77. 11 17
      misago/conf/tests/test_context_processors.py
  78. 50 0
      misago/conf/tests/test_dynamic_settings_middleware.py
  79. 167 0
      misago/conf/tests/test_getting_dynamic_settings_values.py
  80. 33 0
      misago/conf/tests/test_getting_static_settings_values.py
  81. 0 4
      misago/conf/tests/test_migrationutils.py
  82. 68 0
      misago/conf/tests/test_overridding_dynamic_settings.py
  83. 0 132
      misago/conf/tests/test_settings.py
  84. 2 2
      misago/conf/views.py
  85. 10 0
      misago/conftest.py
  86. 0 104
      misago/core/cachebuster.py
  87. 4 3
      misago/core/mail.py
  88. 1 7
      misago/core/middleware.py
  89. 0 24
      misago/core/migrationutils.py
  90. 0 73
      misago/core/tests/test_cachebuster.py
  91. 17 12
      misago/core/tests/test_errorpages.py
  92. 20 12
      misago/core/tests/test_exceptionhandler_middleware.py
  93. 32 3
      misago/core/tests/test_mail.py
  94. 0 26
      misago/core/tests/test_migrationutils.py
  95. 0 41
      misago/core/tests/test_threadstore.py
  96. 0 20
      misago/core/testutils.py
  97. 0 17
      misago/core/threadstore.py
  98. 2 2
      misago/faker/management/commands/createfakecategories.py
  99. 3 1
      misago/markup/api.py
  100. 4 4
      misago/markup/flavours.py
  101. 2 1
      misago/markup/serializers.py
  102. 3 3
      misago/readtracker/categoriestracker.py
  103. 26 26
      misago/readtracker/tests/test_categoriestracker.py
  104. 24 24
      misago/readtracker/tests/test_threadstracker.py
  105. 2 2
      misago/readtracker/threadstracker.py
  106. 1 1
      misago/search/api.py
  107. 1 1
      misago/search/context_processors.py
  108. 2 3
      misago/search/tests/test_api.py
  109. 6 10
      misago/search/tests/test_views.py
  110. 2 2
      misago/search/views.py
  111. 3 3
      misago/templates/misago/base.html
  112. 12 12
      misago/templates/misago/categories/base.html
  113. 3 3
      misago/templates/misago/categories/header.html
  114. 2 2
      misago/templates/misago/emails/base.html
  115. 2 2
      misago/templates/misago/emails/base.txt
  116. 3 3
      misago/templates/misago/footer.html
  117. 4 4
      misago/templates/misago/index.html
  118. 5 5
      misago/templates/misago/navbar.html
  119. 1 1
      misago/templates/misago/threadslist/tabs.html
  120. 11 11
      misago/templates/misago/threadslist/threads.html
  121. 4 4
      misago/threads/api/attachments.py
  122. 3 3
      misago/threads/api/pollvotecreateendpoint.py
  123. 4 4
      misago/threads/api/postendpoints/delete.py
  124. 3 3
      misago/threads/api/postendpoints/edits.py
  125. 3 3
      misago/threads/api/postendpoints/merge.py
  126. 4 4
      misago/threads/api/postendpoints/patch_event.py
  127. 10 8
      misago/threads/api/postendpoints/patch_post.py
  128. 1 1
      misago/threads/api/postendpoints/read.py
  129. 2 1
      misago/threads/api/postendpoints/split.py
  130. 7 1
      misago/threads/api/postingendpoint/__init__.py
  131. 6 5
      misago/threads/api/postingendpoint/attachments.py
  132. 10 10
      misago/threads/api/postingendpoint/category.py
  133. 9 6
      misago/threads/api/postingendpoint/emailnotification.py
  134. 4 2
      misago/threads/api/postingendpoint/floodprotection.py
  135. 12 3
      misago/threads/api/postingendpoint/participants.py
  136. 2 2
      misago/threads/api/postingendpoint/privatethread.py
  137. 11 3
      misago/threads/api/postingendpoint/reply.py
  138. 4 4
      misago/threads/api/postingendpoint/subscribe.py
  139. 1 1
      misago/threads/api/threadendpoints/delete.py
  140. 4 4
      misago/threads/api/threadendpoints/editor.py
  141. 6 5
      misago/threads/api/threadendpoints/merge.py
  142. 25 23
      misago/threads/api/threadendpoints/patch.py
  143. 8 8
      misago/threads/api/threadpoll.py
  144. 9 9
      misago/threads/api/threadposts.py
  145. 2 2
      misago/threads/api/threads.py
  146. 3 3
      misago/threads/middleware.py
  147. 1 4
      misago/threads/migrations/0004_update_settings.py
  148. 5 2
      misago/threads/participants.py
  149. 4 4
      misago/threads/permissions/attachments.py
  150. 29 29
      misago/threads/permissions/bestanswers.py
  151. 37 37
      misago/threads/permissions/polls.py
  152. 25 25
      misago/threads/permissions/privatethreads.py
  153. 153 152
      misago/threads/permissions/threads.py
  154. 1 1
      misago/threads/search.py
  155. 30 27
      misago/threads/serializers/moderation.py
  156. 109 0
      misago/threads/test.py
  157. 4 1
      misago/threads/tests/test_anonymize_data.py
  158. 4 14
      misago/threads/tests/test_attachments_api.py
  159. 59 31
      misago/threads/tests/test_attachments_middleware.py
  160. 27 21
      misago/threads/tests/test_attachmentview.py
  161. 34 36
      misago/threads/tests/test_emailnotification_middleware.py
  162. 13 14
      misago/threads/tests/test_events.py
  163. 18 14
      misago/threads/tests/test_floodprotection.py
  164. 9 7
      misago/threads/tests/test_floodprotection_middleware.py
  165. 6 13
      misago/threads/tests/test_gotoviews.py
  166. 0 18
      misago/threads/tests/test_post_mentions.py
  167. 13 19
      misago/threads/tests/test_privatethread_patch_api.py
  168. 41 45
      misago/threads/tests/test_privatethread_start_api.py
  169. 4 7
      misago/threads/tests/test_privatethread_view.py
  170. 0 32
      misago/threads/tests/test_privatethreads.py
  171. 35 25
      misago/threads/tests/test_privatethreads_api.py
  172. 7 9
      misago/threads/tests/test_privatethreads_lists.py
  173. 21 24
      misago/threads/tests/test_subscription_middleware.py
  174. 18 49
      misago/threads/tests/test_thread_bulkpatch_api.py
  175. 70 95
      misago/threads/tests/test_thread_editreply_api.py
  176. 98 186
      misago/threads/tests/test_thread_merge_api.py
  177. 226 368
      misago/threads/tests/test_thread_patch_api.py
  178. 0 25
      misago/threads/tests/test_thread_poll_api.py
  179. 28 14
      misago/threads/tests/test_thread_pollcreate_api.py
  180. 24 16
      misago/threads/tests/test_thread_polldelete_api.py
  181. 23 16
      misago/threads/tests/test_thread_polledit_api.py
  182. 22 18
      misago/threads/tests/test_thread_pollvotes_api.py
  183. 45 82
      misago/threads/tests/test_thread_postbulkdelete_api.py
  184. 4 25
      misago/threads/tests/test_thread_postbulkpatch_api.py
  185. 54 64
      misago/threads/tests/test_thread_postdelete_api.py
  186. 6 6
      misago/threads/tests/test_thread_postedits_api.py
  187. 5 4
      misago/threads/tests/test_thread_postlikes_api.py
  188. 59 49
      misago/threads/tests/test_thread_postmerge_api.py
  189. 50 94
      misago/threads/tests/test_thread_postmove_api.py
  190. 90 173
      misago/threads/tests/test_thread_postpatch_api.py
  191. 52 94
      misago/threads/tests/test_thread_postsplit_api.py
  192. 42 59
      misago/threads/tests/test_thread_reply_api.py
  193. 32 81
      misago/threads/tests/test_thread_start_api.py
  194. 71 140
      misago/threads/tests/test_threads_api.py
  195. 17 45
      misago/threads/tests/test_threads_bulkdelete_api.py
  196. 139 221
      misago/threads/tests/test_threads_editor_api.py
  197. 59 198
      misago/threads/tests/test_threads_merge_api.py
  198. 124 290
      misago/threads/tests/test_threadslists.py
  199. 144 160
      misago/threads/tests/test_threadview.py
  200. 4 6
      misago/threads/tests/test_utils.py
  201. 33 26
      misago/threads/tests/test_validators.py
  202. 26 24
      misago/threads/validators.py
  203. 6 6
      misago/threads/viewmodels/category.py
  204. 3 3
      misago/threads/viewmodels/post.py
  205. 5 5
      misago/threads/viewmodels/posts.py
  206. 8 8
      misago/threads/viewmodels/thread.py
  207. 17 16
      misago/threads/viewmodels/threads.py
  208. 1 1
      misago/threads/views/attachment.py
  209. 6 3
      misago/threads/views/goto.py
  210. 13 10
      misago/users/api/auth.py
  211. 3 5
      misago/users/api/captcha.py
  212. 22 12
      misago/users/api/userendpoints/avatar.py
  213. 11 3
      misago/users/api/userendpoints/changeemail.py
  214. 8 4
      misago/users/api/userendpoints/changepassword.py
  215. 5 6
      misago/users/api/userendpoints/create.py
  216. 23 19
      misago/users/api/userendpoints/signature.py
  217. 54 39
      misago/users/api/userendpoints/username.py
  218. 1 1
      misago/users/api/usernamechanges.py
  219. 12 12
      misago/users/api/users.py
  220. 3 3
      misago/users/apps.py
  221. 22 23
      misago/users/avatars/uploaded.py
  222. 8 11
      misago/users/bans.py
  223. 10 10
      misago/users/captcha.py
  224. 1 1
      misago/users/constants.py
  225. 5 2
      misago/users/context_processors.py
  226. 21 27
      misago/users/forms/admin.py
  227. 5 1
      misago/users/forms/auth.py
  228. 8 5
      misago/users/forms/register.py
  229. 15 10
      misago/users/management/commands/createsuperuser.py
  230. 5 3
      misago/users/management/commands/invalidatebans.py
  231. 6 0
      misago/users/management/commands/prepareuserdatadownloads.py
  232. 4 1
      misago/users/middleware.py
  233. 2 10
      misago/users/migrations/0003_bans_version_tracker.py
  234. 1 4
      misago/users/migrations/0006_update_settings.py
  235. 16 0
      misago/users/migrations/0016_cache_version.py
  236. 32 0
      misago/users/migrations/0017_move_bans_to_cache_version.py
  237. 2 1
      misago/users/models/__init__.py
  238. 8 9
      misago/users/models/ban.py
  239. 19 0
      misago/users/models/online.py
  240. 5 3
      misago/users/models/rank.py
  241. 65 123
      misago/users/models/user.py
  242. 41 29
      misago/users/namechanges.py
  243. 24 23
      misago/users/online/utils.py
  244. 13 11
      misago/users/permissions/decorators.py
  245. 7 7
      misago/users/permissions/delete.py
  246. 28 28
      misago/users/permissions/moderation.py
  247. 13 15
      misago/users/permissions/profiles.py
  248. 1 1
      misago/users/profilefields/default.py
  249. 1 1
      misago/users/profilefields/serializers.py
  250. 9 2
      misago/users/registration.py
  251. 1 1
      misago/users/search.py
  252. 9 7
      misago/users/serializers/auth.py
  253. 8 6
      misago/users/serializers/options.py
  254. 6 3
      misago/users/serializers/user.py
  255. 29 0
      misago/users/setupnewuser.py
  256. 2 2
      misago/users/signatures.py
  257. 15 11
      misago/users/social/pipeline.py
  258. 15 15
      misago/users/tests/test_activation_views.py
  259. 0 11
      misago/users/tests/test_activepostersranking.py
  260. 12 10
      misago/users/tests/test_avatars.py
  261. 18 14
      misago/users/tests/test_bans.py
  262. 10 11
      misago/users/tests/test_captcha_api.py
  263. 45 0
      misago/users/tests/test_getting_user_status.py
  264. 4 2
      misago/users/tests/test_invalidatebans.py
  265. 4 10
      misago/users/tests/test_joinip_profilefield.py
  266. 8 20
      misago/users/tests/test_lists_views.py
  267. 5 8
      misago/users/tests/test_mention_api.py
  268. 68 16
      misago/users/tests/test_namechanges.py
  269. 70 0
      misago/users/tests/test_new_user_setup.py
  270. 0 36
      misago/users/tests/test_online_utils.py
  271. 29 36
      misago/users/tests/test_profile_views.py
  272. 55 0
      misago/users/tests/test_rankadmin_views.py
  273. 26 27
      misago/users/tests/test_search.py
  274. 43 16
      misago/users/tests/test_signatures.py
  275. 45 21
      misago/users/tests/test_social_pipeline.py
  276. 35 60
      misago/users/tests/test_user_avatar_api.py
  277. 46 25
      misago/users/tests/test_user_create_api.py
  278. 80 0
      misago/users/tests/test_user_creation.py
  279. 9 16
      misago/users/tests/test_user_details_api.py
  280. 7 14
      misago/users/tests/test_user_editdetails_api.py
  281. 62 0
      misago/users/tests/test_user_getters.py
  282. 0 74
      misago/users/tests/test_user_model.py
  283. 7 25
      misago/users/tests/test_user_signature_api.py
  284. 12 39
      misago/users/tests/test_user_username_api.py
  285. 69 90
      misago/users/tests/test_useradmin_views.py
  286. 6 14
      misago/users/tests/test_usernamechanges_api.py
  287. 56 91
      misago/users/tests/test_users_api.py
  288. 12 8
      misago/users/tests/test_validators.py
  289. 34 12
      misago/users/testutils.py
  290. 18 16
      misago/users/validators.py
  291. 1 1
      misago/users/viewmodels/activeposters.py
  292. 1 1
      misago/users/viewmodels/followers.py
  293. 1 1
      misago/users/viewmodels/posts.py
  294. 1 1
      misago/users/viewmodels/rankusers.py
  295. 4 4
      misago/users/viewmodels/threads.py
  296. 1 1
      misago/users/views/activation.py
  297. 37 15
      misago/users/views/admin/users.py
  298. 1 1
      misago/users/views/forgottenpassword.py
  299. 2 2
      misago/users/views/lists.py
  300. 6 6
      misago/users/views/profile.py

+ 6 - 3
devproject/settings.py

@@ -188,6 +188,7 @@ INSTALLED_APPS = [
     # Misago apps
     'misago.admin',
     'misago.acl',
+    'misago.cache',
     'misago.core',
     'misago.conf',
     'misago.markup',
@@ -223,12 +224,14 @@ MIDDLEWARE = [
     'django.contrib.messages.middleware.MessageMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
 
+    'misago.cache.middleware.cache_versions_middleware',
+    'misago.conf.middleware.dynamic_settings_middleware',
     'misago.users.middleware.UserMiddleware',
+    'misago.acl.middleware.user_acl_middleware',
     'misago.core.middleware.ExceptionHandlerMiddleware',
     'misago.users.middleware.OnlineTrackerMiddleware',
     'misago.admin.middleware.AdminAuthMiddleware',
     'misago.threads.middleware.UnreadThreadsCountMiddleware',
-    'misago.core.middleware.ThreadStoreMiddleware',
 ]
 
 ROOT_URLCONF = 'devproject.urls'
@@ -283,12 +286,12 @@ TEMPLATES = [
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.messages.context_processors.messages',
 
+                'misago.acl.context_processors.user_acl',
+                'misago.conf.context_processors.conf',
                 'misago.core.context_processors.site_address',
                 'misago.core.context_processors.momentjs_locale',
-                'misago.conf.context_processors.settings',
                 'misago.search.context_processors.search_providers',
                 'misago.users.context_processors.user_links',
-                'misago.legal.context_processors.legal_links',
 
                 # Data preloaders
                 'misago.conf.context_processors.preload_settings_json',

+ 1 - 2
devproject/test_settings.py

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

+ 2 - 2
misago/acl/__init__.py

@@ -1,3 +1,3 @@
-from .api import get_user_acl, add_acl, serialize_acl
-
 default_app_config = 'misago.acl.apps.MisagoACLsConfig'
+
+ACL_CACHE = "acl"

+ 0 - 65
misago/acl/api.py

@@ -1,65 +0,0 @@
-"""
-Module functions for ACLs
-
-Workflow for ACLs in Misago is simple:
-
-First, you get user ACL. Its directory that you can introspect to find out user
-permissions, or if you have objects, you can use this acl to make those objects
-aware of their ACLs. This gives objects themselves special "acl" attribute with
-properties defined by ACL providers within their "add_acl_to_target"
-"""
-import copy
-
-from misago.core import threadstore
-from misago.core.cache import cache
-
-from . import version
-from .builder import build_acl
-from .providers import providers
-
-
-def get_user_acl(user):
-    """get ACL for User"""
-    acl_key = 'acl_%s' % user.acl_key
-
-    acl_cache = threadstore.get(acl_key)
-    if not acl_cache:
-        acl_cache = cache.get(acl_key)
-
-    if acl_cache and version.is_valid(acl_cache.get('_acl_version')):
-        return acl_cache
-    else:
-        new_acl = build_acl(user.get_roles())
-        new_acl['_acl_version'] = version.get_version()
-
-        threadstore.set(acl_key, new_acl)
-        cache.set(acl_key, new_acl)
-
-        return new_acl
-
-
-def add_acl(user, target):
-    """add valid ACL to target (iterable of objects or single object)"""
-    if hasattr(target, '__iter__'):
-        for item in target:
-            _add_acl_to_target(user, item)
-    else:
-        _add_acl_to_target(user, target)
-
-
-def _add_acl_to_target(user, target):
-    """add valid ACL to single target, helper for add_acl function"""
-    target.acl = {}
-
-    for annotator in providers.get_obj_type_annotators(target):
-        annotator(user, target)
-
-
-def serialize_acl(target):
-    """serialize authenticated user's ACL"""
-    serialized_acl = copy.deepcopy(target.acl_cache)
-
-    for serializer in providers.get_obj_type_serializers(target):
-        serializer(serialized_acl)
-
-    return serialized_acl

+ 1 - 0
misago/acl/apps.py

@@ -1,6 +1,7 @@
 from django.apps import AppConfig
 from .providers import providers
 
+
 class MisagoACLsConfig(AppConfig):
     name = 'misago.acl'
     label = 'misago_acl'

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


+ 23 - 0
misago/acl/cache.py

@@ -0,0 +1,23 @@
+from django.core.cache import cache
+
+from misago.cache.versions import invalidate_cache
+
+from . import ACL_CACHE
+
+
+def get_acl_cache(user, cache_versions):
+    key = get_cache_key(user, cache_versions)
+    return cache.get(key)
+
+
+def set_acl_cache(user, cache_versions, user_acl):
+    key = get_cache_key(user, cache_versions)
+    cache.set(key, user_acl)
+
+
+def get_cache_key(user, cache_versions):
+    return 'acl_%s_%s' % (user.acl_key, cache_versions[ACL_CACHE])
+
+
+def clear_acl_cache():
+    invalidate_cache(ACL_CACHE)

+ 0 - 1
misago/acl/constants.py

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

+ 2 - 0
misago/acl/context_processors.py

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

+ 12 - 0
misago/acl/middleware.py

@@ -0,0 +1,12 @@
+from django.utils.functional import SimpleLazyObject
+
+from . import useracl
+
+
+def user_acl_middleware(get_response):
+    """Sets request.user_acl attribute with dict containing current user acl."""
+    def middleware(request):
+        request.user_acl = useracl.get_user_acl(request.user, request.cache_versions)
+        return get_response(request)
+
+    return middleware

+ 2 - 10
misago/acl/migrations/0002_acl_version_tracker.py

@@ -1,20 +1,12 @@
 from django.db import migrations
 
-from misago.acl.constants import ACL_CACHEBUSTER
-from misago.core.migrationutils import cachebuster_register_cache
-
-
-def register_acl_version_tracker(apps, schema_editor):
-    cachebuster_register_cache(apps, ACL_CACHEBUSTER)
-
 
 class Migration(migrations.Migration):
+    """Superseded by 0004"""
 
     dependencies = [
         ('misago_acl', '0001_initial'),
         ('misago_core', '0001_initial'),
     ]
 
-    operations = [
-        migrations.RunPython(register_acl_version_tracker),
-    ]
+    operations = []

+ 17 - 0
misago/acl/migrations/0004_cache_version.py

@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+from django.db import migrations
+
+from misago.acl import ACL_CACHE
+from misago.cache.operations import StartCacheVersioning
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('misago_acl', '0003_default_roles'),
+        ('misago_cache', '0001_initial'),
+    ]
+
+    operations = [
+        StartCacheVersioning(ACL_CACHE)
+    ]

+ 3 - 3
misago/acl/models.py

@@ -2,7 +2,7 @@ from django.contrib.postgres.fields import JSONField
 from django.db import models
 from django.utils.translation import gettext as _
 
-from . import version as acl_version
+from .cache import clear_acl_cache
 
 
 def permissions_default():
@@ -22,11 +22,11 @@ class BaseRole(models.Model):
 
     def save(self, *args, **kwargs):
         if self.pk:
-            acl_version.invalidate()
+            clear_acl_cache()
         return super().save(*args, **kwargs)
 
     def delete(self, *args, **kwargs):
-        acl_version.invalidate()
+        clear_acl_cache()
         return super().delete(*args, **kwargs)
 
 

+ 18 - 0
misago/acl/objectacl.py

@@ -0,0 +1,18 @@
+from .providers import providers
+
+
+def add_acl_to_obj(user_acl, obj):
+    """add valid ACL to obj (iterable of objects or single object)"""
+    if hasattr(obj, '__iter__'):
+        for item in obj:
+            _add_acl_to_obj(user_acl, item)
+    else:
+        _add_acl_to_obj(user_acl, obj)
+
+
+def _add_acl_to_obj(user_acl, obj):
+    """add valid ACL to single obj, helper for add_acl function"""
+    obj.acl = {}
+
+    for annotator in providers.get_obj_type_annotators(obj):
+        annotator(user_acl, obj)

+ 1 - 1
misago/acl/panels.py

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

+ 15 - 13
misago/acl/providers.py

@@ -5,13 +5,13 @@ from misago.conf import settings
 
 _NOT_INITIALIZED_ERROR = (
     "PermissionProviders instance has to load providers with load() "
-    "before get_obj_type_annotators(), get_obj_type_serializers(), "
+    "before get_obj_type_annotators(), get_user_acl_serializers(), "
     "list() or dict() methods will be available."
 )
 
 _ALREADY_INITIALIZED_ERROR = (
     "PermissionProviders instance has already loaded providers and "
-    "acl_annotator or acl_serializer are no longer available."
+    "acl_annotator or user_acl_serializer are no longer available."
 )
 
 
@@ -24,14 +24,16 @@ class PermissionProviders(object):
         self._providers_dict = {}
 
         self._annotators = {}
-        self._serializers = {}
+        self._user_acl_serializers = []
 
     def load(self):
-        if not self._initialized:
-            self._register_providers()
-            self._change_lists_to_tupes(self._annotators)
-            self._change_lists_to_tupes(self._serializers)
-            self._initialized = True
+        if self._initialized:
+            raise RuntimeError("providers are already loaded")
+
+        self._register_providers()
+        self._coerce_dict_values_to_tuples(self._annotators)
+        self._user_acl_serializers = tuple(self._user_acl_serializers)
+        self._initialized = True
 
     def _register_providers(self):
         for namespace in settings.MISAGO_ACL_EXTENSIONS:
@@ -41,7 +43,7 @@ class PermissionProviders(object):
             if hasattr(self._providers_dict[namespace], 'register_with'):
                 self._providers_dict[namespace].register_with(self)
 
-    def _change_lists_to_tupes(self, types_dict):
+    def _coerce_dict_values_to_tuples(self, types_dict):
         for hashType in types_dict.keys():
             types_dict[hashType] = tuple(types_dict[hashType])
 
@@ -50,18 +52,18 @@ class PermissionProviders(object):
         assert not self._initialized, _ALREADY_INITIALIZED_ERROR
         self._annotators.setdefault(hashable_type, []).append(func)
 
-    def acl_serializer(self, hashable_type, func):
+    def user_acl_serializer(self, func):
         """registers ACL serializer for specified types"""
         assert not self._initialized, _ALREADY_INITIALIZED_ERROR
-        self._serializers.setdefault(hashable_type, []).append(func)
+        self._user_acl_serializers.append(func)
 
     def get_obj_type_annotators(self, obj):
         assert self._initialized, _NOT_INITIALIZED_ERROR
         return self._annotators.get(obj.__class__, [])
 
-    def get_obj_type_serializers(self, obj):
+    def get_user_acl_serializers(self):
         assert self._initialized, _NOT_INITIALIZED_ERROR
-        return self._serializers.get(obj.__class__, [])
+        return self._user_acl_serializers
 
     def list(self):
         assert self._initialized, _NOT_INITIALIZED_ERROR

+ 56 - 0
misago/acl/test.py

@@ -0,0 +1,56 @@
+from contextlib import ContextDecorator, ExitStack, contextmanager
+from functools import wraps
+from unittest.mock import patch
+
+from .useracl import get_user_acl
+
+__all__ = ["patch_user_acl"]
+
+
+class patch_user_acl(ContextDecorator, ExitStack):
+    """Testing utility that patches get_user_acl results
+
+    Can be used as decorator or context manager.
+
+    Patch should be a dict or callable.
+    """
+
+    _acl_patches = []
+
+    def __init__(self, acl_patch):
+        super().__init__()
+        self.acl_patch = acl_patch
+
+    def patched_get_user_acl(self, user, cache_versions):
+        user_acl = get_user_acl(user, cache_versions)
+        self.apply_acl_patches(user, user_acl)
+        return user_acl
+
+    def apply_acl_patches(self, user, user_acl):
+        for acl_patch in self._acl_patches:
+            self.apply_acl_patch(user, user_acl, acl_patch)
+
+    def apply_acl_patch(self, user, user_acl, acl_patch):
+        if callable(acl_patch):
+            acl_patch(user, user_acl)
+        else:
+            user_acl.update(acl_patch)
+
+    def __enter__(self):
+        super().__enter__()
+        self.enter_context(self.enable_acl_patch())
+        self.enter_context(self.patch_user_acl())
+
+    @contextmanager
+    def enable_acl_patch(self):
+        try:
+            self._acl_patches.append(self.acl_patch)
+            yield
+        finally:
+            self._acl_patches.pop(-1)
+
+    def patch_user_acl(self):
+        return patch(
+            "misago.acl.useracl.get_user_acl",
+            side_effect=self.patched_get_user_acl,
+        )

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

@@ -1,26 +0,0 @@
-from django.contrib.auth import get_user_model
-from django.test import TestCase
-
-from misago.acl.api import get_user_acl
-from misago.users.models import AnonymousUser
-
-
-UserModel = get_user_model()
-
-
-class GetUserACLTests(TestCase):
-    def test_get_authenticated_acl(self):
-        """get ACL for authenticated user"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
-
-        acl = get_user_acl(test_user)
-
-        self.assertTrue(acl)
-        self.assertEqual(acl, test_user.acl_cache)
-
-    def test_get_anonymous_acl(self):
-        """get ACL for unauthenticated user"""
-        acl = get_user_acl(AnonymousUser())
-
-        self.assertTrue(acl)
-        self.assertEqual(acl, AnonymousUser().acl_cache)

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

@@ -0,0 +1,94 @@
+from unittest.mock import patch
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from misago.acl.useracl import get_user_acl
+from misago.conftest import get_cache_versions
+from misago.users.models import AnonymousUser
+
+User = get_user_model()
+
+cache_versions = get_cache_versions()
+
+
+class GettingUserACLTests(TestCase):
+    def test_getter_returns_authenticated_user_acl(self):
+        user = User.objects.create_user('Bob', 'bob@bob.com')
+        acl = get_user_acl(user, cache_versions)
+
+        assert acl
+        assert acl["user_id"] == user.id
+        assert acl["is_authenticated"] is True
+        assert acl["is_anonymous"] is False
+
+    def test_user_acl_includes_staff_and_superuser_false_status(self):
+        user = User.objects.create_user('Bob', 'bob@bob.com')
+        acl = get_user_acl(user, cache_versions)
+
+        assert acl
+        assert acl["is_staff"] is False
+        assert acl["is_superuser"] is False
+
+    def test_user_acl_includes_cache_versions(self):
+        user = User.objects.create_user('Bob', 'bob@bob.com')
+        acl = get_user_acl(user, cache_versions)
+
+        assert acl
+        assert acl["cache_versions"] == cache_versions
+
+    def test_getter_returns_anonymous_user_acl(self):
+        user = AnonymousUser()
+        acl = get_user_acl(user, cache_versions)
+
+        assert acl
+        assert acl["user_id"] == user.id
+        assert acl["is_authenticated"] is False
+        assert acl["is_anonymous"] is True
+
+    def test_superuser_acl_includes_staff_and_superuser_true_status(self):
+        user = User.objects.create_superuser('Bob', 'bob@bob.com', 'Pass.123')
+        acl = get_user_acl(user, cache_versions)
+
+        assert acl
+        assert acl["is_staff"] is True
+        assert acl["is_superuser"] is True
+
+    @patch('django.core.cache.cache.get', return_value=dict())
+    def test_getter_returns_acl_from_cache(self, cache_get):
+        user = AnonymousUser()
+        get_user_acl(user, cache_versions)
+        cache_get.assert_called_once()
+
+    @patch('django.core.cache.cache.set')
+    @patch('misago.acl.buildacl.build_acl', return_value=dict())
+    @patch('django.core.cache.cache.get', return_value=None)
+    def test_getter_builds_new_acl_when_cache_is_not_available(self, cache_get, *_):
+        user = AnonymousUser()
+        get_user_acl(user, cache_versions)
+        cache_get.assert_called_once()
+
+    @patch('django.core.cache.cache.set')
+    @patch('misago.acl.buildacl.build_acl', return_value=dict())
+    @patch('django.core.cache.cache.get', return_value=None)
+    def test_getter_sets_new_cache_if_no_cache_is_set(self, cache_set, *_):
+        user = AnonymousUser()
+        get_user_acl(user, cache_versions)
+        cache_set.assert_called_once()
+
+
+    @patch('django.core.cache.cache.set')
+    @patch('misago.acl.buildacl.build_acl', return_value=dict())
+    @patch('django.core.cache.cache.get', return_value=None)
+    def test_acl_cache_name_includes_cache_verssion(self, cache_set, *_):
+        user = AnonymousUser()
+        get_user_acl(user, cache_versions)
+        cache_key = cache_set.call_args[0][0]
+        assert cache_versions["acl"] in cache_key
+
+    @patch('django.core.cache.cache.set')
+    @patch('django.core.cache.cache.get', return_value=dict())
+    def test_getter_is_not_setting_new_cache_if_cache_is_set(self, _, cache_set):
+        user = AnonymousUser()
+        get_user_acl(user, cache_versions)
+        cache_set.assert_not_called()

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

@@ -0,0 +1,79 @@
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from misago.acl import useracl
+from misago.acl.test import patch_user_acl
+from misago.conftest import get_cache_versions
+
+User = get_user_model()
+
+cache_versions = get_cache_versions()
+
+
+def callable_acl_patch(user, user_acl):
+    user_acl["patched_for_user_id"] = user.id
+
+
+class PatchingUserACLTests(TestCase):
+    @patch_user_acl({"is_patched": True})
+    def test_decorator_patches_all_users_acls_in_test(self):
+        user = User.objects.create_user("User", "user@example.com")
+        user_acl = useracl.get_user_acl(user, cache_versions)
+        assert user_acl["is_patched"]
+
+    def test_decorator_removes_patches_after_test(self):
+        user = User.objects.create_user("User", "user@example.com")
+
+        @patch_user_acl({"is_patched": True})
+        def test_function(patch_user_acl):
+            user_acl = useracl.get_user_acl(user, cache_versions)
+            assert user_acl["is_patched"]
+
+        user_acl = useracl.get_user_acl(user, cache_versions)
+        assert "is_patched" not in user_acl
+
+    def test_context_manager_patches_all_users_acls_in_test(self):
+        user = User.objects.create_user("User", "user@example.com")
+        with patch_user_acl({"can_rename_users": "patched"}):
+            user_acl = useracl.get_user_acl(user, cache_versions)
+            assert user_acl["can_rename_users"] == "patched"
+
+    def test_context_manager_removes_patches_after_exit(self):
+        user = User.objects.create_user("User", "user@example.com")
+
+        with patch_user_acl({"is_patched": True}):
+            user_acl = useracl.get_user_acl(user, cache_versions)
+            assert user_acl["is_patched"]
+
+        user_acl = useracl.get_user_acl(user, cache_versions)
+        assert "is_patched" not in user_acl
+
+    @patch_user_acl(callable_acl_patch)
+    def test_callable_patch_is_called_with_user_and_acl_by_decorator(self):
+        user = User.objects.create_user("User", "user@example.com")
+        user_acl = useracl.get_user_acl(user, cache_versions)
+        assert user_acl["patched_for_user_id"] == user.id
+
+    def test_callable_patch_is_called_with_user_and_acl_by_context_manager(self):
+        user = User.objects.create_user("User", "user@example.com")
+        with patch_user_acl(callable_acl_patch):
+            user_acl = useracl.get_user_acl(user, cache_versions)
+            assert user_acl["patched_for_user_id"] == user.id
+
+    @patch_user_acl({"acl_patch": 1})
+    @patch_user_acl({"acl_patch": 2})
+    def test_multiple_acl_patches_applied_by_decorator_stack(self):
+        user = User.objects.create_user("User", "user@example.com")
+        user_acl = useracl.get_user_acl(user, cache_versions)
+        assert user_acl["acl_patch"] == 2
+
+    def test_multiple_acl_patches_applied_by_context_manager_stack(self):
+        user = User.objects.create_user("User", "user@example.com")
+        with patch_user_acl({"acl_patch": 1}):
+            with patch_user_acl({"acl_patch": 2}):
+                user_acl = useracl.get_user_acl(user, cache_versions)
+                assert user_acl["acl_patch"] == 2
+            user_acl = useracl.get_user_acl(user, cache_versions)
+            assert user_acl["acl_patch"] == 1
+        user_acl = useracl.get_user_acl(user, cache_versions)
+        assert "acl_patch" not in user_acl

+ 46 - 73
misago/acl/tests/test_providers.py

@@ -1,112 +1,85 @@
-from types import ModuleType
-
 from django.test import TestCase
 
 from misago.acl.providers import PermissionProviders
 from misago.conf import settings
 
 
-class TestType(object):
-    pass
-
-
 class PermissionProvidersTests(TestCase):
-    def test_initialization(self):
-        """providers manager is lazily initialized"""
+    def test_providers_are_not_loaded_on_container_init(self):
         providers = PermissionProviders()
 
-        self.assertTrue(providers._initialized is False)
-        self.assertTrue(not providers._providers)
-        self.assertTrue(not providers._providers_dict)
-
-        # public api errors on non-loaded object
-        with self.assertRaises(AssertionError):
-            providers.get_obj_type_annotators(TestType())
-
-        with self.assertRaises(AssertionError):
-            providers.get_obj_type_serializers(TestType())
-
-        with self.assertRaises(AssertionError):
-            providers.list()
-
-        self.assertTrue(providers._initialized is False)
-        self.assertTrue(not providers._providers)
-        self.assertTrue(not providers._providers_dict)
+        assert not providers._initialized
+        assert not providers._providers
+        assert not providers._annotators
+        assert not providers._user_acl_serializers
 
-        # load initializes providers
+    def test_container_loads_providers(self):
         providers = PermissionProviders()
         providers.load()
 
-        self.assertTrue(providers._initialized)
-        self.assertTrue(providers._providers)
-        self.assertTrue(providers._providers_dict)
+        assert providers._providers
+        assert providers._annotators
+        assert providers._user_acl_serializers
 
-    def test_list(self):
-        """providers manager list() returns iterable of tuples"""
+    def test_loading_providers_second_time_raises_runtime_error(self):
         providers = PermissionProviders()
-
-        # providers.list() throws before loading providers
-        with self.assertRaises(AssertionError):
-            providers.list()
-
         providers.load()
 
-        providers_list = providers.list()
+        with self.assertRaises(RuntimeError):
+            providers.load()
 
+    def test_container_returns_list_of_providers(self):
+        providers = PermissionProviders()
+        providers.load()
+        
         providers_setting = settings.MISAGO_ACL_EXTENSIONS
-        self.assertEqual(len(providers_list), len(providers_setting))
-
-        for extension, module in providers_list:
-            self.assertTrue(isinstance(extension, str))
-            self.assertEqual(type(module), ModuleType)
+        self.assertEqual(len(providers.list()), len(providers_setting))
 
-    def test_dict(self):
-        """providers manager dict() returns dict"""
+    def test_container_returns_dict_of_providers(self):
         providers = PermissionProviders()
+        providers.load()
+        
+        providers_setting = settings.MISAGO_ACL_EXTENSIONS
+        self.assertEqual(len(providers.dict()), len(providers_setting))
 
-        # providers.dict() throws before loading providers
+    def test_accessing_providers_list_before_load_raises_assertion_error(self):
+        providers = PermissionProviders()
+        with self.assertRaises(AssertionError):
+            providers.list()
+    
+    def test_accessing_providers_dict_before_load_raises_assertion_error(self):
+        providers = PermissionProviders()
         with self.assertRaises(AssertionError):
             providers.dict()
 
-        providers.load()
-
-        providers_dict = providers.dict()
-
-        providers_setting = settings.MISAGO_ACL_EXTENSIONS
-        self.assertEqual(len(providers_dict), len(providers_setting))
+    def test_getter_returns_registered_type_annotator(self):
+        class TestType(object):
+            pass
 
-        for extension, module in providers_dict.items():
-            self.assertTrue(isinstance(extension, str))
-            self.assertEqual(type(module), ModuleType)
 
-    def test_annotators(self):
-        """its possible to register and get annotators"""
-        def mock_annotator(*args):
+        def test_annotator():
             pass
+        
 
         providers = PermissionProviders()
-        providers.acl_annotator(TestType, mock_annotator)
+        providers.acl_annotator(TestType, test_annotator)
         providers.load()
 
-        # providers.acl_annotator() throws after loading providers
-        with self.assertRaises(AssertionError):
-            providers.acl_annotator(TestType, mock_annotator)
+        assert test_annotator in providers.get_obj_type_annotators(TestType())
 
-        annotators_list = providers.get_obj_type_annotators(TestType())
-        self.assertEqual(annotators_list[0], mock_annotator)
+    def test_container_returns_list_of_user_acl_serializers(self):
+        providers = PermissionProviders()
+        providers.load()
 
-    def test_serializers(self):
-        """its possible to register and get annotators"""
-        def mock_serializer(*args):
+        assert providers.get_user_acl_serializers()
+
+    def test_getter_returns_registered_user_acl_serializer(self):
+        def test_user_acl_serializer():
             pass
 
+
         providers = PermissionProviders()
-        providers.acl_serializer(TestType, mock_serializer)
+        providers.user_acl_serializer(test_user_acl_serializer)
         providers.load()
 
-        # providers.acl_serializer() throws after loading providers
-        with self.assertRaises(AssertionError):
-            providers.acl_serializer(TestType, mock_serializer)
-
-        serializers_list = providers.get_obj_type_serializers(TestType())
-        self.assertEqual(serializers_list[0], mock_serializer)
+        assert test_user_acl_serializer in providers.get_user_acl_serializers()

+ 37 - 0
misago/acl/tests/test_roleadmin_views.py

@@ -1,7 +1,9 @@
 from django.urls import reverse
 
+from misago.acl import ACL_CACHE
 from misago.acl.models import Role
 from misago.acl.testutils import fake_post_data
+from misago.cache.test import assert_invalidates_cache
 from misago.admin.testutils import AdminTestCase
 
 
@@ -70,6 +72,25 @@ class RoleAdminViewsTests(AdminTestCase):
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, test_role.name)
 
+    def test_editing_role_invalidates_acl_cache(self):
+        self.client.post(
+            reverse('misago:admin:permissions:users:new'), data=fake_data({
+                'name': 'Test Role',
+            })
+        )
+
+        test_role = Role.objects.get(name='Test Role')
+
+        with assert_invalidates_cache(ACL_CACHE):
+            self.client.post(
+                reverse('misago:admin:permissions:users:edit', kwargs={
+                    'pk': test_role.pk,
+                }),
+                data=fake_data({
+                    'name': 'Top Lel',
+                })
+            )
+
     def test_users_view(self):
         """users with this role view has no showstoppers"""
         response = self.client.post(
@@ -106,3 +127,19 @@ class RoleAdminViewsTests(AdminTestCase):
         self.client.get(reverse('misago:admin:permissions:users:index'))
         response = self.client.get(reverse('misago:admin:permissions:users:index'))
         self.assertNotContains(response, test_role.name)
+
+    def test_deleting_role_invalidates_acl_cache(self):
+        self.client.post(
+            reverse('misago:admin:permissions:users:new'), data=fake_data({
+                'name': 'Test Role',
+            })
+        )
+
+        test_role = Role.objects.get(name='Test Role')
+
+        with assert_invalidates_cache(ACL_CACHE):
+            self.client.post(
+                reverse('misago:admin:permissions:users:delete', kwargs={
+                    'pk': test_role.pk,
+                })
+            )

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

@@ -0,0 +1,24 @@
+import json
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from misago.acl.useracl import get_user_acl, serialize_user_acl
+from misago.conftest import get_cache_versions
+
+User = get_user_model()
+
+cache_versions = get_cache_versions()
+
+
+class SerializingUserACLTests(TestCase):
+    def test_user_acl_is_serializeable(self):
+        user = User.objects.create_user('Bob', 'bob@bob.com')
+        acl = get_user_acl(user, cache_versions)
+        assert serialize_user_acl(acl)
+
+    def test_user_acl_is_json_serializeable(self):
+        user = User.objects.create_user('Bob', 'bob@bob.com')
+        acl = get_user_acl(user, cache_versions)
+        serialized_acl = serialize_user_acl(acl)
+        assert json.dumps(serialized_acl)

+ 0 - 1
misago/acl/tests/test_testutils.py

@@ -8,5 +8,4 @@ class FakeTestDataTests(TestCase):
     def test_fake_post_data_for_role(self):
         """fake data was created for Role"""
         test_data = fake_post_data(Role(), {'can_fly': 1})
-
         self.assertIn('can_fly', test_data)

+ 12 - 0
misago/acl/tests/test_user_acl_context_processor.py

@@ -0,0 +1,12 @@
+from unittest.mock import Mock
+
+from django.test import TestCase
+
+from misago.acl.context_processors import user_acl
+
+
+class ContextProcessorsTests(TestCase):
+    def test_context_processor_adds_request_user_acl_to_context(self):
+        test_acl = {"test": True}
+        context = user_acl(Mock(user_acl=test_acl))
+        assert context == {"user_acl": test_acl}

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

@@ -0,0 +1,30 @@
+from unittest.mock import Mock
+
+from django.contrib.auth import get_user_model
+from django.test import TestCase
+
+from misago.acl.middleware import user_acl_middleware
+from misago.conftest import get_cache_versions
+
+User = get_user_model()
+
+cache_versions = get_cache_versions()
+
+
+class MiddlewareTests(TestCase):
+    def test_middleware_sets_attr_on_request(self):
+        user = User.objects.create_user("User", "user@example.com")
+
+        get_response = Mock()
+        request = Mock(user=user, cache_versions=cache_versions)
+        middleware = user_acl_middleware(get_response)
+        middleware(request)
+        assert request.user_acl
+
+    def test_middleware_calls_get_response(self):
+        user = User.objects.create_user("User", "user@example.com")
+        get_response = Mock()
+        request = Mock(user=user, cache_versions=cache_versions)
+        middleware = user_acl_middleware(get_response)
+        middleware(request)
+        get_response.assert_called_once()

+ 0 - 19
misago/acl/testutils.py

@@ -1,8 +1,3 @@
-from copy import deepcopy
-from hashlib import md5
-
-from misago.core import threadstore
-
 from .forms import get_permissions_forms
 
 
@@ -21,17 +16,3 @@ def fake_post_data(target, data_dict):
             else:
                 data_dict[field.html_name] = field.value()
     return data_dict
-
-
-def override_acl(user, new_acl):
-    """overrides user permissions with specified ones"""
-    final_cache = deepcopy(user.acl_cache)
-    final_cache.update(new_acl)
-    
-    if user.is_authenticated:
-        user._acl_cache = final_cache
-        user.acl_key = md5(str(user.pk).encode()).hexdigest()[:8]
-        user.save(update_fields=['acl_key'])
-        threadstore.set('acl_%s' % user.acl_key, final_cache)
-    else:
-        threadstore.set('acl_%s' % user.acl_key, final_cache)

+ 30 - 0
misago/acl/useracl.py

@@ -0,0 +1,30 @@
+import copy
+
+from . import buildacl
+from .cache import get_acl_cache, set_acl_cache
+from .providers import providers
+
+
+def get_user_acl(user, cache_versions):
+    user_acl = get_acl_cache(user, cache_versions)
+    if user_acl is None:
+        user_acl = buildacl.build_acl(user.get_roles())
+        set_acl_cache(user, cache_versions, user_acl)
+    user_acl["user_id"] = user.id
+    user_acl["is_authenticated"] = bool(user.is_authenticated)
+    user_acl["is_anonymous"] = bool(user.is_anonymous)
+    user_acl["is_staff"] = user.is_staff
+    user_acl["is_superuser"] = user.is_superuser
+    user_acl["cache_versions"] = cache_versions.copy()
+    return user_acl
+
+
+def serialize_user_acl(user_acl):
+    """serialize authenticated user's ACL"""
+    serialized_acl = copy.deepcopy(user_acl)
+    serialized_acl.pop("cache_versions")
+
+    for serializer in providers.get_user_acl_serializers():
+        serializer(serialized_acl)
+
+    return serialized_acl

+ 0 - 15
misago/acl/version.py

@@ -1,15 +0,0 @@
-from misago.core import cachebuster
-
-from .constants import ACL_CACHEBUSTER
-
-
-def get_version():
-    return cachebuster.get_version(ACL_CACHEBUSTER)
-
-
-def is_valid(version):
-    return cachebuster.is_valid(ACL_CACHEBUSTER, version)
-
-
-def invalidate():
-    cachebuster.invalidate(ACL_CACHEBUSTER)

+ 17 - 18
misago/admin/views/generic/list.py

@@ -116,15 +116,15 @@ class ListView(AdminView):
                 # So address ball contains copy-friendly link
                 refresh_querystring = True
 
-        SearchForm = self.get_search_form(request)
-        if SearchForm:
-            filtering_methods = self.get_filtering_methods(request)
+        search_form = self.get_search_form(request)
+        if search_form:
+            filtering_methods = self.get_filtering_methods(request, search_form)
             active_filters = self.get_filtering_method_to_use(filtering_methods)
             if request.GET.get('clear_filters'):
                 # Clear filters from querystring
                 request.session.pop(self.filters_session_key, None)
                 active_filters = {}
-            self.apply_filtering_on_context(context, active_filters, SearchForm)
+            self.apply_filtering_on_context(context, active_filters, search_form)
 
             if (filtering_methods['GET'] and
                     filtering_methods['GET'] != filtering_methods['session']):
@@ -181,12 +181,23 @@ class ListView(AdminView):
     def filters_session_key(self):
         return 'misago_admin_%s_filters' % self.root_link
 
-    def get_filters_from_GET(self, search_form, request):
+    def get_filtering_methods(self, request, search_form):
+        methods = {
+            'GET': self.get_filters_from_GET(request, search_form),
+            'session': self.get_filters_from_session(request, search_form),
+        }
+
+        if request.GET.get('set_filters'):
+            methods['session'] = {}
+
+        return methods
+
+    def get_filters_from_GET(self, request, search_form):
         form = search_form(request.GET)
         form.is_valid()
         return self.clean_filtering_data(form.cleaned_data)
 
-    def get_filters_from_session(self, search_form, request):
+    def get_filters_from_session(self, request, search_form):
         session_filters = request.session.get(self.filters_session_key, {})
         form = search_form(session_filters)
         form.is_valid()
@@ -198,18 +209,6 @@ class ListView(AdminView):
                 del data[key]
         return data
 
-    def get_filtering_methods(self, request):
-        SearchForm = self.get_search_form(request)
-        methods = {
-            'GET': self.get_filters_from_GET(SearchForm, request),
-            'session': self.get_filters_from_session(SearchForm, request),
-        }
-
-        if request.GET.get('set_filters'):
-            methods['session'] = {}
-
-        return methods
-
     def get_filtering_method_to_use(self, methods):
         for method in ('GET', 'session'):
             if methods.get(method):

+ 1 - 0
misago/cache/__init__.py

@@ -0,0 +1 @@
+default_app_config = 'misago.cache.apps.MisagoCacheConfig'

+ 7 - 0
misago/cache/apps.py

@@ -0,0 +1,7 @@
+from django.apps import AppConfig
+
+
+class MisagoCacheConfig(AppConfig):
+    name = 'misago.cache'
+    label = 'misago_cache'
+    verbose_name = "Misago Cache"

+ 0 - 0
misago/cache/management/__init__.py


+ 0 - 0
misago/cache/management/commands/__init__.py


+ 11 - 0
misago/cache/management/commands/invalidateversionedcaches.py

@@ -0,0 +1,11 @@
+from django.core.management.base import BaseCommand
+
+from misago.cache.versions import invalidate_all_caches
+
+
+class Command(BaseCommand):
+    help = 'Invalidates versioned caches'
+
+    def handle(self, *args, **options):
+        invalidate_all_caches()
+        self.stdout.write("Invalidated all versioned caches.")

+ 10 - 0
misago/cache/middleware.py

@@ -0,0 +1,10 @@
+from .versions import get_cache_versions
+
+
+def cache_versions_middleware(get_response):
+    """Sets request.cache_versions attribute with dict of cache versions."""
+    def middleware(request):
+        request.cache_versions = get_cache_versions()
+        return get_response(request)
+
+    return middleware

+ 22 - 0
misago/cache/migrations/0001_initial.py

@@ -0,0 +1,22 @@
+# Generated by Django 1.11.16 on 2018-11-25 15:15
+from django.db import migrations, models
+
+import misago.cache.utils
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CacheVersion',
+            fields=[
+                ('cache', models.CharField(max_length=128, primary_key=True, serialize=False)),
+                ('version', models.CharField(default=misago.cache.utils.generate_version_string, max_length=8)),
+            ],
+        ),
+    ]

+ 0 - 0
misago/cache/migrations/__init__.py


+ 8 - 0
misago/cache/models.py

@@ -0,0 +1,8 @@
+from django.db import models
+
+from .utils import generate_version_string
+
+
+class CacheVersion(models.Model):
+    cache = models.CharField(max_length=128, primary_key=True)
+    version = models.CharField(max_length=8, default=generate_version_string)

+ 29 - 0
misago/cache/operations.py

@@ -0,0 +1,29 @@
+from django.db.migrations import RunPython
+
+
+class StartCacheVersioning(RunPython):
+    def __init__(self, cache):
+        code = start_cache_versioning(cache)
+        reverse_code = stop_cache_versioning(cache)
+        super().__init__(code, reverse_code)
+
+
+class StopCacheVersioning(RunPython):
+    def __init__(self, cache):
+        code = stop_cache_versioning(cache)
+        reverse_code = start_cache_versioning(cache)
+        super().__init__(code, reverse_code)
+
+
+def start_cache_versioning(cache):
+    def migration_operation(apps, _):
+        CacheVersion = apps.get_model('misago_cache', 'CacheVersion')
+        CacheVersion.objects.create(cache=cache)
+    return migration_operation
+
+
+def stop_cache_versioning(cache):
+    def migration_operation(apps, _):
+        CacheVersion = apps.get_model('misago_cache', 'CacheVersion')
+        CacheVersion.objects.filter(cache=cache).delete()
+    return migration_operation

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

+ 0 - 0
misago/cache/tests/__init__.py


+ 5 - 0
misago/cache/tests/conftest.py

@@ -0,0 +1,5 @@
+from misago.cache.models import CacheVersion
+
+
+def cache_version():
+    return CacheVersion.objects.create(cache="test_cache")

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

+ 22 - 0
misago/cache/tests/test_cache_versions_middleware.py

@@ -0,0 +1,22 @@
+from unittest.mock import Mock
+
+from django.test import TestCase
+
+from misago.cache.versions import CACHE_NAME
+from misago.cache.middleware import cache_versions_middleware
+
+
+class MiddlewareTests(TestCase):
+    def test_middleware_sets_attr_on_request(self):
+        get_response = Mock()
+        request = Mock()
+        middleware = cache_versions_middleware(get_response)
+        middleware(request)
+        assert request.cache_versions
+
+    def test_middleware_calls_get_response(self):
+        get_response = Mock()
+        request = Mock()
+        middleware = cache_versions_middleware(get_response)
+        middleware(request)
+        get_response.assert_called_once()

+ 45 - 0
misago/cache/tests/test_getting_cache_versions.py

@@ -0,0 +1,45 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+
+from misago.cache.versions import (
+    CACHE_NAME, get_cache_versions, get_cache_versions_from_cache, get_cache_versions_from_db
+)
+
+
+class CacheVersionsTests(TestCase):
+    def test_db_getter_returns_cache_versions_from_db(self):
+        with self.assertNumQueries(1):
+            assert get_cache_versions_from_db()
+
+    @patch('django.core.cache.cache.get', return_value=True)
+    def test_cache_getter_returns_cache_versions_from_cache(self, cache_get):
+        assert get_cache_versions_from_cache() is True
+        cache_get.assert_called_once_with(CACHE_NAME)
+
+    @patch('django.core.cache.cache.get', return_value=True)
+    def test_getter_reads_from_cache(self, cache_get):
+        with self.assertNumQueries(0):
+            assert get_cache_versions() is True
+        cache_get.assert_called_once_with(CACHE_NAME)
+
+    @patch('django.core.cache.cache.set')
+    @patch('django.core.cache.cache.get', return_value=None)
+    def test_getter_reads_from_db_when_cache_is_not_available(self, cache_get, _):
+        db_caches = get_cache_versions_from_db()
+        with self.assertNumQueries(1):
+            assert get_cache_versions() == db_caches
+        cache_get.assert_called_once_with(CACHE_NAME)
+
+    @patch('django.core.cache.cache.set')
+    @patch('django.core.cache.cache.get', return_value=None)
+    def test_getter_sets_new_cache_if_no_cache_is_set(self, _, cache_set):
+        get_cache_versions()
+        db_caches = get_cache_versions_from_db()
+        cache_set.assert_called_once_with(CACHE_NAME, db_caches)
+
+    @patch('django.core.cache.cache.set')
+    @patch('django.core.cache.cache.get', return_value=True)
+    def test_getter_is_not_setting_new_cache_if_cache_is_set(self, _, cache_set):
+        get_cache_versions()
+        cache_set.assert_not_called()

+ 11 - 0
misago/cache/tests/test_invalidate_caches_management_command.py

@@ -0,0 +1,11 @@
+from unittest.mock import Mock, patch
+
+from django.core.management import call_command
+from django.test import TestCase
+
+
+class InvalidateCachesManagementCommandTests(TestCase):
+    @patch("misago.cache.versions.invalidate_all_caches")
+    def test_management_command_invalidates_all_caches(self, invalidate_all_caches):
+        call_command('invalidateversionedcaches', stdout=Mock())
+        invalidate_all_caches.assert_called_once()

+ 38 - 0
misago/cache/tests/test_invalidating_caches.py

@@ -0,0 +1,38 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+
+from misago.cache.versions import (
+    CACHE_NAME, get_cache_versions_from_db, invalidate_cache, invalidate_all_caches
+)
+from misago.cache.models import CacheVersion
+
+from .conftest import cache_version
+
+
+class InvalidatingCacheTests(TestCase):
+    @patch('django.core.cache.cache.delete')
+    def test_invalidating_cache_updates_cache_version_in_database(self, _):
+        test_cache = cache_version()
+        invalidate_cache(test_cache.cache)
+        updated_test_cache = CacheVersion.objects.get(cache=test_cache.cache)
+        assert test_cache.version != updated_test_cache.version
+
+    @patch('django.core.cache.cache.delete')
+    def test_invalidating_cache_deletes_versions_cache(self, cache_delete):
+        test_cache = cache_version()
+        invalidate_cache(test_cache.cache)
+        cache_delete.assert_called_once_with(CACHE_NAME)
+
+    @patch('django.core.cache.cache.delete')
+    def test_invalidating_all_caches_updates_cache_version_in_database(self, _):
+        test_cache = cache_version()
+        invalidate_all_caches()
+        updated_test_cache = CacheVersion.objects.get(cache=test_cache.cache)
+        assert test_cache.version != updated_test_cache.version
+
+    @patch('django.core.cache.cache.delete')
+    def test_invalidating_all_caches_deletes_versions_cache(self, cache_delete):
+        cache_version()
+        invalidate_all_caches()
+        cache_delete.assert_called_once_with(CACHE_NAME)

+ 5 - 0
misago/cache/utils.py

@@ -0,0 +1,5 @@
+from django.utils.crypto import get_random_string
+
+
+def generate_version_string():
+    return get_random_string(8)

+ 38 - 0
misago/cache/versions.py

@@ -0,0 +1,38 @@
+from django.core.cache import cache
+
+from .models import CacheVersion
+from .utils import generate_version_string
+
+CACHE_NAME = "cache_versions"
+
+
+def get_cache_versions():
+    cache_versions = get_cache_versions_from_cache()
+    if cache_versions is None:
+        cache_versions = get_cache_versions_from_db()
+        cache.set(CACHE_NAME, cache_versions)
+    return cache_versions
+
+
+def get_cache_versions_from_cache():
+    return cache.get(CACHE_NAME)
+
+
+def get_cache_versions_from_db():
+    queryset = CacheVersion.objects.all()
+    return {i.cache: i.version for i in queryset}
+
+
+def invalidate_cache(cache_name):
+    CacheVersion.objects.filter(cache=cache_name).update(
+        version=generate_version_string(),
+    )
+    cache.delete(CACHE_NAME)
+
+
+def invalidate_all_caches():
+    for cache_name in get_cache_versions_from_db().keys():
+        CacheVersion.objects.filter(cache=cache_name).update(
+            version=generate_version_string(),
+        )
+    cache.delete(CACHE_NAME)

+ 1 - 1
misago/categories/api.py

@@ -7,5 +7,5 @@ from .utils import get_categories_tree
 
 class CategoryViewSet(viewsets.ViewSet):
     def list(self, request):
-        categories_tree = get_categories_tree(request.user, join_posters=True)
+        categories_tree = get_categories_tree(request.user, request.user_acl, join_posters=True)
         return Response(CategorySerializer(categories_tree, many=True).data)

+ 2 - 2
misago/categories/management/commands/fixcategoriestree.py

@@ -1,6 +1,6 @@
 from django.core.management.base import BaseCommand
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear_acl_cache
 from misago.categories.models import Category
 
 
@@ -19,5 +19,5 @@ class Command(BaseCommand):
         self.stdout.write("Categories tree has been rebuild.")
 
         Category.objects.clear_cache()
-        acl_version.invalidate()
+        clear_acl_cache()
         self.stdout.write("Caches have been cleared.")

+ 2 - 2
misago/categories/models.py

@@ -3,7 +3,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 
 from django.db import models
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear_acl_cache
 from misago.acl.models import BaseRole
 from misago.conf import settings
 from misago.core.cache import cache
@@ -115,7 +115,7 @@ class Category(MPTTModel):
 
     def delete(self, *args, **kwargs):
         Category.objects.clear_cache()
-        acl_version.invalidate()
+        clear_acl_cache()
         return super().delete(*args, **kwargs)
 
     def synchronize(self):

+ 11 - 13
misago/categories/permissions.py

@@ -85,14 +85,14 @@ def build_category_acl(acl, category, categories_roles, key_name):
             acl['browseable_categories'].append(category.pk)
 
 
-def add_acl_to_category(user, target):
-    target.acl['can_see'] = can_see_category(user, target)
-    target.acl['can_browse'] = can_browse_category(user, target)
+def add_acl_to_category(user_acl, target):
+    target.acl['can_see'] = can_see_category(user_acl, target)
+    target.acl['can_browse'] = can_browse_category(user_acl, target)
 
 
-def serialize_categories_acls(serialized_acl):
+def serialize_categories_acls(user_acl):
     categories_acl = []
-    for category, acl in serialized_acl.pop('categories').items():
+    for category, acl in user_acl.pop('categories').items():
         if acl['can_browse']:
             categories_acl.append({
                 'id': category,
@@ -102,31 +102,29 @@ def serialize_categories_acls(serialized_acl):
                 'can_hide_threads': acl.get('can_hide_threads', 0),
                 'can_close_threads': acl.get('can_close_threads', False),
             })
-    serialized_acl['categories'] = categories_acl
+    user_acl['categories'] = categories_acl
 
 
 def register_with(registry):
     registry.acl_annotator(Category, add_acl_to_category)
+    registry.user_acl_serializer(serialize_categories_acls)
 
-    registry.acl_serializer(get_user_model(), serialize_categories_acls)
-    registry.acl_serializer(AnonymousUser, serialize_categories_acls)
 
-
-def allow_see_category(user, target):
+def allow_see_category(user_acl, target):
     try:
         category_id = target.pk
     except AttributeError:
         category_id = int(target)
 
-    if not category_id in user.acl_cache['visible_categories']:
+    if not category_id in user_acl['visible_categories']:
         raise Http404()
 
 
 can_see_category = return_boolean(allow_see_category)
 
 
-def allow_browse_category(user, target):
-    target_acl = user.acl_cache['categories'].get(target.id, {'can_browse': False})
+def allow_browse_category(user_acl, target):
+    target_acl = user_acl['categories'].get(target.id, {'can_browse': False})
     if not target_acl['can_browse']:
         message = _('You don\'t have permission to browse "%(category)s" contents.')
         raise PermissionDenied(message % {'category': target.name})

+ 58 - 0
misago/categories/tests/test_categories_admin_views.py

@@ -1,6 +1,8 @@
 from django.urls import reverse
 
+from misago.acl import ACL_CACHE
 from misago.admin.testutils import AdminTestCase
+from misago.cache.test import assert_invalidates_cache
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads.models import Thread
@@ -140,6 +142,21 @@ class CategoryAdminViewsTests(CategoryAdminTestCase):
         response = self.client.get(reverse('misago:admin:categories:nodes:index'))
         self.assertContains(response, 'Test Subcategory')
 
+    def test_creating_new_category_invalidates_acl_cache(self):
+        root = Category.objects.root_category()
+
+        with assert_invalidates_cache(ACL_CACHE):
+            self.client.post(
+                reverse('misago:admin:categories:nodes:new'),
+                data={
+                    'name': 'Test Category',
+                    'description': 'Lorem ipsum dolor met',
+                    'new_parent': root.pk,
+                    'prune_started_after': 0,
+                    'prune_replied_after': 0,
+                },
+            )
+
     def test_edit_view(self):
         """edit category view has no showstoppers"""
         private_threads = Category.objects.private_threads()
@@ -228,6 +245,35 @@ class CategoryAdminViewsTests(CategoryAdminTestCase):
         response = self.client.get(reverse('misago:admin:categories:nodes:index'))
         self.assertContains(response, 'Test Category Edited')
 
+    def test_editing_category_invalidates_acl_cache(self):
+        root = Category.objects.root_category()
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                'name': 'Test Category',
+                'description': 'Lorem ipsum dolor met',
+                'new_parent': root.pk,
+                'prune_started_after': 0,
+                'prune_replied_after': 0,
+            },
+        )
+        
+        test_category = Category.objects.get(slug='test-category')
+
+        with assert_invalidates_cache(ACL_CACHE):
+            self.client.post(
+                reverse('misago:admin:categories:nodes:edit', kwargs={
+                    'pk': test_category.pk,
+                }),
+                data={
+                    'name': 'Test Category Edited',
+                    'new_parent': root.pk,
+                    'role': 'category',
+                    'prune_started_after': 0,
+                    'prune_replied_after': 0,
+                },
+            )
+
     def test_move_views(self):
         """move up/down views have no showstoppers"""
         root = Category.objects.root_category()
@@ -522,3 +568,15 @@ class CategoryAdminDeleteViewTests(CategoryAdminTestCase):
             (self.category_e, 1, 10, 13),
             (self.category_f, 2, 11, 12),
         ])
+
+    def test_deleting_category_invalidates_acl_cache(self):
+        with assert_invalidates_cache(ACL_CACHE):
+            self.client.post(
+                reverse('misago:admin:categories:nodes:delete', kwargs={
+                    'pk': self.category_d.pk,
+                }),
+                data={
+                    'move_children_to': '',
+                    'move_threads_to': '',
+                }
+            )

+ 4 - 3
misago/categories/tests/test_category_model.py

@@ -1,11 +1,12 @@
+from django.test import TestCase
+
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories.models import Category
-from misago.core.testutils import MisagoTestCase
 from misago.threads import testutils
 from misago.threads.threadtypes import trees_map
 
 
-class CategoryManagerTests(MisagoTestCase):
+class CategoryManagerTests(TestCase):
     def test_private_threads(self):
         """private_threads returns private threads category"""
         category = Category.objects.private_threads()
@@ -45,7 +46,7 @@ class CategoryManagerTests(MisagoTestCase):
                 self.assertNotIn(category.id, test_dict)
 
 
-class CategoryModelTests(MisagoTestCase):
+class CategoryModelTests(TestCase):
     def setUp(self):
         super().setUp()
 

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

@@ -3,6 +3,8 @@ from io import StringIO
 from django.core.management import call_command
 from django.test import TestCase
 
+from misago.acl import ACL_CACHE
+from misago.cache.test import assert_invalidates_cache
 from misago.categories.management.commands import fixcategoriestree
 from misago.categories.models import Category
 
@@ -82,3 +84,8 @@ class FixCategoriesTreeTests(TestCase):
             (self.test_category, 1, 2, 3),
             (self.first_category, 1, 4, 5),
         ])
+
+    def test_fixing_categories_tree_invalidates_acl_cache(self):
+        with assert_invalidates_cache(ACL_CACHE):
+            run_command()
+

+ 67 - 0
misago/categories/tests/test_permissions_admin_views.py

@@ -1,8 +1,10 @@
 from django.urls import reverse
 
+from misago.acl import ACL_CACHE
 from misago.acl.models import Role
 from misago.acl.testutils import fake_post_data
 from misago.admin.testutils import AdminTestCase
+from misago.cache.test import assert_invalidates_cache
 from misago.categories.models import Category, CategoryRole
 
 
@@ -72,6 +74,26 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         response = self.client.get(reverse('misago:admin:permissions:categories:index'))
         self.assertContains(response, test_role.name)
 
+    def test_editing_role_invalidates_acl_cache(self):
+        self.client.post(
+            reverse('misago:admin:permissions:categories:new'),
+            data=fake_data({
+                'name': 'Test CategoryRole',
+            }),
+        )
+
+        test_role = CategoryRole.objects.get(name='Test CategoryRole')
+
+        with assert_invalidates_cache(ACL_CACHE):
+            self.client.post(
+                reverse('misago:admin:permissions:categories:edit', kwargs={
+                    'pk': test_role.pk,
+                }),
+                data=fake_data({
+                    'name': 'Top Lel',
+                }),
+            )
+
     def test_delete_view(self):
         """delete role view has no showstoppers"""
         self.client.post(
@@ -93,6 +115,23 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         response = self.client.get(reverse('misago:admin:permissions:categories:index'))
         self.assertNotContains(response, test_role.name)
 
+    def test_deleting_role_invalidates_acl_cache(self):
+        self.client.post(
+            reverse('misago:admin:permissions:categories:new'),
+            data=fake_data({
+                'name': 'Test CategoryRole',
+            }),
+        )
+
+        test_role = CategoryRole.objects.get(name='Test CategoryRole')
+
+        with assert_invalidates_cache(ACL_CACHE):
+            self.client.post(
+                reverse('misago:admin:permissions:categories:delete', kwargs={
+                    'pk': test_role.pk,
+                })
+            )
+
     def test_change_category_roles_view(self):
         """change category roles perms view works"""
         root = Category.objects.root_category()
@@ -186,6 +225,20 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         self.assertEqual(
             category_role_set.get(role=test_role_b).category_role_id, role_comments.pk
         )
+        
+        # Check that ACL was invalidated
+        with assert_invalidates_cache(ACL_CACHE):
+            self.client.post(
+                reverse(
+                    'misago:admin:categories:nodes:permissions', kwargs={
+                        'pk': test_category.pk,
+                    }
+                ),
+                data={
+                    ('%s-category_role' % test_role_a.pk): role_full.pk,
+                    ('%s-category_role' % test_role_b.pk): role_comments.pk,
+                },
+            )
 
     def test_change_role_categories_permissions_view(self):
         """change role categories perms view works"""
@@ -323,3 +376,17 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         )
         self.assertEqual(categories_acls.get(category=category_c).category_role_id, role_full.pk)
         self.assertEqual(categories_acls.get(category=category_d).category_role_id, role_full.pk)
+
+        # Check that ACL was invalidated
+        with assert_invalidates_cache(ACL_CACHE):
+            self.client.post(
+                reverse('misago:admin:permissions:users:categories', kwargs={
+                    'pk': test_role.pk,
+                }),
+                data={
+                    ('%s-role' % category_a.pk): role_comments.pk,
+                    ('%s-role' % category_b.pk): role_comments.pk,
+                    ('%s-role' % category_c.pk): role_full.pk,
+                    ('%s-role' % category_d.pk): role_full.pk,
+                },
+            )

+ 19 - 13
misago/categories/tests/test_utils.py

@@ -1,9 +1,21 @@
-from misago.acl.testutils import override_acl
+from misago.acl.useracl import get_user_acl
 from misago.categories.models import Category
 from misago.categories.utils import get_categories_tree, get_category_path
-from misago.core import threadstore
+from misago.conftest import get_cache_versions
 from misago.users.testutils import AuthenticatedUserTestCase
 
+cache_versions = get_cache_versions()
+
+
+def get_patched_user_acl(user):
+    user_acl = get_user_acl(user, cache_versions)
+    categories_acl = {'categories': {}, 'visible_categories': []}
+    for category in Category.objects.all_categories():
+        categories_acl['visible_categories'].append(category.id)
+        categories_acl['categories'][category.id] = {'can_see': 1, 'can_browse': 1}
+    user_acl.update(categories_acl)
+    return user_acl
+
 
 class CategoriesUtilsTests(AuthenticatedUserTestCase):
     def setUp(self):
@@ -20,9 +32,7 @@ class CategoriesUtilsTests(AuthenticatedUserTestCase):
         Category E
           + Subcategory F
         """
-
         super().setUp()
-        threadstore.clear()
 
         self.root = Category.objects.root_category()
         self.first_category = Category.objects.get(slug='first-category')
@@ -84,15 +94,11 @@ class CategoriesUtilsTests(AuthenticatedUserTestCase):
             save=True,
         )
 
-        categories_acl = {'categories': {}, 'visible_categories': []}
-        for category in Category.objects.all_categories():
-            categories_acl['visible_categories'].append(category.pk)
-            categories_acl['categories'][category.pk] = {'can_see': 1, 'can_browse': 1}
-        override_acl(self.user, categories_acl)
+        self.user_acl = get_patched_user_acl(self.user)
 
     def test_root_categories_tree_no_parent(self):
         """get_categories_tree returns all children of root nodes"""
-        categories_tree = get_categories_tree(self.user)
+        categories_tree = get_categories_tree(self.user, self.user_acl)
         self.assertEqual(len(categories_tree), 3)
 
         self.assertEqual(categories_tree[0], Category.objects.get(slug='first-category'))
@@ -101,19 +107,19 @@ class CategoriesUtilsTests(AuthenticatedUserTestCase):
 
     def test_root_categories_tree_with_parent(self):
         """get_categories_tree returns all children of given node"""
-        categories_tree = get_categories_tree(self.user, self.category_a)
+        categories_tree = get_categories_tree(self.user, self.user_acl, self.category_a)
         self.assertEqual(len(categories_tree), 1)
         self.assertEqual(categories_tree[0], Category.objects.get(slug='category-b'))
 
     def test_root_categories_tree_with_leaf(self):
         """get_categories_tree returns all children of given node"""
         categories_tree = get_categories_tree(
-            self.user, Category.objects.get(slug='subcategory-f')
+            self.user, self.user_acl, Category.objects.get(slug='subcategory-f')
         )
         self.assertEqual(len(categories_tree), 0)
 
     def test_get_category_path(self):
         """get_categories_tree returns all children of root nodes"""
-        for node in get_categories_tree(self.user):
+        for node in get_categories_tree(self.user, self.user_acl):
             parent_nodes = len(get_category_path(node))
             self.assertEqual(parent_nodes, node.level)

+ 26 - 46
misago/categories/tests/test_views.py

@@ -1,53 +1,47 @@
+import json
+
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
-from misago.categories.utils import get_categories_tree
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 class CategoryViewsTests(AuthenticatedUserTestCase):
+    def setUp(self):
+        super().setUp()
+
+        self.category = Category.objects.get(slug='first-category')
+
     def test_index_renders(self):
         """categories list renders for authenticated"""
         response = self.client.get(reverse('misago:categories'))
-
-        for node in get_categories_tree(self.user):
-            self.assertContains(response, node.name)
-            if node.level > 1:
-                self.assertContains(response, node.get_absolute_url())
+        self.assertContains(response, self.category.name)
+        self.assertContains(response, self.category.get_absolute_url())
 
     def test_index_renders_for_guest(self):
         """categories list renders for guest"""
         self.logout_user()
 
         response = self.client.get(reverse('misago:categories'))
+        self.assertContains(response, self.category.name)
+        self.assertContains(response, self.category.get_absolute_url())
 
-        for node in get_categories_tree(self.user):
-            self.assertContains(response, node.name)
-            if node.level > 1:
-                self.assertContains(response, node.get_absolute_url())
-
+    @patch_user_acl({'visible_categories': []})
     def test_index_no_perms_renders(self):
         """categories list renders no visible categories for authenticated"""
-        override_acl(self.user, {'visible_categories': []})
         response = self.client.get(reverse('misago:categories'))
+        self.assertNotContains(response, self.category.name)
+        self.assertNotContains(response, self.category.get_absolute_url())
 
-        for node in get_categories_tree(self.user):
-            self.assertNotIn(node.name, response.content)
-            if node.level > 1:
-                self.assertNotContains(response, node.get_absolute_url())
-
+    @patch_user_acl({'visible_categories': []})
     def test_index_no_perms_renders_for_guest(self):
         """categories list renders no visible categories for guest"""
         self.logout_user()
 
-        override_acl(self.user, {'visible_categories': []})
         response = self.client.get(reverse('misago:categories'))
-
-        for node in get_categories_tree(self.user):
-            self.assertNotIn(node.name, response.content)
-            if node.level > 1:
-                self.assertNotContains(response, node.get_absolute_url())
+        self.assertNotContains(response, self.category.name)
+        self.assertNotContains(response, self.category.get_absolute_url())
 
 
 class CategoryAPIViewsTests(AuthenticatedUserTestCase):
@@ -59,41 +53,27 @@ class CategoryAPIViewsTests(AuthenticatedUserTestCase):
     def test_list_renders(self):
         """api returns categories for authenticated"""
         response = self.client.get(reverse('misago:api:category-list'))
-
-        for node in get_categories_tree(self.user):
-            self.assertContains(response, node.name)
-            if node.level > 1:
-                self.assertNotContains(response, node.get_absolute_url())
+        self.assertContains(response, self.category.name)
+        self.assertContains(response, self.category.get_absolute_url())
 
     def test_list_renders_for_guest(self):
         """api returns categories for guest"""
         self.logout_user()
 
         response = self.client.get(reverse('misago:api:category-list'))
+        self.assertContains(response, self.category.name)
+        self.assertContains(response, self.category.get_absolute_url())
 
-        for node in get_categories_tree(self.user):
-            self.assertContains(response, node.name)
-            if node.level > 1:
-                self.assertNotContains(response, node.get_absolute_url())
-
+    @patch_user_acl({'visible_categories': []})
     def test_list_no_perms_renders(self):
         """api returns no categories for authenticated"""
-        override_acl(self.user, {'visible_categories': []})
         response = self.client.get(reverse('misago:api:category-list'))
+        assert json.loads(response.content) == []
 
-        for node in get_categories_tree(self.user):
-            self.assertNotIn(node.name, response.content)
-            if node.level > 1:
-                self.assertNotContains(response, node.get_absolute_url())
-
+    @patch_user_acl({'visible_categories': []})
     def test_list_no_perms_renders_for_guest(self):
         """api returns no categories for guest"""
         self.logout_user()
 
-        override_acl(self.user, {'visible_categories': []})
         response = self.client.get(reverse('misago:api:category-list'))
-
-        for node in get_categories_tree(self.user):
-            self.assertNotContains(response, node.name)
-            if node.level > 1:
-                self.assertNotContains(response, node.get_absolute_url())
+        assert json.loads(response.content) == []

+ 6 - 6
misago/categories/utils.py

@@ -1,11 +1,11 @@
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.readtracker import categoriestracker
 
 from .models import Category
 
 
-def get_categories_tree(user, parent=None, join_posters=False):
-    if not user.acl_cache['visible_categories']:
+def get_categories_tree(user, user_acl, parent=None, join_posters=False):
+    if not user_acl['visible_categories']:
         return []
 
     if parent:
@@ -13,7 +13,7 @@ def get_categories_tree(user, parent=None, join_posters=False):
     else:
         queryset = Category.objects.all_categories()
 
-    queryset_with_acl = queryset.filter(id__in=user.acl_cache['visible_categories'])
+    queryset_with_acl = queryset.filter(id__in=user_acl['visible_categories'])
     if join_posters:
         queryset_with_acl = queryset_with_acl.select_related('last_poster')
 
@@ -32,8 +32,8 @@ def get_categories_tree(user, parent=None, join_posters=False):
         if category.parent_id and category.level > parent_level:
             categories_dict[category.parent_id].subcategories.append(category)
 
-    add_acl(user, categories_list)
-    categoriestracker.make_read_aware(user, categories_list)
+    add_acl_to_obj(user_acl, categories_list)
+    categoriestracker.make_read_aware(user, user_acl, categories_list)
 
     for category in reversed(visible_categories):
         if category.acl['can_browse']:

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

@@ -2,7 +2,7 @@ from django.contrib import messages
 from django.shortcuts import redirect
 from django.utils.translation import gettext_lazy as _
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear_acl_cache
 from misago.admin.views import generic
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories.forms import CategoryFormFactory, DeleteFormFactory
@@ -88,7 +88,7 @@ class CategoryFormMixin(object):
             if copied_acls:
                 RoleCategoryACL.objects.bulk_create(copied_acls)
 
-        acl_version.invalidate()
+        clear_acl_cache()
         messages.success(request, self.message_submit % {'name': target.name})
 
 

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

@@ -6,7 +6,7 @@ from misago.categories.utils import get_categories_tree
 
 
 def categories(request):
-    categories_tree = get_categories_tree(request.user, join_posters=True)
+    categories_tree = get_categories_tree(request.user, request.user_acl, join_posters=True)
 
     request.frontend_context.update({
         'CATEGORIES': CategorySerializer(categories_tree, many=True).data,

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

@@ -2,7 +2,7 @@ from django.contrib import messages
 from django.shortcuts import redirect
 from django.utils.translation import gettext_lazy as _
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear_acl_cache
 from misago.acl.forms import get_permissions_forms
 from misago.acl.models import Role
 from misago.acl.views import RoleAdmin, RolesList
@@ -128,7 +128,7 @@ class CategoryPermissions(CategoryAdmin, generic.ModelFormView):
             if new_permissions:
                 RoleCategoryACL.objects.bulk_create(new_permissions)
 
-            acl_version.invalidate()
+            clear_acl_cache()
 
             message = _("Category %(name)s permissions have been changed.")
             messages.success(request, message % {'name': target.name})
@@ -196,7 +196,7 @@ class RoleCategoriesACL(RoleAdmin, generic.ModelFormView):
             if new_permissions:
                 RoleCategoryACL.objects.bulk_create(new_permissions)
 
-            acl_version.invalidate()
+            clear_acl_cache()
 
             message = _("Category permissions for role %(name)s have been changed.")
             messages.success(request, message % {'name': target.name})

+ 5 - 1
misago/conf/__init__.py

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

+ 23 - 0
misago/conf/cache.py

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

+ 22 - 24
misago/conf/context_processors.py

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

+ 0 - 96
misago/conf/dbsettings.py

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

+ 63 - 0
misago/conf/dynamicsettings.py

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

+ 1 - 2
misago/conf/forms.py

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

+ 0 - 22
misago/conf/gateway.py

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

+ 14 - 0
misago/conf/middleware.py

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

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

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

+ 0 - 7
misago/conf/migrationutils.py

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

+ 18 - 0
misago/conf/staticsettings.py

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

+ 21 - 0
misago/conf/test.py

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

+ 11 - 17
misago/conf/tests/test_context_processors.py

@@ -1,27 +1,21 @@
-from django.test import TestCase
+from unittest.mock import Mock
 
-from misago.conf.context_processors import settings
-from misago.conf.dbsettings import db_settings
-from misago.core import threadstore
+from django.test import TestCase
 
+from misago.conftest import get_cache_versions
 
-class MockRequest(object):
-    pass
+from misago.conf.context_processors import conf
+from misago.conf.dynamicsettings import DynamicSettings
 
 
 class ContextProcessorsTests(TestCase):
-    def tearDown(self):
-        threadstore.clear()
-
-    def test_db_settings(self):
-        """DBSettings are exposed to templates"""
-        mock_request = MockRequest()
-        processor_settings = settings(mock_request)['misago_settings'],
-
-        self.assertEqual(id(processor_settings[0]), id(db_settings))
+    def test_request_settings_are_included_in_template_context(self):
+        cache_versions = get_cache_versions()
+        mock_request = Mock(settings=DynamicSettings(cache_versions))
+        context_settings = conf(mock_request)['settings']
+        assert context_settings == mock_request.settings
 
-    def test_preload_settings(self):
-        """site configuration is preloaded by middleware"""
+    def test_settings_are_included_in_frontend_context(self):
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, '"SETTINGS": {"')

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

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

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

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

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

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

+ 0 - 4
misago/conf/tests/test_migrationutils.py

@@ -3,7 +3,6 @@ from django.test import TestCase
 
 from misago.conf import migrationutils
 from misago.conf.models import SettingsGroup
-from misago.core import threadstore
 
 
 class DBConfMigrationUtilsTests(TestCase):
@@ -36,9 +35,6 @@ class DBConfMigrationUtilsTests(TestCase):
         migrationutils.migrate_settings_group(apps, self.test_group)
         self.groups_count = SettingsGroup.objects.count()
 
-    def tearDown(self):
-        threadstore.clear()
-
     def test_get_custom_group_and_settings(self):
         """tests setup created settings group"""
         custom_group = migrationutils.get_group(

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

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

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

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

+ 2 - 2
misago/conf/views.py

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

+ 10 - 0
misago/conftest.py

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

+ 0 - 104
misago/core/cachebuster.py

@@ -1,104 +0,0 @@
-from django.db.models import F
-
-from . import threadstore
-
-
-CACHE_KEY = 'misago_cachebuster'
-
-
-class CacheBusterController(object):
-    def register_cache(self, cache):
-        from .models import CacheVersion
-        CacheVersion.objects.create(cache=cache)
-
-    def unregister_cache(self, cache):
-        from .models import CacheVersion
-
-        try:
-            cache = CacheVersion.objects.get(cache=cache)
-            cache.delete()
-        except CacheVersion.DoesNotExist:
-            raise ValueError('Cache "%s" is not registered' % cache)
-
-    @property
-    def cache(self):
-        return self.read_threadstore()
-
-    def read_threadstore(self):
-        data = threadstore.get(CACHE_KEY, 'nada')
-        if data == 'nada':
-            data = self.read_cache()
-            threadstore.set(CACHE_KEY, data)
-        return data
-
-    def read_cache(self):
-        from .cache import cache as default_cache
-
-        data = default_cache.get(CACHE_KEY, 'nada')
-        if data == 'nada':
-            data = self.read_db()
-            default_cache.set(CACHE_KEY, data)
-        return data
-
-    def read_db(self):
-        from .models import CacheVersion
-
-        data = {}
-        for cache_version in CacheVersion.objects.iterator():
-            data[cache_version.cache] = cache_version.version
-        return data
-
-    def get_cache_version(self, cache):
-        try:
-            return self.cache[cache]
-        except KeyError:
-            raise ValueError('Cache "%s" is not registered' % cache)
-
-    def is_cache_valid(self, cache, version):
-        try:
-            return self.cache[cache] == version
-        except KeyError:
-            raise ValueError('Cache "%s" is not registered' % cache)
-
-    def invalidate_cache(self, cache):
-        from .cache import cache as default_cache
-        from .models import CacheVersion
-
-        self.cache[cache] += 1
-        CacheVersion.objects.filter(cache=cache).update(version=F('version') + 1)
-        default_cache.delete(CACHE_KEY)
-
-    def invalidate_all(self):
-        from .cache import cache as default_cache
-        from .models import CacheVersion
-
-        CacheVersion.objects.update(version=F('version') + 1)
-        default_cache.delete(CACHE_KEY)
-
-
-_controller = CacheBusterController()
-
-
-# Expose controller API
-def register(cache_name):
-    _controller.register_cache(cache_name)
-
-
-def unregister(cache_name):
-    _controller.unregister_cache(cache_name)
-
-
-def get_version(cache_name):
-    return _controller.get_cache_version(cache_name)
-
-
-def is_valid(cache_name, version):
-    return _controller.is_cache_valid(cache_name, version)
-
-
-def invalidate(cache_name):
-    _controller.invalidate_cache(cache_name)
-
-
-def invalidate_all():
-    _controller.invalidate_all()

+ 4 - 3
misago/core/mail.py

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

+ 1 - 7
misago/core/middleware.py

@@ -1,6 +1,6 @@
 from django.utils.deprecation import MiddlewareMixin
 
-from misago.core import exceptionhandler, threadstore
+from misago.core import exceptionhandler
 from misago.core.utils import is_request_to_misago
 
 
@@ -19,9 +19,3 @@ class FrontendContextMiddleware(MiddlewareMixin):
     def process_request(self, request):
         request.include_frontend_context = True
         request.frontend_context = {}
-
-
-class ThreadStoreMiddleware(MiddlewareMixin):
-    def process_response(self, request, response):
-        threadstore.clear()
-        return response

+ 0 - 24
misago/core/migrationutils.py

@@ -1,24 +0,0 @@
-from .cache import cache as default_cache
-from .cachebuster import CACHE_KEY
-
-
-def _CacheVersion(apps):
-    return apps.get_model('misago_core', 'CacheVersion')
-
-
-def cachebuster_register_cache(apps, cache):
-    _CacheVersion(apps).objects.create(cache=cache)
-
-
-def cachebuster_unregister_cache(apps, cache):
-    CacheVersion = _CacheVersion(apps)
-
-    try:
-        cache = CacheVersion.objects.get(cache=cache)
-        cache.delete()
-    except CacheVersion.DoesNotExist:
-        raise ValueError('Cache "%s" is not registered' % cache)
-
-
-def delete_cachebuster_cache():
-    default_cache.delete(CACHE_KEY)

+ 0 - 73
misago/core/tests/test_cachebuster.py

@@ -1,73 +0,0 @@
-from misago.core import cachebuster
-from misago.core.models import CacheVersion
-from misago.core.testutils import MisagoTestCase
-
-
-class CacheBusterTests(MisagoTestCase):
-    def test_register_unregister_cache(self):
-        """register and unregister adds/removes cache"""
-        test_cache_name = 'eric_the_fish'
-        with self.assertRaises(CacheVersion.DoesNotExist):
-            CacheVersion.objects.get(cache=test_cache_name)
-
-        cachebuster.register(test_cache_name)
-        CacheVersion.objects.get(cache=test_cache_name)
-
-        cachebuster.unregister(test_cache_name)
-        with self.assertRaises(CacheVersion.DoesNotExist):
-            CacheVersion.objects.get(cache=test_cache_name)
-
-
-class CacheBusterCacheTests(MisagoTestCase):
-    def setUp(self):
-        super().setUp()
-
-        self.cache_name = 'eric_the_fish'
-        cachebuster.register(self.cache_name)
-
-    def test_cache_validation(self):
-        """cache correctly validates"""
-        version = cachebuster.get_version(self.cache_name)
-        self.assertEqual(version, 0)
-
-        db_version = CacheVersion.objects.get(cache=self.cache_name).version
-        self.assertEqual(db_version, 0)
-
-        self.assertEqual(db_version, version)
-        self.assertTrue(cachebuster.is_valid(self.cache_name, version))
-        self.assertTrue(cachebuster.is_valid(self.cache_name, db_version))
-
-    def test_cache_invalidation(self):
-        """invalidate has increased valid version number"""
-        db_version = CacheVersion.objects.get(cache=self.cache_name).version
-        cachebuster.invalidate(self.cache_name)
-
-        new_version = cachebuster.get_version(self.cache_name)
-        new_db_version = CacheVersion.objects.get(cache=self.cache_name)
-        new_db_version = new_db_version.version
-
-        self.assertEqual(new_version, 1)
-        self.assertEqual(new_db_version, 1)
-        self.assertEqual(new_version, new_db_version)
-        self.assertFalse(cachebuster.is_valid(self.cache_name, db_version))
-        self.assertTrue(cachebuster.is_valid(self.cache_name, new_db_version))
-
-    def test_cache_invalidation_all(self):
-        """invalidate_all has increased valid version number"""
-        cache_a = "eric_the_halibut"
-        cache_b = "eric_the_crab"
-        cache_c = "eric_the_lion"
-
-        cachebuster.register(cache_a)
-        cachebuster.register(cache_b)
-        cachebuster.register(cache_c)
-
-        cachebuster.invalidate_all()
-
-        new_version_a = CacheVersion.objects.get(cache=cache_a).version
-        new_version_b = CacheVersion.objects.get(cache=cache_b).version
-        new_version_c = CacheVersion.objects.get(cache=cache_c).version
-
-        self.assertEqual(new_version_a, 1)
-        self.assertEqual(new_version_b, 1)
-        self.assertEqual(new_version_c, 1)

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

@@ -4,9 +4,12 @@ from django.test import Client, TestCase, override_settings
 from django.test.client import RequestFactory
 from django.urls import reverse
 
+from misago.acl.useracl import get_user_acl
+from misago.users.models import AnonymousUser
+from misago.conf.dynamicsettings import DynamicSettings
+from misago.conftest import get_cache_versions
 from misago.core.testproject.views import mock_custom_403_error_page, mock_custom_404_error_page
 from misago.core.utils import encode_json_html
-from misago.users.models import AnonymousUser
 
 
 class CSRFErrorViewTests(TestCase):
@@ -73,20 +76,22 @@ class ErrorPageViewsTests(TestCase):
         self.assertContains(response, "Banned in auth!", status_code=403)
 
 
+def test_request(url):
+    request = RequestFactory().get(url)
+    request.cache_versions = get_cache_versions()
+    request.settings = DynamicSettings(request.cache_versions)
+    request.user = AnonymousUser()
+    request.user_acl = get_user_acl(request.user, request.cache_versions)
+    request.include_frontend_context = True
+    request.frontend_context = {}
+    return request
+
+
 @override_settings(ROOT_URLCONF='misago.core.testproject.urlswitherrorhandlers')
 class CustomErrorPagesTests(TestCase):
     def setUp(self):
-        self.misago_request = RequestFactory().get(reverse('misago:index'))
-        self.site_request = RequestFactory().get(reverse('raise-403'))
-
-        self.misago_request.user = AnonymousUser()
-        self.site_request.user = AnonymousUser()
-
-        self.misago_request.include_frontend_context = True
-        self.site_request.include_frontend_context = True
-
-        self.misago_request.frontend_context = {}
-        self.site_request.frontend_context = {}
+        self.misago_request = test_request(reverse('misago:index'))
+        self.site_request = test_request(reverse('raise-403'))
 
     def test_shared_403_decorator(self):
         """shared_403_decorator calls correct error handler"""

+ 20 - 12
misago/core/tests/test_exceptionhandler_middleware.py

@@ -3,27 +3,35 @@ from django.test import TestCase
 from django.test.client import RequestFactory
 from django.urls import reverse
 
+from misago.acl.useracl import get_user_acl
+from misago.conf.dynamicsettings import DynamicSettings
 from misago.core.middleware import ExceptionHandlerMiddleware
+from misago.conftest import get_cache_versions
 from misago.users.models import AnonymousUser
 
+from misago.core.middleware import ExceptionHandlerMiddleware
 
-class ExceptionHandlerMiddlewareTests(TestCase):
-    def setUp(self):
-        self.request = RequestFactory().get(reverse('misago:index'))
-        self.request.user = AnonymousUser()
-        self.request.include_frontend_context = True
-        self.request.frontend_context = {}
 
+def test_request():
+    request = RequestFactory().get(reverse('misago:index'))
+    request.cache_versions = get_cache_versions()
+    request.settings = DynamicSettings(request.cache_versions)
+    request.user = AnonymousUser()
+    request.user_acl = get_user_acl(request.user, request.cache_versions)
+    request.include_frontend_context = True
+    request.frontend_context = {}
+    return request
+
+
+class ExceptionHandlerMiddlewareTests(TestCase):
     def test_middleware_returns_response_for_supported_exception(self):
         """Middleware returns HttpResponse for supported exception"""
-        exception = Http404()
         middleware = ExceptionHandlerMiddleware()
-
-        self.assertTrue(middleware.process_exception(self.request, exception))
+        exception = Http404()
+        assert middleware.process_exception(test_request(), exception)
 
     def test_middleware_returns_none_for_non_supported_exception(self):
         """Middleware returns None for non-supported exception"""
-        exception = TypeError()
         middleware = ExceptionHandlerMiddleware()
-
-        self.assertFalse(middleware.process_exception(self.request, exception))
+        exception = TypeError()
+        assert middleware.process_exception(test_request(), exception) is None

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

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

+ 0 - 26
misago/core/tests/test_migrationutils.py

@@ -1,26 +0,0 @@
-from django.apps import apps
-from django.test import TestCase
-
-from misago.core import migrationutils
-from misago.core.models import CacheVersion
-
-
-class CacheBusterUtilsTests(TestCase):
-    def test_cachebuster_register_cache(self):
-        """cachebuster_register_cache registers cache on migration successfully"""
-        cache_name = 'eric_licenses'
-        migrationutils.cachebuster_register_cache(apps, cache_name)
-        CacheVersion.objects.get(cache=cache_name)
-
-    def test_cachebuster_unregister_cache(self):
-        """cachebuster_unregister_cache removes cache on migration successfully"""
-        cache_name = 'eric_licenses'
-        migrationutils.cachebuster_register_cache(apps, cache_name)
-        CacheVersion.objects.get(cache=cache_name)
-
-        migrationutils.cachebuster_unregister_cache(apps, cache_name)
-        with self.assertRaises(CacheVersion.DoesNotExist):
-            CacheVersion.objects.get(cache=cache_name)
-
-        with self.assertRaises(ValueError):
-            migrationutils.cachebuster_unregister_cache(apps, cache_name)

+ 0 - 41
misago/core/tests/test_threadstore.py

@@ -1,41 +0,0 @@
-from django.test import TestCase
-from django.test.client import RequestFactory
-from django.urls import reverse
-
-from misago.core import threadstore
-from misago.core.middleware import ThreadStoreMiddleware
-
-
-class ThreadStoreTests(TestCase):
-    def setUp(self):
-        threadstore.clear()
-
-    def test_set_get_value(self):
-        """It's possible to set and get value from threadstore"""
-        self.assertEqual(threadstore.get('knights_say'), None)
-
-        returned_value = threadstore.set('knights_say', 'Ni!')
-        self.assertEqual(returned_value, 'Ni!')
-        self.assertEqual(threadstore.get('knights_say'), 'Ni!')
-
-    def test_clear_store(self):
-        """clear cleared threadstore"""
-        self.assertEqual(threadstore.get('the_fish'), None)
-        threadstore.set('the_fish', 'Eric')
-        self.assertEqual(threadstore.get('the_fish'), 'Eric')
-        threadstore.clear()
-        self.assertEqual(threadstore.get('the_fish'), None)
-
-
-class ThreadStoreMiddlewareTests(TestCase):
-    def setUp(self):
-        self.request = RequestFactory().get(reverse('misago:index'))
-
-    def test_middleware_clears_store_on_response_exception(self):
-        """Middleware cleared store on response"""
-
-        threadstore.set('any_chesse', 'Nope')
-        middleware = ThreadStoreMiddleware()
-        response = middleware.process_response(self.request, 'FakeResponse')
-        self.assertEqual(response, 'FakeResponse')
-        self.assertEqual(threadstore.get('any_chesse'), None)

+ 0 - 20
misago/core/testutils.py

@@ -1,20 +0,0 @@
-from django.test import TestCase
-
-from . import threadstore
-from .cache import cache
-
-
-class MisagoTestCase(TestCase):
-    """TestCase class that empties global state before and after each test"""
-
-    def clear_state(self):
-        cache.clear()
-        threadstore.clear()
-
-    def setUp(self):
-        super().setUp()
-        self.clear_state()
-
-    def tearDown(self):
-        self.clear_state()
-        super().tearDown()

+ 0 - 17
misago/core/threadstore.py

@@ -1,17 +0,0 @@
-from threading import local
-
-
-_thread_local = local()
-
-
-def get(key, default=None):
-    return _thread_local.__dict__.get(key, default)
-
-
-def set(key, value):
-    _thread_local.__dict__[key] = value
-    return _thread_local.__dict__[key]
-
-
-def clear():
-    _thread_local.__dict__.clear()

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

@@ -5,7 +5,7 @@ from faker import Factory
 
 from django.core.management.base import BaseCommand
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear_acl_cache
 from misago.categories.models import Category, RoleCategoryACL
 from misago.core.management.progressbar import show_progress
 
@@ -85,7 +85,7 @@ class Command(BaseCommand):
             created_count += 1
             show_progress(self, created_count, items_to_create, start_time)
 
-        acl_version.invalidate()
+        clear_acl_cache()
 
         total_time = time.time() - start_time
         total_humanized = time.strftime('%H:%M:%S', time.gmtime(total_time))

+ 3 - 1
misago/markup/api.py

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

+ 4 - 4
misago/markup/flavours.py

@@ -43,15 +43,15 @@ def limited(request, text):
     return result['parsed_text']
 
 
-def signature(request, owner, text):
+def signature(request, owner, user_acl, text):
     result = parse(
         text,
         request,
         owner,
         allow_mentions=False,
-        allow_blocks=owner.acl_cache['allow_signature_blocks'],
-        allow_links=owner.acl_cache['allow_signature_links'],
-        allow_images=owner.acl_cache['allow_signature_images'],
+        allow_blocks=user_acl['allow_signature_blocks'],
+        allow_links=user_acl['allow_signature_links'],
+        allow_images=user_acl['allow_signature_images'],
     )
 
     return result['parsed_text']

+ 2 - 1
misago/markup/serializers.py

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

+ 3 - 3
misago/readtracker/categoriestracker.py

@@ -4,7 +4,7 @@ from misago.threads.permissions import exclude_invisible_posts, exclude_invisibl
 from .dates import get_cutoff_date
 
 
-def make_read_aware(user, categories):
+def make_read_aware(user, user_acl, categories):
     if not categories:
         return
 
@@ -17,7 +17,7 @@ def make_read_aware(user, categories):
         return
 
     threads = Thread.objects.filter(category__in=categories)
-    threads = exclude_invisible_threads(user, categories, threads)
+    threads = exclude_invisible_threads(user_acl, categories, threads)
 
     queryset = Post.objects.filter(
         category__in=categories,
@@ -26,7 +26,7 @@ def make_read_aware(user, categories):
     ).values_list('category', flat=True).distinct()
 
     queryset = queryset.exclude(id__in=user.postread_set.values('post'))
-    queryset = exclude_invisible_posts(user, categories, queryset)
+    queryset = exclude_invisible_posts(user_acl, categories, queryset)
 
     unread_categories = list(queryset)
 

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

@@ -4,15 +4,17 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.utils import timezone
 
+from misago.acl.useracl import get_user_acl
 from misago.categories.models import Category
 from misago.conf import settings
-from misago.core import cache, threadstore
+from misago.conftest import get_cache_versions
 from misago.readtracker import poststracker, categoriestracker
 from misago.readtracker.models import PostRead
 from misago.threads import testutils
 
+User = get_user_model()
 
-UserModel = get_user_model()
+cache_versions = get_cache_versions()
 
 
 class AnonymousUser(object):
@@ -22,24 +24,22 @@ class AnonymousUser(object):
 
 class CategoriesTrackerTests(TestCase):
     def setUp(self):
-        cache.cache.clear()
-        threadstore.clear()
-
-        self.user = UserModel.objects.create_user("UserA", "testa@user.com", 'Pass.123')
+        self.user = User.objects.create_user("UserA", "testa@user.com", 'Pass.123')
+        self.user_acl = get_user_acl(self.user, cache_versions)
         self.category = Category.objects.get(slug='first-category')
 
     def test_falsy_value(self):
         """passing falsy value to readtracker causes no errors"""
-        categoriestracker.make_read_aware(self.user, None)
-        categoriestracker.make_read_aware(self.user, False)
-        categoriestracker.make_read_aware(self.user, [])
+        categoriestracker.make_read_aware(self.user, self.user_acl, None)
+        categoriestracker.make_read_aware(self.user, self.user_acl, False)
+        categoriestracker.make_read_aware(self.user, self.user_acl, [])
 
     def test_anon_thread_before_cutoff(self):
         """non-tracked thread is marked as read for anonymous users"""
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         testutils.post_thread(self.category, started_on=started_on)
 
-        categoriestracker.make_read_aware(AnonymousUser(), self.category)
+        categoriestracker.make_read_aware(AnonymousUser(), None, self.category)
         self.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
 
@@ -47,7 +47,7 @@ class CategoriesTrackerTests(TestCase):
         """tracked thread is marked as read for anonymous users"""
         testutils.post_thread(self.category, started_on=timezone.now())
 
-        categoriestracker.make_read_aware(AnonymousUser(), self.category)
+        categoriestracker.make_read_aware(AnonymousUser(), None, self.category)
         self.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
 
@@ -56,7 +56,7 @@ class CategoriesTrackerTests(TestCase):
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         testutils.post_thread(self.category, started_on=started_on)
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
 
@@ -64,7 +64,7 @@ class CategoriesTrackerTests(TestCase):
         """tracked thread is marked as unread for authenticated users"""
         testutils.post_thread(self.category, started_on=timezone.now())
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
 
@@ -73,7 +73,7 @@ class CategoriesTrackerTests(TestCase):
         started_on = timezone.now() - timedelta(days=1)
         testutils.post_thread(self.category, started_on=started_on)
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
 
@@ -83,7 +83,7 @@ class CategoriesTrackerTests(TestCase):
 
         poststracker.save_read(self.user, thread.first_post)
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
 
@@ -94,7 +94,7 @@ class CategoriesTrackerTests(TestCase):
         post = testutils.reply_thread(thread, posted_on=timezone.now())
         poststracker.save_read(self.user, post)
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
 
@@ -105,7 +105,7 @@ class CategoriesTrackerTests(TestCase):
 
         testutils.reply_thread(thread, posted_on=timezone.now(), is_event=True)
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
 
@@ -120,7 +120,7 @@ class CategoriesTrackerTests(TestCase):
             is_hidden=True,
         )
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
 
@@ -136,7 +136,7 @@ class CategoriesTrackerTests(TestCase):
             is_hidden=True,
         )
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
 
@@ -145,7 +145,7 @@ class CategoriesTrackerTests(TestCase):
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         testutils.post_thread(self.category, started_on=started_on)
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
 
@@ -160,7 +160,7 @@ class CategoriesTrackerTests(TestCase):
             is_unapproved=True,
         )
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
 
@@ -176,7 +176,7 @@ class CategoriesTrackerTests(TestCase):
             is_unapproved=True,
         )
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
 
@@ -192,7 +192,7 @@ class CategoriesTrackerTests(TestCase):
             is_unapproved=True,
         )
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
 
@@ -204,7 +204,7 @@ class CategoriesTrackerTests(TestCase):
             is_unapproved=True,
         )
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)
 
@@ -217,7 +217,7 @@ class CategoriesTrackerTests(TestCase):
             is_unapproved=True,
         )
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertFalse(self.category.is_read)
         self.assertTrue(self.category.is_new)
 
@@ -229,6 +229,6 @@ class CategoriesTrackerTests(TestCase):
             is_hidden=True,
         )
 
-        categoriestracker.make_read_aware(self.user, self.category)
+        categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertTrue(self.category.is_read)
         self.assertFalse(self.category.is_new)

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

@@ -4,16 +4,18 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.utils import timezone
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
+from misago.acl.useracl import get_user_acl
 from misago.categories.models import Category
 from misago.conf import settings
-from misago.core import cache, threadstore
+from misago.conftest import get_cache_versions
 from misago.readtracker import poststracker, threadstracker
 from misago.readtracker.models import PostRead
 from misago.threads import testutils
 
+User = get_user_model()
 
-UserModel = get_user_model()
+cache_versions = get_cache_versions()
 
 
 class AnonymousUser(object):
@@ -23,26 +25,24 @@ class AnonymousUser(object):
 
 class ThreadsTrackerTests(TestCase):
     def setUp(self):
-        cache.cache.clear()
-        threadstore.clear()
-
-        self.user = UserModel.objects.create_user("UserA", "testa@user.com", 'Pass.123')
+        self.user = User.objects.create_user("UserA", "testa@user.com", 'Pass.123')
+        self.user_acl = get_user_acl(self.user, cache_versions)
         self.category = Category.objects.get(slug='first-category')
 
-        add_acl(self.user, self.category)
+        add_acl_to_obj(self.user_acl, self.category)
 
     def test_falsy_value(self):
         """passing falsy value to readtracker causes no errors"""
-        threadstracker.make_read_aware(self.user, None)
-        threadstracker.make_read_aware(self.user, False)
-        threadstracker.make_read_aware(self.user, [])
+        threadstracker.make_read_aware(self.user, self.user_acl, None)
+        threadstracker.make_read_aware(self.user, self.user_acl, False)
+        threadstracker.make_read_aware(self.user, self.user_acl, [])
 
     def test_anon_thread_before_cutoff(self):
         """non-tracked thread is marked as read for anonymous users"""
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         thread = testutils.post_thread(self.category, started_on=started_on)
 
-        threadstracker.make_read_aware(AnonymousUser(), thread)
+        threadstracker.make_read_aware(AnonymousUser(), None, thread)
         self.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
 
@@ -50,7 +50,7 @@ class ThreadsTrackerTests(TestCase):
         """tracked thread is marked as read for anonymous users"""
         thread = testutils.post_thread(self.category, started_on=timezone.now())
 
-        threadstracker.make_read_aware(AnonymousUser(), thread)
+        threadstracker.make_read_aware(AnonymousUser(), None, thread)
         self.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
 
@@ -59,7 +59,7 @@ class ThreadsTrackerTests(TestCase):
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         thread = testutils.post_thread(self.category, started_on=started_on)
 
-        threadstracker.make_read_aware(self.user, thread)
+        threadstracker.make_read_aware(self.user, self.user_acl, thread)
         self.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
 
@@ -67,7 +67,7 @@ class ThreadsTrackerTests(TestCase):
         """tracked thread is marked as unread for authenticated users"""
         thread = testutils.post_thread(self.category, started_on=timezone.now())
 
-        threadstracker.make_read_aware(self.user, thread)
+        threadstracker.make_read_aware(self.user, self.user_acl, thread)
         self.assertFalse(thread.is_read)
         self.assertTrue(thread.is_new)
 
@@ -76,7 +76,7 @@ class ThreadsTrackerTests(TestCase):
         started_on = timezone.now() - timedelta(days=1)
         thread = testutils.post_thread(self.category, started_on=started_on)
 
-        threadstracker.make_read_aware(self.user, thread)
+        threadstracker.make_read_aware(self.user, self.user_acl, thread)
         self.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
 
@@ -86,7 +86,7 @@ class ThreadsTrackerTests(TestCase):
 
         poststracker.save_read(self.user, thread.first_post)
 
-        threadstracker.make_read_aware(self.user, thread)
+        threadstracker.make_read_aware(self.user, self.user_acl, thread)
         self.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
 
@@ -97,7 +97,7 @@ class ThreadsTrackerTests(TestCase):
         post = testutils.reply_thread(thread, posted_on=timezone.now())
         poststracker.save_read(self.user, post)
 
-        threadstracker.make_read_aware(self.user, thread)
+        threadstracker.make_read_aware(self.user, self.user_acl, thread)
         self.assertFalse(thread.is_read)
         self.assertTrue(thread.is_new)
 
@@ -108,7 +108,7 @@ class ThreadsTrackerTests(TestCase):
 
         testutils.reply_thread(thread, posted_on=timezone.now(), is_event=True)
 
-        threadstracker.make_read_aware(self.user, thread)
+        threadstracker.make_read_aware(self.user, self.user_acl, thread)
         self.assertFalse(thread.is_read)
         self.assertTrue(thread.is_new)
 
@@ -123,7 +123,7 @@ class ThreadsTrackerTests(TestCase):
             is_hidden=True,
         )
 
-        threadstracker.make_read_aware(self.user, thread)
+        threadstracker.make_read_aware(self.user, self.user_acl, thread)
         self.assertFalse(thread.is_read)
         self.assertTrue(thread.is_new)
 
@@ -139,7 +139,7 @@ class ThreadsTrackerTests(TestCase):
             is_hidden=True,
         )
 
-        threadstracker.make_read_aware(self.user, thread)
+        threadstracker.make_read_aware(self.user, self.user_acl, thread)
         self.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
 
@@ -148,7 +148,7 @@ class ThreadsTrackerTests(TestCase):
         started_on = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
         thread = testutils.post_thread(self.category, started_on=started_on)
 
-        threadstracker.make_read_aware(self.user, thread)
+        threadstracker.make_read_aware(self.user, self.user_acl, thread)
         self.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
 
@@ -163,7 +163,7 @@ class ThreadsTrackerTests(TestCase):
             is_unapproved=True,
         )
 
-        threadstracker.make_read_aware(self.user, thread)
+        threadstracker.make_read_aware(self.user, self.user_acl, thread)
         self.assertTrue(thread.is_read)
         self.assertFalse(thread.is_new)
 
@@ -179,6 +179,6 @@ class ThreadsTrackerTests(TestCase):
             is_unapproved=True,
         )
 
-        threadstracker.make_read_aware(self.user, thread)
+        threadstracker.make_read_aware(self.user, self.user_acl, thread)
         self.assertFalse(thread.is_read)
         self.assertTrue(thread.is_new)

+ 2 - 2
misago/readtracker/threadstracker.py

@@ -4,7 +4,7 @@ from misago.threads.permissions import exclude_invisible_posts
 from .dates import get_cutoff_date
 
 
-def make_read_aware(user, threads):
+def make_read_aware(user, user_acl, threads):
     if not threads:
         return
 
@@ -24,7 +24,7 @@ def make_read_aware(user, threads):
     ).values_list('thread', flat=True).distinct()
 
     queryset = queryset.exclude(id__in=user.postread_set.values('post'))
-    queryset = exclude_invisible_posts(user, categories, queryset)
+    queryset = exclude_invisible_posts(user_acl, categories, queryset)
 
     unread_threads = list(queryset)
 

+ 1 - 1
misago/search/api.py

@@ -15,7 +15,7 @@ from .searchproviders import searchproviders
 @api_view()
 def search(request, search_provider=None):
     allowed_providers = searchproviders.get_allowed_providers(request)
-    if not request.user.acl_cache['can_search'] or not allowed_providers:
+    if not request.user_acl['can_search'] or not allowed_providers:
         raise PermissionDenied(_("You don't have permission to search site."))
 
     search_query = get_search_query(request)

+ 1 - 1
misago/search/context_processors.py

@@ -8,7 +8,7 @@ def search_providers(request):
     allowed_providers = []
 
     try:
-        if request.user.acl_cache['can_search']:
+        if request.user_acl['can_search']:
             allowed_providers = searchproviders.get_allowed_providers(request)
     except AttributeError:
         # is user has no acl_cache attribute, cease entire middleware

+ 2 - 3
misago/search/tests/test_api.py

@@ -1,6 +1,6 @@
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.search.searchproviders import searchproviders
 from misago.users.testutils import AuthenticatedUserTestCase
 
@@ -11,10 +11,9 @@ class SearchApiTests(AuthenticatedUserTestCase):
 
         self.test_link = reverse('misago:api:search')
 
+    @patch_user_acl({"can_search": False})
     def test_no_permission(self):
         """api validates permission to search"""
-        override_acl(self.user, {'can_search': 0})
-
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {

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

@@ -1,6 +1,6 @@
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.threads.search import SearchThreads
 from misago.users.testutils import AuthenticatedUserTestCase
 
@@ -11,27 +11,24 @@ class LandingTests(AuthenticatedUserTestCase):
 
         self.test_link = reverse('misago:search')
 
+    @patch_user_acl({'can_search': False})
     def test_no_permission(self):
         """view validates permission to search forum"""
-        override_acl(self.user, {'can_search': 0})
-
         response = self.client.get(self.test_link)
-
         self.assertContains(response, "have permission to search site", status_code=403)
 
+    @patch_user_acl({'can_search': True})
     def test_redirect_to_provider(self):
         """view validates permission to search forum"""
         response = self.client.get(self.test_link)
-
         self.assertEqual(response.status_code, 302)
         self.assertIn(SearchThreads.url, response['location'])
 
 
 class SearchTests(AuthenticatedUserTestCase):
+    @patch_user_acl({'can_search': False})
     def test_no_permission(self):
         """view validates permission to search forum"""
-        override_acl(self.user, {'can_search': 0})
-
         response = self.client.get(
             reverse('misago:search', kwargs={
                 'search_provider': 'users',
@@ -48,10 +45,9 @@ class SearchTests(AuthenticatedUserTestCase):
 
         self.assertEqual(response.status_code, 404)
 
+    @patch_user_acl({'can_search': True, 'can_search_users': False})
     def test_provider_no_permission(self):
         """provider raises 403 without permission"""
-        override_acl(self.user, {'can_search_users': 0})
-
         response = self.client.get(
             reverse('misago:search', kwargs={
                 'search_provider': 'users',
@@ -64,7 +60,7 @@ class SearchTests(AuthenticatedUserTestCase):
         """provider displays no script page"""
         response = self.client.get(
             reverse('misago:search', kwargs={
-                'search_provider': 'threads',
+                'search_provider': 'users',
             })
         )
 

+ 2 - 2
misago/search/views.py

@@ -9,7 +9,7 @@ from .searchproviders import searchproviders
 
 def landing(request):
     allowed_providers = searchproviders.get_allowed_providers(request)
-    if not request.user.acl_cache['can_search'] or not allowed_providers:
+    if not request.user_acl['can_search'] or not allowed_providers:
         raise PermissionDenied(_("You don't have permission to search site."))
 
     default_provider = allowed_providers[0]
@@ -18,7 +18,7 @@ def landing(request):
 
 def search(request, search_provider):
     all_providers = searchproviders.get_providers(request)
-    if not request.user.acl_cache['can_search'] or not all_providers:
+    if not request.user_acl['can_search'] or not all_providers:
         raise PermissionDenied(_("You don't have permission to search site."))
 
     for provider in all_providers:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 3 - 3
misago/threads/middleware.py

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

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

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

+ 5 - 2
misago/threads/participants.py

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

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

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

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

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

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

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

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

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

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

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

+ 1 - 1
misago/threads/search.py

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

+ 30 - 27
misago/threads/serializers/moderation.py

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

+ 109 - 0
misago/threads/test.py

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 21 - 24
misago/threads/tests/test_subscription_middleware.py

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -2,17 +2,21 @@ import json
 
 from django.urls import reverse
 
-from misago.acl import add_acl
-from misago.acl.testutils import override_acl
+from misago.acl import useracl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.categories.models import Category
+from misago.conftest import get_cache_versions
 from misago.readtracker import poststracker
 from misago.threads import testutils
-from misago.threads.serializers.moderation import THREADS_LIMIT
 from misago.threads.models import Poll, PollVote, Post, Thread
 from misago.threads.serializers import ThreadsListSerializer
+from misago.threads.serializers.moderation import THREADS_LIMIT
+from misago.threads.test import patch_category_acl, patch_other_category_acl
 
 from .test_threads_api import ThreadsApiTestCase
 
+cache_versions = get_cache_versions()
+
 
 class ThreadsMergeApiTests(ThreadsApiTestCase):
     def setUp(self):
@@ -20,40 +24,14 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.api_link = reverse('misago:api:thread-merge')
 
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other Category',
+            slug='other-category',
         ).insert_at(
             self.category,
             position='last-child',
             save=True,
         )
-        self.category_b = Category.objects.get(slug='category-b')
-
-    def override_other_category(self):
-        categories =  self.user.acl_cache['categories']
-
-        visible_categories = self.user.acl_cache['visible_categories']
-        browseable_categories = self.user.acl_cache['browseable_categories']
-
-        visible_categories.append(self.category_b.pk)
-        browseable_categories.append(self.category_b.pk)
-
-        override_acl(
-            self.user, {
-                'visible_categories': visible_categories,
-                'browseable_categories': browseable_categories,
-                'categories': {
-                    self.category.pk: categories[self.category.pk],
-                    self.category_b.pk: {
-                        'can_see': 1,
-                        'can_browse': 1,
-                        'can_see_all_threads': 1,
-                        'can_see_own_threads': 0,
-                        'can_start_threads': 2,
-                    },
-                },
-            }
-        )
+        self.other_category = Category.objects.get(slug='other-category')
 
     def test_merge_no_threads(self):
         """api validates if we are trying to merge no threads"""
@@ -143,7 +121,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
     def test_merge_with_invisible_thread(self):
         """api validates if we are trying to merge with inaccesible thread"""
-        unaccesible_thread = testutils.post_thread(category=self.category_b)
+        unaccesible_thread = testutils.post_thread(category=self.other_category)
 
         response = self.client.post(
             self.api_link,
@@ -166,7 +144,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(
             self.api_link,
             json.dumps({
-                'category': self.category.pk,
+                'category': self.category.id,
                 'title': 'Lorem ipsum dolor',
                 'threads': [self.thread.id, thread.id],
             }),
@@ -188,14 +166,10 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             ]
         )
 
+    @patch_other_category_acl()
+    @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
     def test_thread_category_is_closed(self):
         """api validates if thread's category is open"""
-        self.override_acl({
-            'can_merge_threads': 1,
-            'can_close_threads': 0,
-        })
-        self.override_other_category()
-
         other_thread = testutils.post_thread(self.category)
 
         self.category.is_closed = True
@@ -204,7 +178,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(
             self.api_link,
             json.dumps({
-                'category': self.category_b.pk,
+                'category': self.other_category.id,
                 'title': 'Lorem ipsum dolor',
                 'threads': [self.thread.id, other_thread.id],
             }),
@@ -224,14 +198,10 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             },
         ])
 
+    @patch_other_category_acl()
+    @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
     def test_thread_is_closed(self):
         """api validates if thread is open"""
-        self.override_acl({
-            'can_merge_threads': 1,
-            'can_close_threads': 0,
-        })
-        self.override_other_category()
-
         other_thread = testutils.post_thread(self.category)
 
         other_thread.is_closed = True
@@ -240,7 +210,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         response = self.client.post(
             self.api_link,
             json.dumps({
-                'category': self.category_b.pk,
+                'category': self.other_category.id,
                 'title': 'Lorem ipsum dolor',
                 'threads': [self.thread.id, other_thread.id],
             }),
@@ -255,19 +225,13 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             },
         ])
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_too_many_threads(self):
         """api rejects too many threads to merge"""
         threads = []
         for _ in range(THREADS_LIMIT + 1):
             threads.append(testutils.post_thread(category=self.category).pk)
 
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         response = self.client.post(
             self.api_link,
             json.dumps({
@@ -282,15 +246,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_no_final_thread(self):
         """api rejects merge because no data to merge threads was specified"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -308,15 +266,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_invalid_final_title(self):
         """api rejects merge because final thread title was invalid"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -335,15 +287,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_invalid_category(self):
         """api rejects merge because final category was invalid"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -351,7 +297,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             json.dumps({
                 'threads': [self.thread.id, thread.id],
                 'title': 'Valid thread title',
-                'category': self.category_b.id,
+                'category': self.other_category.id,
             }),
             content_type="application/json",
         )
@@ -362,16 +308,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_start_threads": False})
     def test_merge_unallowed_start_thread(self):
         """api rejects merge because category isn't allowing starting threads"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-            'can_start_threads': 0,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -390,15 +329,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_invalid_weight(self):
         """api rejects merge because final weight was invalid"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -418,15 +351,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_unallowed_global_weight(self):
         """api rejects merge because global weight was unallowed"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -446,15 +373,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_unallowed_local_weight(self):
         """api rejects merge because local weight was unallowed"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -474,16 +395,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_pin_threads": 1})
     def test_merge_allowed_local_weight(self):
         """api allows local weight"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-            'can_pin_threads': 1,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -503,16 +417,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_pin_threads": 2})
     def test_merge_allowed_global_weight(self):
         """api allows global weight"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-            'can_pin_threads': 2,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -532,15 +439,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
     def test_merge_unallowed_close(self):
         """api rejects merge because closing thread was unallowed"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -560,15 +461,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_close_threads": True})
     def test_merge_with_close(self):
         """api allows for closing thread"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-            'can_close_threads': True,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -589,16 +484,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_hide_threads": 0})
     def test_merge_unallowed_hidden(self):
         """api rejects merge because hidden thread was unallowed"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-            'can_hide_threads': 0,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -618,16 +506,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True, "can_hide_threads": 1})
     def test_merge_with_hide(self):
         """api allows for hiding thread"""
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-            'can_hide_threads': 1,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -648,17 +529,10 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             }
         )
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge(self):
         """api performs basic merge"""
         posts_ids = [p.id for p in Post.objects.all()]
-
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': False,
-            'can_edit_threads': False,
-            'can_reply_threads': False,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         response = self.client.post(
@@ -679,8 +553,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread.is_read = False
         new_thread.subscription = None
 
-        add_acl(self.user, new_thread.category)
-        add_acl(self.user, new_thread)
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
+        add_acl_to_obj(user_acl, new_thread.category)
+        add_acl_to_obj(user_acl, new_thread)
 
         self.assertEqual(response_json, ThreadsListSerializer(new_thread).data)
 
@@ -691,17 +566,15 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         # are old threads gone?
         self.assertEqual([t.pk for t in Thread.objects.all()], [new_thread.pk])
 
+    @patch_category_acl({
+        "can_merge_threads": True,
+        "can_close_threads": True,
+        "can_hide_threads": 1,
+        "can_pin_threads": 2,
+    })
     def test_merge_kitchensink(self):
         """api performs merge"""
         posts_ids = [p.id for p in Post.objects.all()]
-
-        self.override_acl({
-            'can_merge_threads': True,
-            'can_close_threads': True,
-            'can_hide_threads': 1,
-            'can_pin_threads': 2,
-        })
-
         thread = testutils.post_thread(category=self.category)
 
         poststracker.save_read(self.user, self.thread.first_post)
@@ -745,8 +618,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertTrue(new_thread.is_closed)
         self.assertTrue(new_thread.is_hidden)
 
-        add_acl(self.user, new_thread.category)
-        add_acl(self.user, new_thread)
+        user_acl = useracl.get_user_acl(self.user, cache_versions)
+        add_acl_to_obj(user_acl, new_thread.category)
+        add_acl_to_obj(user_acl, new_thread)
 
         self.assertEqual(response_json, ThreadsListSerializer(new_thread).data)
 
@@ -772,10 +646,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.user.subscription_set.get(thread=new_thread)
         self.user.subscription_set.get(category=self.category)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_merged_best_answer(self):
         """api merges two threads successfully, moving best answer to old thread"""
-        self.override_acl({'can_merge_threads': 1})
-
         other_thread = testutils.post_thread(self.category)
 
         best_answer = testutils.reply_thread(self.thread)
@@ -797,10 +670,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread = Thread.objects.get(pk=response.json()['id'])
         self.assertEqual(new_thread.best_answer_id, best_answer.id)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_merge_conflict_best_answer(self):
         """api errors on merge conflict, returning list of available best answers"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
@@ -835,10 +707,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(
             Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_best_answer_invalid_resolution(self):
         """api errors on invalid merge conflict resolution"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
@@ -868,10 +739,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(
             Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_unmark_all_best_answers(self):
         """api unmarks all best answers when unmark all choice is selected"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
@@ -898,10 +768,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertFalse(new_thread.has_best_answer)
         self.assertIsNone(new_thread.best_answer_id)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_first_best_answer(self):
         """api unmarks other best answer on merge"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
@@ -927,10 +796,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread = Thread.objects.get(pk=response.json()['id'])
         self.assertEqual(new_thread.best_answer_id, best_answer.id)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_other_best_answer(self):
         """api unmarks first best answer on merge"""
-        self.override_acl({'can_merge_threads': 1})
-
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
@@ -956,10 +824,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         new_thread = Thread.objects.get(pk=response.json()['id'])
         self.assertEqual(new_thread.best_answer_id, other_best_answer.id)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_kept_poll(self):
         """api merges two threads successfully, keeping poll from other thread"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(other_thread, self.user)
 
@@ -983,10 +850,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 1)
         self.assertEqual(PollVote.objects.count(), 4)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_merge_threads_moved_poll(self):
         """api merges two threads successfully, moving poll from old thread"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
 
@@ -1010,10 +876,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 1)
         self.assertEqual(PollVote.objects.count(), 4)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_poll(self):
         """api errors on merge conflict, returning list of available polls"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
@@ -1049,10 +914,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 2)
         self.assertEqual(PollVote.objects.count(), 8)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_poll_invalid_resolution(self):
         """api errors on invalid merge conflict resolution"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
 
         testutils.post_poll(self.thread, self.user)
@@ -1078,10 +942,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 2)
         self.assertEqual(PollVote.objects.count(), 8)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_delete_all_polls(self):
         """api deletes all polls when delete all choice is selected"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
 
         testutils.post_poll(self.thread, self.user)
@@ -1103,10 +966,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(Poll.objects.count(), 0)
         self.assertEqual(PollVote.objects.count(), 0)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_first_poll(self):
         """api deletes other poll on merge"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
@@ -1131,10 +993,9 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         with self.assertRaises(Poll.DoesNotExist):
             Poll.objects.get(pk=other_poll.pk)
 
+    @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_keep_other_poll(self):
         """api deletes first poll on merge"""
-        self.override_acl({'can_merge_threads': True})
-
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)

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

@@ -4,7 +4,7 @@ from django.urls import reverse
 from django.utils import timezone
 from django.utils.encoding import smart_str
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.conf import settings
 from misago.readtracker import poststracker
@@ -12,10 +12,48 @@ from misago.threads import testutils
 from misago.users.models import AnonymousUser
 from misago.users.testutils import AuthenticatedUserTestCase
 
-
 LISTS_URLS = ('', 'my/', 'new/', 'unread/', 'subscribed/', )
 
 
+def patch_categories_acl(category_acl=None, base_acl=None):
+    def patch_acl(_, user_acl):
+        first_category = Category.objects.get(slug='first-category')
+        first_category_acl = user_acl['categories'][first_category.id].copy()
+
+        user_acl.update({
+            'categories': {},
+            'visible_categories': [],
+            'browseable_categories': [],
+            'can_approve_content': [],
+        })
+
+        # copy first category's acl to other categories to make base for overrides
+        for category in Category.objects.all_categories():
+            user_acl['categories'][category.id] = first_category_acl
+
+        if base_acl:
+            user_acl.update(base_acl)
+
+        for category in Category.objects.all_categories():
+            user_acl['visible_categories'].append(category.id)
+            user_acl['browseable_categories'].append(category.id)
+            user_acl['categories'][category.id].update({
+                'can_see': 1,
+                'can_browse': 1,
+                'can_see_all_threads': 1,
+                'can_see_own_threads': 0,
+                'can_hide_threads': 0,
+                'can_approve_content': 0,
+            })
+
+            if category_acl:
+                user_acl['categories'][category.id].update(category_acl)
+                if category_acl.get('can_approve_content'):
+                    user_acl['can_approve_content'].append(category.id)
+
+    return patch_user_acl(patch_acl)
+
+
 class ThreadsListTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         """
@@ -108,8 +146,6 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
 
         self.category_f = Category.objects.get(slug='category-f')
 
-        self.clear_state()
-
         Category.objects.partial_rebuild(self.root.tree_id)
 
         self.root = Category.objects.root_category()
@@ -120,46 +156,6 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
         self.category_e = Category.objects.get(slug='category-e')
         self.category_f = Category.objects.get(slug='category-f')
 
-        self.access_all_categories()
-
-    def access_all_categories(self, category_acl=None, base_acl=None):
-        self.clear_state()
-
-        categories_acl = {
-            'categories': {},
-            'visible_categories': [],
-            'browseable_categories': [],
-            'can_approve_content': [],
-        }
-
-        # copy first category's acl to other categories to make base for overrides
-        first_category_acl = self.user.acl_cache['categories'][self.first_category.pk].copy()
-        for category in Category.objects.all_categories():
-            categories_acl['categories'][category.pk] = first_category_acl
-
-        if base_acl:
-            categories_acl.update(base_acl)
-
-        for category in Category.objects.all_categories():
-            categories_acl['visible_categories'].append(category.pk)
-            categories_acl['browseable_categories'].append(category.pk)
-            categories_acl['categories'][category.pk].update({
-                'can_see': 1,
-                'can_browse': 1,
-                'can_see_all_threads': 1,
-                'can_see_own_threads': 0,
-                'can_hide_threads': 0,
-                'can_approve_content': 0,
-            })
-
-            if category_acl:
-                categories_acl['categories'][category.pk].update(category_acl)
-                if category_acl.get('can_approve_content'):
-                    categories_acl['can_approve_content'].append(category.pk)
-
-        override_acl(self.user, categories_acl)
-        return categories_acl
-
     def assertContainsThread(self, response, thread):
         self.assertContains(response, ' href="%s"' % thread.get_absolute_url())
 
@@ -185,11 +181,10 @@ class ApiTests(ThreadsListTestCase):
 
 
 class AllThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_empty(self):
         """empty threads list renders"""
         for url in LISTS_URLS:
-            self.access_all_categories()
-
             response = self.client.get('/' + url)
             self.assertEqual(response.status_code, 200)
             self.assertContains(response, "empty-message")
@@ -198,8 +193,6 @@ class AllThreadsListTests(ThreadsListTestCase):
             else:
                 self.assertContains(response, "There are no threads on this forum")
 
-            self.access_all_categories()
-
             response = self.client.get(self.category_b.get_absolute_url() + url)
             self.assertEqual(response.status_code, 200)
             self.assertContains(response, self.category_b.name)
@@ -209,8 +202,6 @@ class AllThreadsListTests(ThreadsListTestCase):
             else:
                 self.assertContains(response, "There are no threads in this category")
 
-            self.access_all_categories()
-
             response = self.client.get('%s?list=%s' % (self.api_link, url.strip('/') or 'all'))
             self.assertEqual(response.status_code, 200)
 
@@ -221,46 +212,34 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.logout_user()
         self.user = self.get_anonymous_user()
 
-        self.access_all_categories()
-
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "There are no threads on this forum")
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_b.get_absolute_url())
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.category_b.name)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "There are no threads in this category")
 
-        self.access_all_categories()
-
         response = self.client.get('%s?list=all' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_authenticated_only_views(self):
         """authenticated only views return 403 for guests"""
         for url in LISTS_URLS:
-            self.access_all_categories()
-
             response = self.client.get('/' + url)
             self.assertEqual(response.status_code, 200)
 
-            self.access_all_categories()
-
             response = self.client.get(self.category_b.get_absolute_url() + url)
             self.assertEqual(response.status_code, 200)
             self.assertContains(response, self.category_b.name)
 
-            self.access_all_categories()
-
-            self.access_all_categories()
             response = self.client.get(
                 '%s?category=%s&list=%s' %
                 (self.api_link, self.category_b.pk, url.strip('/') or 'all', )
@@ -270,22 +249,19 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.logout_user()
         self.user = self.get_anonymous_user()
         for url in LISTS_URLS[1:]:
-            self.access_all_categories()
-
             response = self.client.get('/' + url)
             self.assertEqual(response.status_code, 403)
 
-            self.access_all_categories()
             response = self.client.get(self.category_b.get_absolute_url() + url)
             self.assertEqual(response.status_code, 403)
 
-            self.access_all_categories()
             response = self.client.get(
                 '%s?category=%s&list=%s' %
                 (self.api_link, self.category_b.pk, url.strip('/') or 'all', )
             )
             self.assertEqual(response.status_code, 403)
 
+    @patch_categories_acl()
     def test_list_renders_categories_picker(self):
         """categories picker renders valid categories"""
         Category(
@@ -316,7 +292,6 @@ class AllThreadsListTests(ThreadsListTestCase):
         # hidden category
         self.assertNotContains(response, 'subcategory-%s' % test_category.css_class)
 
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -325,11 +300,8 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.assertNotIn(self.category_b.pk, response_json['subcategories'])
 
         # test category view
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url())
         self.assertEqual(response.status_code, 200)
-
         self.assertContains(response, 'subcategory-%s' % self.category_b.css_class)
 
         # readable categories, but non-accessible directly
@@ -337,7 +309,6 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.assertNotContains(response, 'subcategory-%s' % self.category_d.css_class)
         self.assertNotContains(response, 'subcategory-%s' % self.category_f.css_class)
 
-        self.access_all_categories()
         response = self.client.get('%s?category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
@@ -462,7 +433,7 @@ class CategoryThreadsListTests(ThreadsListTestCase):
             response = self.client.get(test_category.get_absolute_url() + url)
             self.assertEqual(response.status_code, 404)
 
-            response = self.client.get('%s?category=%s' % (self.api_link, test_category.pk))
+            response = self.client.get('%s?category=%s' % (self.api_link, test_category.id))
             self.assertEqual(response.status_code, 404)
 
     def test_access_protected_category(self):
@@ -478,37 +449,23 @@ class CategoryThreadsListTests(ThreadsListTestCase):
         test_category = Category.objects.get(slug='hidden-category')
 
         for url in LISTS_URLS:
-            override_acl(
-                self.user, {
-                    'visible_categories': [test_category.pk],
-                    'browseable_categories': [],
-                    'categories': {
-                        test_category.pk: {
-                            'can_see': 1,
-                            'can_browse': 0,
-                        },
+            with patch_user_acl({
+                'visible_categories': [test_category.id],
+                'browseable_categories': [],
+                'categories': {
+                    test_category.id: {
+                        'can_see': 1,
+                        'can_browse': 0,
                     },
-                }
-            )
-            response = self.client.get(test_category.get_absolute_url() + url)
-            self.assertEqual(response.status_code, 403)
+                },
+            }):
+                response = self.client.get(test_category.get_absolute_url() + url)
+                self.assertEqual(response.status_code, 403)
 
-            override_acl(
-                self.user, {
-                    'visible_categories': [test_category.pk],
-                    'browseable_categories': [],
-                    'categories': {
-                        test_category.pk: {
-                            'can_see': 1,
-                            'can_browse': 0,
-                        },
-                    },
-                }
-            )
-            response = self.client.get(
-                '%s?category=%s&list=%s' % (self.api_link, test_category.pk, url.strip('/'), )
-            )
-            self.assertEqual(response.status_code, 403)
+                response = self.client.get(
+                    '%s?category=%s&list=%s' % (self.api_link, test_category.id, url.strip('/'))
+                )
+                self.assertEqual(response.status_code, 403)
 
     def test_display_pinned_threads(self):
         """
@@ -550,7 +507,7 @@ class CategoryThreadsListTests(ThreadsListTestCase):
         self.assertTrue(positions['s'] > positions['g'])
 
         # API behaviour is identic
-        response = self.client.get('/api/threads/?category=%s' % self.first_category.pk)
+        response = self.client.get('/api/threads/?category=%s' % self.first_category.id)
         self.assertEqual(response.status_code, 200)
 
         content = smart_str(response.content)
@@ -574,6 +531,7 @@ class CategoryThreadsListTests(ThreadsListTestCase):
 
 
 class ThreadsVisibilityTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_test_thread(self):
         """list renders test thread with valid top category"""
         test_thread = testutils.post_thread(
@@ -592,7 +550,6 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertContains(response, 'thread-detail-category-%s' % self.category_c.css_class)
 
         # api displays same data
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -602,7 +559,6 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertIn(self.category_a.pk, response_json['subcategories'])
 
         # test category view
-        self.access_all_categories()
         response = self.client.get(self.category_b.get_absolute_url())
         self.assertEqual(response.status_code, 200)
 
@@ -613,7 +569,6 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertContains(response, 'thread-detail-category-%s' % self.category_c.css_class)
 
         # api displays same data
-        self.access_all_categories()
         response = self.client.get('%s?category=%s' % (self.api_link, self.category_b.pk))
         self.assertEqual(response.status_code, 200)
 
@@ -664,6 +619,7 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_user_see_own_unapproved_thread(self):
         """list renders unapproved thread that belongs to viewer"""
         test_thread = testutils.post_thread(
@@ -677,13 +633,13 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
+    @patch_categories_acl()
     def test_list_user_cant_see_unapproved_thread(self):
         """list hides unapproved thread that belongs to other user"""
         test_thread = testutils.post_thread(
@@ -696,13 +652,13 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_user_cant_see_hidden_thread(self):
         """list hides hidden thread that belongs to other user"""
         test_thread = testutils.post_thread(
@@ -715,13 +671,13 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_user_cant_see_own_hidden_thread(self):
         """list hides hidden thread that belongs to viewer"""
         test_thread = testutils.post_thread(
@@ -735,13 +691,13 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl({'can_hide_threads': 1})
     def test_list_user_can_see_own_hidden_thread(self):
         """list shows hidden thread that belongs to viewer due to permission"""
         test_thread = testutils.post_thread(
@@ -750,21 +706,18 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
             is_hidden=True,
         )
 
-        self.access_all_categories({'can_hide_threads': 1})
-
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories({'can_hide_threads': 1})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
+    @patch_categories_acl({'can_hide_threads': 1})
     def test_list_user_can_see_hidden_thread(self):
         """list shows hidden thread that belongs to other user due to permission"""
         test_thread = testutils.post_thread(
@@ -772,21 +725,18 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
             is_hidden=True,
         )
 
-        self.access_all_categories({'can_hide_threads': 1})
-
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories({'can_hide_threads': 1})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
+    @patch_categories_acl({'can_approve_content': 1})
     def test_list_user_can_see_unapproved_thread(self):
         """list shows hidden thread that belongs to other user due to permission"""
         test_thread = testutils.post_thread(
@@ -794,15 +744,11 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
             is_unapproved=True,
         )
 
-        self.access_all_categories({'can_approve_content': 1})
-
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories({'can_approve_content': 1})
-
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -811,34 +757,30 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
 
 
 class MyThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_empty(self):
         """list renders empty"""
-        self.access_all_categories()
-
         response = self.client.get('/my/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'my/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=my' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=my&category=%s' % (self.api_link, self.category_a.pk))
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_renders_test_thread(self):
         """list renders only threads posted by user"""
         test_thread = testutils.post_thread(
@@ -848,22 +790,17 @@ class MyThreadsListTests(ThreadsListTestCase):
 
         other_thread = testutils.post_thread(category=self.category_a)
 
-        self.access_all_categories()
-
         response = self.client.get('/my/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertNotContainsThread(response, other_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'my/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertNotContainsThread(response, other_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=my' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -871,7 +808,6 @@ class MyThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=my&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
@@ -881,52 +817,43 @@ class MyThreadsListTests(ThreadsListTestCase):
 
 
 class NewThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_empty(self):
         """list renders empty"""
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_renders_new_thread(self):
         """list renders new thread"""
         test_thread = testutils.post_thread(category=self.category_a)
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -934,7 +861,6 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
@@ -942,6 +868,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
+    @patch_categories_acl()
     def test_list_renders_thread_bumped_after_user_cutoff(self):
         """list renders new thread bumped after user cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=10)
@@ -957,20 +884,15 @@ class NewThreadsListTests(ThreadsListTestCase):
             posted_on=self.user.joined_on + timedelta(days=4),
         )
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -978,7 +900,6 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
@@ -986,6 +907,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
+    @patch_categories_acl()
     def test_list_hides_global_cutoff_thread(self):
         """list hides thread started before global cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=10)
@@ -996,33 +918,28 @@ class NewThreadsListTests(ThreadsListTestCase):
             started_on=timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF + 1),
         )
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_hides_user_cutoff_thread(self):
         """list hides thread started before users cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
@@ -1033,63 +950,51 @@ class NewThreadsListTests(ThreadsListTestCase):
             started_on=self.user.joined_on - timedelta(minutes=1),
         )
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_hides_user_read_thread(self):
         """list hides thread already read by user"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
 
         test_thread = testutils.post_thread(category=self.category_a)
-
         poststracker.save_read(self.user, test_thread.first_post)
 
-        self.access_all_categories()
-
         response = self.client.get('/new/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'new/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
@@ -1098,29 +1003,24 @@ class NewThreadsListTests(ThreadsListTestCase):
 
 
 class UnreadThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_renders_empty(self):
         """list renders empty"""
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1129,31 +1029,25 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_renders_unread_thread(self):
         """list renders thread with unread posts"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
 
         test_thread = testutils.post_thread(category=self.category_a)
-
         poststracker.save_read(self.user, test_thread.first_post)
-
         testutils.reply_thread(test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -1161,7 +1055,6 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1171,6 +1064,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
+    @patch_categories_acl()
     def test_list_hides_never_read_thread(self):
         """list hides never read thread"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
@@ -1178,27 +1072,21 @@ class UnreadThreadsListTests(ThreadsListTestCase):
 
         test_thread = testutils.post_thread(category=self.category_a)
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1207,36 +1095,30 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_hides_read_thread(self):
         """list hides read thread"""
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
 
         test_thread = testutils.post_thread(category=self.category_a)
-
         poststracker.save_read(self.user, test_thread.first_post)
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1245,6 +1127,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_hides_global_cutoff_thread(self):
         """list hides thread replied before global cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=10)
@@ -1256,30 +1139,23 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         )
 
         poststracker.save_read(self.user, test_thread.first_post)
-
         testutils.reply_thread(test_thread, posted_on=test_thread.started_on + timedelta(days=1))
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1288,6 +1164,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
+    @patch_categories_acl()
     def test_list_hides_user_cutoff_thread(self):
         """list hides thread replied before user cutoff"""
         self.user.joined_on = timezone.now() - timedelta(days=10)
@@ -1305,27 +1182,21 @@ class UnreadThreadsListTests(ThreadsListTestCase):
             posted_on=test_thread.started_on + timedelta(days=1),
         )
 
-        self.access_all_categories()
-
         response = self.client.get('/unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unread/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1336,6 +1207,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
 
 
 class SubscribedThreadsListTests(ThreadsListTestCase):
+    @patch_categories_acl()
     def test_list_shows_subscribed_thread(self):
         """list shows subscribed thread"""
         test_thread = testutils.post_thread(category=self.category_a)
@@ -1345,20 +1217,15 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
             last_read_on=test_thread.last_post_on,
         )
 
-        self.access_all_categories()
-
         response = self.client.get('/subscribed/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'subscribed/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=subscribed' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -1366,7 +1233,6 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertContains(response, test_thread.get_absolute_url())
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=subscribed&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1376,24 +1242,20 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 1)
         self.assertContains(response, test_thread.get_absolute_url())
 
+    @patch_categories_acl()
     def test_list_hides_unsubscribed_thread(self):
         """list shows subscribed thread"""
         test_thread = testutils.post_thread(category=self.category_a)
 
-        self.access_all_categories()
-
         response = self.client.get('/subscribed/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
-
         response = self.client.get(self.category_a.get_absolute_url() + 'subscribed/')
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
         # test api
-        self.access_all_categories()
         response = self.client.get('%s?list=subscribed' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
@@ -1401,7 +1263,6 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
         self.assertEqual(len(response_json['results']), 0)
         self.assertNotContainsThread(response, test_thread)
 
-        self.access_all_categories()
         response = self.client.get(
             '%s?list=subscribed&category=%s' % (self.api_link, self.category_a.pk)
         )
@@ -1420,29 +1281,29 @@ class UnapprovedListTests(ThreadsListTestCase):
             '%s?list=unapproved' % self.api_link,
         )
 
-        for test_url in TEST_URLS:
-            self.access_all_categories()
-            response = self.client.get(test_url)
-            self.assertEqual(response.status_code, 403)
+        with patch_categories_acl():
+            for test_url in TEST_URLS:
+                response = self.client.get(test_url)
+                self.assertEqual(response.status_code, 403)
 
         # approval perm has no influence on visibility
-        for test_url in TEST_URLS:
-            self.access_all_categories({'can_approve_content': True})
-
-            self.access_all_categories()
-            response = self.client.get(test_url)
-            self.assertEqual(response.status_code, 403)
+        with patch_categories_acl({'can_approve_content': True}):
+            for test_url in TEST_URLS:
+                response = self.client.get(test_url)
+                self.assertEqual(response.status_code, 403)
 
         # approval perm has no influence on visibility
-        for test_url in TEST_URLS:
-            self.access_all_categories(base_acl={
-                'can_see_unapproved_content_lists': True,
-            })
-
-            self.access_all_categories()
-            response = self.client.get(test_url)
-            self.assertEqual(response.status_code, 200)
-
+        with patch_categories_acl(base_acl={
+            'can_see_unapproved_content_lists': True,
+        }):
+            for test_url in TEST_URLS:
+                response = self.client.get(test_url)
+                self.assertEqual(response.status_code, 200)
+
+    @patch_categories_acl(
+        {'can_approve_content': True},
+        {'can_see_unapproved_content_lists': True},
+    )
     def test_list_shows_all_threads_for_approving_user(self):
         """list shows all threads with unapproved posts when user has perm"""
         visible_thread = testutils.post_thread(
@@ -1455,40 +1316,23 @@ class UnapprovedListTests(ThreadsListTestCase):
             is_unapproved=False,
         )
 
-        self.access_all_categories({
-            'can_approve_content': True,
-        }, {
-            'can_see_unapproved_content_lists': True,
-        })
-
         response = self.client.get('/unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
-        self.access_all_categories({
-            'can_approve_content': True
-        }, {
-            'can_see_unapproved_content_lists': True,
-        })
-
         response = self.client.get(self.category_a.get_absolute_url() + 'unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
         # test api
-        self.access_all_categories({
-            'can_approve_content': True
-        }, {
-            'can_see_unapproved_content_lists': True,
-        })
-
         response = self.client.get('%s?list=unapproved' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, visible_thread.get_absolute_url())
         self.assertNotContains(response, hidden_thread.get_absolute_url())
 
+    @patch_categories_acl(base_acl={'can_see_unapproved_content_lists': True})
     def test_list_shows_owned_threads_for_unapproving_user(self):
         """list shows owned threads with unapproved posts for user without perm"""
         visible_thread = testutils.post_thread(
@@ -1502,49 +1346,41 @@ class UnapprovedListTests(ThreadsListTestCase):
             is_unapproved=True,
         )
 
-        self.access_all_categories(base_acl={
-            'can_see_unapproved_content_lists': True,
-        })
         response = self.client.get('/unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
-        self.access_all_categories(base_acl={
-            'can_see_unapproved_content_lists': True,
-        })
         response = self.client.get(self.category_a.get_absolute_url() + 'unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
         # test api
-        self.access_all_categories(base_acl={
-            'can_see_unapproved_content_lists': True,
-        })
         response = self.client.get('%s?list=unapproved' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, visible_thread.get_absolute_url())
         self.assertNotContains(response, hidden_thread.get_absolute_url())
 
 
+def patch_category_see_all_threads_acl():
+    def patch_acl(_, user_acl):
+        category = Category.objects.get(slug='first-category')
+        category_acl = user_acl['categories'][category.id].copy()
+        category_acl.update({'can_see_all_threads': 0})
+        user_acl['categories'][category.id] = category_acl
+
+    return patch_user_acl(patch_acl)
+
+
 class OwnerOnlyThreadsVisibilityTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
         self.category = Category.objects.get(slug='first-category')
 
-    def override_acl(self, user):
-        category_acl = user.acl_cache['categories'][self.category.pk].copy()
-        category_acl.update({'can_see_all_threads': 0})
-        user.acl_cache['categories'][self.category.pk] = category_acl
-
-        override_acl(user, user.acl_cache)
-
     def test_owned_threads_visibility(self):
         """only user-posted threads are visible in category"""
-        self.override_acl(self.user)
-
         visible_thread = testutils.post_thread(
             poster=self.user,
             category=self.category,
@@ -1556,18 +1392,16 @@ class OwnerOnlyThreadsVisibilityTests(AuthenticatedUserTestCase):
             is_unapproved=True,
         )
 
-        response = self.client.get(self.category.get_absolute_url())
-
-        self.assertEqual(response.status_code, 200)
-        self.assertContains(response, visible_thread.get_absolute_url())
-        self.assertNotContains(response, hidden_thread.get_absolute_url())
+        with patch_category_see_all_threads_acl():
+            response = self.client.get(self.category.get_absolute_url())
+            self.assertEqual(response.status_code, 200)
+            self.assertContains(response, visible_thread.get_absolute_url())
+            self.assertNotContains(response, hidden_thread.get_absolute_url())
 
     def test_owned_threads_visibility_anonymous(self):
         """anons can't see any threads in limited visibility category"""
         self.logout_user()
 
-        self.override_acl(AnonymousUser())
-
         user_thread = testutils.post_thread(
             poster=self.user,
             category=self.category,
@@ -1579,8 +1413,8 @@ class OwnerOnlyThreadsVisibilityTests(AuthenticatedUserTestCase):
             is_unapproved=True,
         )
 
-        response = self.client.get(self.category.get_absolute_url())
-
-        self.assertEqual(response.status_code, 200)
-        self.assertNotContains(response, user_thread.get_absolute_url())
-        self.assertNotContains(response, guest_thread.get_absolute_url())
+        with patch_category_see_all_threads_acl():
+            response = self.client.get(self.category.get_absolute_url())
+            self.assertEqual(response.status_code, 200)
+            self.assertNotContains(response, user_thread.get_absolute_url())
+            self.assertNotContains(response, guest_thread.get_absolute_url())

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

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

+ 4 - 6
misago/threads/tests/test_utils.py

@@ -1,10 +1,11 @@
+from django.test import TestCase
+
 from misago.categories.models import Category
-from misago.core.testutils import MisagoTestCase
 from misago.threads import testutils
 from misago.threads.utils import add_categories_to_items, get_thread_id_from_url
 
 
-class AddCategoriesToItemsTests(MisagoTestCase):
+class AddCategoriesToItemsTests(TestCase):
     def setUp(self):
         """
         Create categories tree for test cases:
@@ -19,7 +20,6 @@ class AddCategoriesToItemsTests(MisagoTestCase):
         Category E
           + Subcategory F
         """
-
         super().setUp()
 
         self.root = Category.objects.root_category()
@@ -90,8 +90,6 @@ class AddCategoriesToItemsTests(MisagoTestCase):
             save=True,
         )
 
-        self.clear_state()
-
         Category.objects.partial_rebuild(self.root.tree_id)
 
         self.root = Category.objects.root_category()
@@ -176,7 +174,7 @@ class MockRequest(object):
         return self.scheme == 'https'
 
 
-class GetThreadIdFromUrlTests(MisagoTestCase):
+class GetThreadIdFromUrlTests(TestCase):
     def test_get_thread_id_from_valid_urls(self):
         """get_thread_id_from_url extracts thread pk from valid urls"""
         TEST_CASES = [

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

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

+ 26 - 24
misago/threads/validators.py

@@ -12,7 +12,7 @@ from misago.core.validators import validate_sluggable
 from .threadtypes import trees_map
 
 
-def validate_category(user, category_id, allow_root=False):
+def validate_category(user_acl, category_id, allow_root=False):
     try:
         threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
         category = Category.objects.get(
@@ -26,21 +26,29 @@ def validate_category(user, category_id, allow_root=False):
     if allow_root and category and not category.level:
         return category
 
-    if not category or not can_see_category(user, category):
+    if not category or not can_see_category(user_acl, category):
         raise ValidationError(_("Requested category could not be found."))
 
-    if not can_browse_category(user, category):
+    if not can_browse_category(user_acl, category):
         raise ValidationError(_("You don't have permission to access this category."))
     return category
 
 
-def validate_title(title):
-    title_len = len(title)
+def validate_thread_title(settings, title):
+    validate_thread_title_length(settings, title)
 
-    if not title_len:
-        raise ValidationError(_("You have to enter thread title."))
+    error_not_sluggable = _("Thread title should contain alpha-numeric characters.")
+    error_slug_too_long = _("Thread title is too long.")
+    validate_sluggable(error_not_sluggable, error_slug_too_long)(title)
+
+
+def validate_thread_title_length(settings, value):
+    value_len = len(value)
+
+    if not value_len:
+        raise ValidationError(_("You have to enter an thread title."))
 
-    if title_len < settings.thread_title_length_min:
+    if value_len < settings.thread_title_length_min:
         message = ngettext(
             "Thread title should be at least %(limit_value)s character long (it has %(show_value)s).",
             "Thread title should be at least %(limit_value)s characters long (it has %(show_value)s).",
@@ -49,11 +57,11 @@ def validate_title(title):
         raise ValidationError(
             message % {
                 'limit_value': settings.thread_title_length_min,
-                'show_value': title_len,
+                'show_value': value_len,
             }
         )
 
-    if title_len > settings.thread_title_length_max:
+    if value_len > settings.thread_title_length_max:
         message = ngettext(
             "Thread title cannot be longer than %(limit_value)s character (it has %(show_value)s).",
             "Thread title cannot be longer than %(limit_value)s characters (it has %(show_value)s).",
@@ -62,24 +70,18 @@ def validate_title(title):
         raise ValidationError(
             message % {
                 'limit_value': settings.thread_title_length_max,
-                'show_value': title_len,
+                'show_value': value_len,
             }
         )
 
-    error_not_sluggable = _("Thread title should contain alpha-numeric characters.")
-    error_slug_too_long = _("Thread title is too long.")
-    validate_sluggable(error_not_sluggable, error_slug_too_long)(title)
-
-    return title
-
 
-def validate_post_length(post):
-    post_len = len(post)
+def validate_post_length(settings, value):
+    value_len = len(value)
 
-    if not post_len:
+    if not value_len:
         raise ValidationError(_("You have to enter a message."))
 
-    if post_len < settings.post_length_min:
+    if value_len < settings.post_length_min:
         message = ngettext(
             "Posted message should be at least %(limit_value)s character long (it has %(show_value)s).",
             "Posted message should be at least %(limit_value)s characters long (it has %(show_value)s).",
@@ -88,11 +90,11 @@ def validate_post_length(post):
         raise ValidationError(
             message % {
                 'limit_value': settings.post_length_min,
-                'show_value': post_len,
+                'show_value': value_len,
             }
         )
 
-    if settings.post_length_max and post_len > settings.post_length_max:
+    if settings.post_length_max and value_len > settings.post_length_max:
         message = ngettext(
             "Posted message cannot be longer than %(limit_value)s character (it has %(show_value)s).",
             "Posted message cannot be longer than %(limit_value)s characters (it has %(show_value)s).",
@@ -101,7 +103,7 @@ def validate_post_length(post):
         raise ValidationError(
             message % {
                 'limit_value': settings.post_length_max,
-                'show_value': post_len,
+                'show_value': value_len,
             }
         )
 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -56,11 +56,12 @@ def login(request):
 def session_user(request):
     """GET /auth/ will return current auth user, either User or AnonymousUser"""
     if request.user.is_authenticated:
-        UserSerializer = AuthenticatedUserSerializer
+        serializer = AuthenticatedUserSerializer
     else:
-        UserSerializer = AnonymousUserSerializer
+        serializer = AnonymousUserSerializer
 
-    return Response(UserSerializer(request.user).data)
+    serialized_user = serializer(request.user, context={"acl": request.user_acl}).data
+    return Response(serialized_user)
 
 
 @api_view(['GET'])
@@ -68,8 +69,8 @@ def get_criteria(request):
     """GET /auth/criteria/ will return password and username criteria for accounts"""
     criteria = {
         'username': {
-            'min_length': settings.username_length_min,
-            'max_length': settings.username_length_max,
+            'min_length': request.settings.username_length_min,
+            'max_length': request.settings.username_length_max,
         },
         'password': [],
     }
@@ -99,7 +100,7 @@ def send_activation(request):
 
         mail_subject = _("Activate %(user)s account on %(forum_name)s forums") % {
             'user': requesting_user.username,
-            'forum_name': settings.forum_name,
+            'forum_name': request.settings.forum_name,
         }
 
         mail_user(
@@ -107,7 +108,8 @@ def send_activation(request):
             mail_subject,
             'misago/emails/activation/by_user',
             context={
-                'activation_token': make_activation_token(requesting_user),
+                "activation_token": make_activation_token(requesting_user),
+                "settings": request.settings,
             },
         )
 
@@ -137,7 +139,7 @@ def send_password_form(request):
 
         mail_subject = _("Change %(user)s password on %(forum_name)s forums") % {
             'user': requesting_user.username,
-            'forum_name': settings.forum_name,
+            'forum_name': request.settings.forum_name,
         }
 
         confirmation_token = make_password_change_token(requesting_user)
@@ -147,7 +149,8 @@ def send_password_form(request):
             mail_subject,
             'misago/emails/change_password_form_link',
             context={
-                'confirmation_token': confirmation_token,
+                "confirmation_token": confirmation_token,
+                "settings": request.settings,
             },
         )
 
@@ -191,7 +194,7 @@ def change_forgotten_password(request, pk, token):
 
         if user.requires_activation:
             raise PasswordChangeFailed(expired_message)
-        if get_user_ban(user):
+        if get_user_ban(user, request.cache_versions):
             raise PasswordChangeFailed(expired_message)
     except PasswordChangeFailed as e:
         return Response(

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

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

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

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

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

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

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

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

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

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

+ 23 - 19
misago/users/api/userendpoints/signature.py

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

+ 54 - 39
misago/users/api/userendpoints/username.py

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

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

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

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

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

+ 3 - 3
misago/users/apps.py

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

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

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

+ 8 - 11
misago/users/bans.py

@@ -9,13 +9,10 @@ from datetime import timedelta
 from django.utils import timezone
 from django.utils.dateparse import parse_datetime
 
-from misago.core import cachebuster
-
+from .constants import BANS_CACHE
 from .models import Ban, BanCache
 
-
 CACHE_SESSION_KEY = 'misago_ip_check'
-VERSION_KEY = 'misago_bans'
 
 
 def get_username_ban(username, registration_only=False):
@@ -39,7 +36,7 @@ def get_ip_ban(ip, registration_only=False):
         return None
 
 
-def get_user_ban(user):
+def get_user_ban(user, cache_versions):
     """
     This function checks if user is banned
 
@@ -49,11 +46,11 @@ def get_user_ban(user):
     """
     try:
         ban_cache = user.ban_cache
-        if not ban_cache.is_valid:
+        if not ban_cache.is_valid(cache_versions):
             _set_user_ban_cache(user)
     except BanCache.DoesNotExist:
         user.ban_cache = BanCache(user=user)
-        user.ban_cache = _set_user_ban_cache(user)
+        user.ban_cache = _set_user_ban_cache(user, cache_versions)
 
     if user.ban_cache.ban:
         return user.ban_cache
@@ -61,9 +58,9 @@ def get_user_ban(user):
         return None
 
 
-def _set_user_ban_cache(user):
+def _set_user_ban_cache(user, cache_versions):
     ban_cache = user.ban_cache
-    ban_cache.bans_version = cachebuster.get_version(VERSION_KEY)
+    ban_cache.cache_version = cache_versions[BANS_CACHE]
 
     try:
         user_ban = Ban.objects.get_ban(
@@ -103,7 +100,7 @@ def get_request_ip_ban(request):
     found_ban = get_ip_ban(request.user_ip)
 
     ban_cache = request.session[CACHE_SESSION_KEY] = {
-        'version': cachebuster.get_version(VERSION_KEY),
+        'version': request.cache_versions[BANS_CACHE],
         'ip': request.user_ip,
     }
 
@@ -128,7 +125,7 @@ def _get_session_bancache(request):
         ban_cache = _hydrate_session_cache(ban_cache)
         if ban_cache['ip'] != request.user_ip:
             return None
-        if not cachebuster.is_valid(VERSION_KEY, ban_cache['version']):
+        if ban_cache['version'] != request.cache_versions[BANS_CACHE]:
             return None
         if ban_cache.get('expires_on'):
             if ban_cache['expires_on'] < timezone.today():

+ 10 - 10
misago/users/captcha.py

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

+ 1 - 1
misago/users/constants.py

@@ -1 +1 @@
-BANS_CACHEBUSTER = 'misago_bans'
+BANS_CACHE = "bans"

+ 5 - 2
misago/users/context_processors.py

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

+ 21 - 27
misago/users/forms/admin.py

@@ -7,15 +7,13 @@ from django.utils.translation import ngettext
 
 from misago.acl.models import Role
 from misago.admin.forms import IsoDateTimeField, YesNoSwitch
-from misago.conf import settings
-from misago.core import threadstore
 from misago.core.validators import validate_sluggable
+
 from misago.users.models import Ban, DataDownload, Rank
 from misago.users.profilefields import profilefields
 from misago.users.utils import hash_email
 from misago.users.validators import validate_email, validate_username
 
-
 UserModel = get_user_model()
 
 
@@ -28,9 +26,15 @@ class UserBaseForm(forms.ModelForm):
         model = UserModel
         fields = ['username', 'email', 'title']
 
+    def __init__(self, *args, **kwargs):
+        self.request = kwargs.pop('request')
+        self.settings = self.request.settings
+
+        super().__init__(*args, **kwargs)
+
     def clean_username(self):
         data = self.cleaned_data['username']
-        validate_username(data, exclude=self.instance)
+        validate_username(self.settings, data, exclude=self.instance)
         return data
 
     def clean_email(self):
@@ -165,10 +169,10 @@ class EditUserForm(UserBaseForm):
     )
 
     subscribe_to_started_threads = forms.TypedChoiceField(
-        label=_("Started threads"), coerce=int, choices=UserModel.SUBSCRIBE_CHOICES
+        label=_("Started threads"), coerce=int, choices=UserModel.SUBSCRIPTION_CHOICES
     )
     subscribe_to_replied_threads = forms.TypedChoiceField(
-        label=_("Replid threads"), coerce=int, choices=UserModel.SUBSCRIBE_CHOICES
+        label=_("Replid threads"), coerce=int, choices=UserModel.SUBSCRIPTION_CHOICES
     )
 
     class Meta:
@@ -191,10 +195,7 @@ class EditUserForm(UserBaseForm):
         ]
 
     def __init__(self, *args, **kwargs):
-        self.request = kwargs.pop('request')
-
         super().__init__(*args, **kwargs)
-
         profilefields.add_fields_to_admin_form(self.request, self.instance, self)
 
     def get_profile_fields_groups(self):
@@ -214,7 +215,7 @@ class EditUserForm(UserBaseForm):
     def clean_signature(self):
         data = self.cleaned_data['signature']
 
-        length_limit = settings.signature_length_max
+        length_limit = self.settings.signature_length_max
         if len(data) > length_limit:
             raise forms.ValidationError(
                 ngettext(
@@ -305,7 +306,7 @@ def EditUserFormFactory(FormType, instance, add_is_active_fields=False, add_admi
     return FormType
 
 
-class SearchUsersFormBase(forms.Form):
+class BaseSearchUsersForm(forms.Form):
     username = forms.CharField(label=_("Username starts with"), required=False)
     email = forms.CharField(label=_("E-mail starts with"), required=False)
     profilefields = forms.CharField(label=_("Profile fields contain"), required=False)
@@ -346,25 +347,19 @@ class SearchUsersFormBase(forms.Form):
         return queryset
 
 
-def SearchUsersForm(*args, **kwargs):
+def create_search_users_form():
     """
     Factory that uses cache for ranks and roles,
     and makes those ranks and roles typed choice fields that play nice
     with passing values via GET
     """
-    ranks_choices = threadstore.get('misago_admin_ranks_choices', 'nada')
-    if ranks_choices == 'nada':
-        ranks_choices = [('', _("All ranks"))]
-        for rank in Rank.objects.order_by('name').iterator():
-            ranks_choices.append((rank.pk, rank.name))
-        threadstore.set('misago_admin_ranks_choices', ranks_choices)
-
-    roles_choices = threadstore.get('misago_admin_roles_choices', 'nada')
-    if roles_choices == 'nada':
-        roles_choices = [('', _("All roles"))]
-        for role in Role.objects.order_by('name').iterator():
-            roles_choices.append((role.pk, role.name))
-        threadstore.set('misago_admin_roles_choices', roles_choices)
+    ranks_choices = [('', _("All ranks"))]
+    for rank in Rank.objects.order_by('name').iterator():
+        ranks_choices.append((rank.pk, rank.name))
+
+    roles_choices = [('', _("All roles"))]
+    for role in Role.objects.order_by('name').iterator():
+        roles_choices.append((role.pk, role.name))
 
     extra_fields = {
         'rank': forms.TypedChoiceField(
@@ -381,8 +376,7 @@ def SearchUsersForm(*args, **kwargs):
         )
     }
 
-    FinalForm = type('SearchUsersFormFinal', (SearchUsersFormBase, ), extra_fields)
-    return FinalForm(*args, **kwargs)
+    return type('SearchUsersForm', (BaseSearchUsersForm, ), extra_fields)
 
 
 class RankForm(forms.ModelForm):

+ 5 - 1
misago/users/forms/auth.py

@@ -31,7 +31,7 @@ class MisagoAuthMixin(object):
 
     def confirm_user_not_banned(self, user):
         if not user.is_staff:
-            self.user_ban = get_user_ban(user)
+            self.user_ban = get_user_ban(user, self.request.cache_versions)
             if self.user_ban:
                 raise ValidationError('', code='banned')
 
@@ -62,6 +62,10 @@ class AuthenticationForm(MisagoAuthMixin, BaseAuthenticationForm):
         widget=forms.PasswordInput,
     )
 
+    def __init__(self, *args, request=None, **kwargs):
+        self.request = request
+        super().__init__(*args, **kwargs)
+
     def clean(self):
         username = self.cleaned_data.get('username')
         password = self.cleaned_data.get('password')

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

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

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

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

+ 5 - 3
misago/users/management/commands/invalidatebans.py

@@ -1,7 +1,9 @@
 from django.core.management.base import BaseCommand
 from django.utils import timezone
 
-from misago.core import cachebuster
+from misago.cache.versions import get_cache_versions
+
+from misago.users.constants import BANS_CACHE
 from misago.users.models import Ban, BanCache
 
 
@@ -28,8 +30,8 @@ class Command(BaseCommand):
         expired_count = queryset.count()
         queryset.delete()
 
-        bans_version = cachebuster.get_version('misago_bans')
-        queryset = BanCache.objects.filter(bans_version__lt=bans_version)
+        cache_versions = get_cache_versions()
+        queryset = BanCache.objects.exclude(cache_version=cache_versions[BANS_CACHE])
 
         expired_count += queryset.count()
         queryset.delete()

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

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

+ 4 - 1
misago/users/middleware.py

@@ -20,7 +20,10 @@ class UserMiddleware(MiddlewareMixin):
         if request.user.is_anonymous:
             request.user = AnonymousUser()
         elif not request.user.is_staff:
-            if get_request_ip_ban(request) or get_user_ban(request.user):
+            if (
+                get_request_ip_ban(request) or
+                get_user_ban(request.user, request.cache_versions)
+            ):
                 logout(request)
                 request.user = AnonymousUser()
 

+ 2 - 10
misago/users/migrations/0003_bans_version_tracker.py

@@ -1,20 +1,12 @@
 from django.db import migrations
 
-from misago.core.migrationutils import cachebuster_register_cache
-from misago.users.constants import BANS_CACHEBUSTER
-
-
-def register_bans_version_tracker(apps, schema_editor):
-    cachebuster_register_cache(apps, BANS_CACHEBUSTER)
-
 
 class Migration(migrations.Migration):
+    """Migration superseded by 0016"""
 
     dependencies = [
         ('misago_users', '0002_users_settings'),
         ('misago_core', '0001_initial'),
     ]
 
-    operations = [
-        migrations.RunPython(register_bans_version_tracker),
-    ]
+    operations = []

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

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

+ 16 - 0
misago/users/migrations/0016_cache_version.py

@@ -0,0 +1,16 @@
+# Generated by Django 1.11.16 on 2018-11-25 15:31
+from django.db import migrations
+
+from misago.cache.operations import StartCacheVersioning
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('misago_users', '0015_user_agreements'),
+        ('misago_cache', '0001_initial'),
+    ]
+
+    operations = [
+        StartCacheVersioning("bans")
+    ]

+ 32 - 0
misago/users/migrations/0017_move_bans_to_cache_version.py

@@ -0,0 +1,32 @@
+# Generated by Django 1.11.16 on 2018-11-29 20:28
+from django.db import migrations, models
+
+from misago.users.constants import BANS_CACHE
+
+
+def populate_cache_version(apps, _):
+    BanCache = apps.get_model("misago_users", "BanCache")
+    BanCache.objects.all().delete()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('misago_users', '0016_cache_version'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='bancache',
+            name='bans_version',
+        ),
+        migrations.RunPython(
+            populate_cache_version,
+            migrations.RunPython.noop,
+        ),
+        migrations.AddField(
+            model_name='bancache',
+            name='cache_version',
+            field=models.CharField(max_length=8),
+        ),
+    ]

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

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

+ 8 - 9
misago/users/models/ban.py

@@ -5,8 +5,8 @@ from django.db import IntegrityError, models
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
-from misago.core import cachebuster
-from misago.users.constants import BANS_CACHEBUSTER
+from misago.cache.versions import invalidate_cache
+from misago.users.constants import BANS_CACHE
 
 
 class BansManager(models.Manager):
@@ -29,7 +29,7 @@ class BansManager(models.Manager):
         )
 
     def invalidate_cache(self):
-        cachebuster.invalidate(BANS_CACHEBUSTER)
+        invalidate_cache(BANS_CACHE)
 
     def get_ban(self, username=None, email=None, ip=None, registration_only=False):
         checks = []
@@ -131,7 +131,7 @@ class BanCache(models.Model):
         blank=True,
         on_delete=models.SET_NULL,
     )
-    bans_version = models.PositiveIntegerField(default=0)
+    cache_version = models.CharField(max_length=8)
     user_message = models.TextField(null=True, blank=True)
     staff_message = models.TextField(null=True, blank=True)
     expires_on = models.DateTimeField(null=True, blank=True)
@@ -157,9 +157,8 @@ class BanCache(models.Model):
     def is_banned(self):
         return bool(self.ban)
 
-    @property
-    def is_valid(self):
-        version_is_valid = cachebuster.is_valid(BANS_CACHEBUSTER, self.bans_version)
-        expired = self.expires_on and self.expires_on < timezone.now()
+    def is_valid(self, cache_versions):
+        is_versioned = self.cache_version == cache_versions[BANS_CACHE]
+        is_expired = self.expires_on and self.expires_on < timezone.now()
 
-        return version_is_valid and not expired
+        return is_versioned and not is_expired

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

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

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

@@ -1,7 +1,7 @@
 from django.db import models, transaction
 from django.urls import reverse
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear_acl_cache
 from misago.core.utils import slugify
 
 
@@ -39,11 +39,13 @@ class Rank(models.Model):
         if not self.pk:
             self.set_order()
         else:
-            acl_version.invalidate()
+            clear_acl_cache()
+        if not self.slug:
+            self.slug = slugify(self.name)
         return super().save(*args, **kwargs)
 
     def delete(self, *args, **kwargs):
-        acl_version.invalidate()
+        clear_acl_cache()
         return super().delete(*args, **kwargs)
 
     def get_absolute_url(self):

+ 65 - 123
misago/users/models/user.py

@@ -11,7 +11,6 @@ from django.urls import reverse
 from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 
-from misago.acl import get_user_acl
 from misago.acl.models import Role
 from misago.conf import settings
 from misago.core.pgutils import PgPartialIndex
@@ -21,102 +20,67 @@ from misago.users.audittrail import create_user_audit_trail
 from misago.users.signatures import is_user_signature_valid
 from misago.users.utils import hash_email
 
+from .online import Online
 from .rank import Rank
 
 
 class UserManager(BaseUserManager):
-    @transaction.atomic
-    def create_user(
-            self, username, email, password=None, create_audit_trail=False,
-            joined_from_ip=None, set_default_avatar=False, **extra_fields):
-        from misago.users.validators import validate_email, validate_username
-
-        email = self.normalize_email(email)
-        username = self.model.normalize_username(username)
-
+    def _create_user(self, username, email, password, **extra_fields):
+        """
+        Create and save a user with the given username, email, and password.
+        """
+        if not username:
+            raise ValueError("User must have an username.")
         if not email:
-            raise ValueError(_("User must have an email address."))
-
-        WATCH_DICT = {
-            'no': self.model.SUBSCRIBE_NONE,
-            'watch': self.model.SUBSCRIBE_NOTIFY,
-            'watch_email': self.model.SUBSCRIBE_ALL,
-        }
-
-        if not 'subscribe_to_started_threads' in extra_fields:
-            new_value = WATCH_DICT[settings.subscribe_start]
-            extra_fields['subscribe_to_started_threads'] = new_value
-
-        if not 'subscribe_to_replied_threads' in extra_fields:
-            new_value = WATCH_DICT[settings.subscribe_reply]
-            extra_fields['subscribe_to_replied_threads'] = new_value
-
-        extra_fields.update({'is_staff': False, 'is_superuser': False})
-
-        now = timezone.now()
-        user = self.model(
-            last_login=now, 
-            joined_on=now, 
-            joined_from_ip=joined_from_ip,
-            **extra_fields
-        )
+            raise ValueError("User must have an email address.")
 
+        user = self.model(**extra_fields)
         user.set_username(username)
         user.set_email(email)
         user.set_password(password)
 
-        validate_username(username)
-        validate_email(email)
-
         if not 'rank' in extra_fields:
             user.rank = Rank.objects.get_default()
 
+        now = timezone.now()
+        user.last_login = now
+        user.joined_on = now
+
         user.save(using=self._db)
+        self._assert_user_has_authenticated_role(user)
+
+        Online.objects.create(user=user, last_click=now)
 
-        if set_default_avatar:
-            avatars.set_default_avatar(
-                user, settings.default_avatar, settings.default_gravatar_fallback
-            )
-        else:
-            # just for test purposes
-            user.avatars = [{'size': 400, 'url': 'http://placekitten.com/400/400'}]
+        return user
 
+    def _assert_user_has_authenticated_role(self, user):
         authenticated_role = Role.objects.get(special_role='authenticated')
         if authenticated_role not in user.roles.all():
             user.roles.add(authenticated_role)
         user.update_acl_key()
+        user.save(update_fields=['acl_key'])
 
-        user.save(update_fields=['avatars', 'acl_key'])
-
-        if create_audit_trail:
-            create_user_audit_trail(user, user.joined_from_ip, user)
-
-        # populate online tracker with default value
-        Online.objects.create(user=user, last_click=now)
+    def create_user(self, username, email=None, password=None, **extra_fields):
+        extra_fields.setdefault('is_staff', False)
+        extra_fields.setdefault('is_superuser', False)
+        return self._create_user(username, email, password, **extra_fields)
 
-        return user
+    def create_superuser(self, username, email, password=None, **extra_fields):
+        extra_fields.setdefault('is_staff', True)
+        extra_fields.setdefault('is_superuser', True)
 
-    @transaction.atomic
-    def create_superuser(self, username, email, password, set_default_avatar=False):
-        user = self.create_user(
-            username,
-            email,
-            password=password,
-            set_default_avatar=set_default_avatar,
-        )
+        if extra_fields.get('is_staff') is not True:
+            raise ValueError('Superuser must have is_staff=True.')
+        if extra_fields.get('is_superuser') is not True:
+            raise ValueError('Superuser must have is_superuser=True.')
 
         try:
-            user.rank = Rank.objects.get(name=_("Forum team"))
-            user.update_acl_key()
+            if not extra_fields.get('rank'):
+                extra_fields["rank"] = Rank.objects.get(name=_("Forum team"))
         except Rank.DoesNotExist:
             pass
 
-        user.is_staff = True
-        user.is_superuser = True
-
-        updated_fields = ('rank', 'acl_key', 'is_staff', 'is_superuser')
-        user.save(update_fields=updated_fields, using=self._db)
-        return user
+        return self._create_user(username, email, password, **extra_fields)
 
     def get_by_username(self, username):
         return self.get(slug=slugify(username))
@@ -135,14 +99,14 @@ class User(AbstractBaseUser, PermissionsMixin):
     ACTIVATION_USER = 1
     ACTIVATION_ADMIN = 2
 
-    SUBSCRIBE_NONE = 0
-    SUBSCRIBE_NOTIFY = 1
-    SUBSCRIBE_ALL = 2
+    SUBSCRIPTION_NONE = 0
+    SUBSCRIPTION_NOTIFY = 1
+    SUBSCRIPTION_ALL = 2
 
-    SUBSCRIBE_CHOICES = [
-        (SUBSCRIBE_NONE, _("No")),
-        (SUBSCRIBE_NOTIFY, _("Notify")),
-        (SUBSCRIBE_ALL, _("Notify with e-mail")),
+    SUBSCRIPTION_CHOICES = [
+        (SUBSCRIPTION_NONE, _("No")),
+        (SUBSCRIPTION_NOTIFY, _("Notify")),
+        (SUBSCRIPTION_ALL, _("Notify with e-mail")),
     ]
 
     LIMIT_INVITES_TO_NONE = 0
@@ -252,12 +216,12 @@ class User(AbstractBaseUser, PermissionsMixin):
     sync_unread_private_threads = models.BooleanField(default=False)
 
     subscribe_to_started_threads = models.PositiveIntegerField(
-        default=SUBSCRIBE_NONE,
-        choices=SUBSCRIBE_CHOICES,
+        default=SUBSCRIPTION_NONE,
+        choices=SUBSCRIPTION_CHOICES,
     )
     subscribe_to_replied_threads = models.PositiveIntegerField(
-        default=SUBSCRIBE_NONE,
-        choices=SUBSCRIBE_CHOICES,
+        default=SUBSCRIPTION_NONE,
+        choices=SUBSCRIPTION_CHOICES,
     )
 
     threads = models.PositiveIntegerField(default=0)
@@ -329,22 +293,6 @@ class User(AbstractBaseUser, PermissionsMixin):
         anonymize_user_data.send(sender=self)
 
     @property
-    def acl_cache(self):
-        try:
-            return self._acl_cache
-        except AttributeError:
-            self._acl_cache = get_user_acl(self)
-            return self._acl_cache
-
-    @acl_cache.setter
-    def acl_cache(self, value):
-        raise TypeError("acl_cache can't be assigned")
-
-    @property
-    def acl_(self):
-        raise NotImplementedError('user.acl_ property was renamed to user.acl')
-
-    @property
     def requires_activation_by_admin(self):
         return self.requires_activation == self.ACTIVATION_ADMIN
 
@@ -401,13 +349,17 @@ class User(AbstractBaseUser, PermissionsMixin):
 
             if self.pk:
                 changed_by = changed_by or self
-                self.record_name_change(changed_by, new_username, old_username)
+                namechange = self.record_name_change(
+                    changed_by, new_username, old_username
+                )
 
                 from misago.users.signals import username_changed
                 username_changed.send(sender=self)
 
+                return namechange
+
     def record_name_change(self, changed_by, new_username, old_username):
-        self.namechanges.create(
+        return self.namechanges.create(
             new_username=new_username,
             old_username=old_username,
             changed_by=changed_by,
@@ -453,35 +405,29 @@ class User(AbstractBaseUser, PermissionsMixin):
         """sends an email to this user (for compat with Django)"""
         send_mail(subject, message, from_email, [self.email], **kwargs)
 
-    def is_following(self, user):
+    def is_following(self, user_or_id):
         try:
-            self.follows.get(pk=user.pk)
-            return True
-        except User.DoesNotExist:
-            return False
+            user_id = user_or_id.id
+        except AttributeError:
+            user_id = user_or_id
 
-    def is_blocking(self, user):
         try:
-            self.blocks.get(pk=user.pk)
+            self.follows.get(id=user_id)
             return True
         except User.DoesNotExist:
             return False
 
+    def is_blocking(self, user_or_id):
+        try:
+            user_id = user_or_id.id
+        except AttributeError:
+            user_id = user_or_id
 
-class Online(models.Model):
-    user = models.OneToOneField(
-        settings.AUTH_USER_MODEL,
-        primary_key=True,
-        related_name='online_tracker',
-        on_delete=models.CASCADE,
-    )
-    last_click = models.DateTimeField(default=timezone.now)
-
-    def save(self, *args, **kwargs):
         try:
-            super().save(*args, **kwargs)
-        except IntegrityError:
-            pass  # first come is first serve in online tracker
+            self.blocks.get(id=user_id)
+            return True
+        except User.DoesNotExist:
+            return False
 
 
 class UsernameChange(models.Model):
@@ -515,11 +461,7 @@ class AnonymousUser(DjangoAnonymousUser):
 
     @property
     def acl_cache(self):
-        try:
-            return self._acl_cache
-        except AttributeError:
-            self._acl_cache = get_user_acl(self)
-            return self._acl_cache
+        raise Exception("AnonymousUser.acl_cache has been removed")
 
     @acl_cache.setter
     def acl_cache(self, value):

+ 41 - 29
misago/users/namechanges.py

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

+ 24 - 23
misago/users/online/utils.py

@@ -9,7 +9,27 @@ from misago.users.models import BanCache, Online
 ACTIVITY_CUTOFF = timedelta(minutes=2)
 
 
-def get_user_status(viewer, user):
+
+def make_users_status_aware(request, users, fetch_state=False):
+    users_dict = {}
+    for user in users:
+        users_dict[user.pk] = user
+
+    if fetch_state:
+        # Fill ban cache on users
+        for ban_cache in BanCache.objects.filter(user__in=users_dict.keys()):
+            users_dict[ban_cache.user_id].ban_cache = ban_cache
+
+        # Fill user online trackers
+        for online_tracker in Online.objects.filter(user__in=users_dict.keys()):
+            users_dict[online_tracker.user_id].online_tracker = online_tracker
+
+    # Fill user states
+    for user in users:
+        user.status = get_user_status(request, user)
+
+
+def get_user_status(request, user):
     user_status = {
         'is_banned': False,
         'is_hidden': user.is_hiding_presence,
@@ -21,14 +41,14 @@ def get_user_status(viewer, user):
         'last_click': user.last_login or user.joined_on,
     }
 
-    user_ban = get_user_ban(user)
+    user_ban = get_user_ban(user, request.cache_versions)
     if user_ban:
         user_status['is_banned'] = True
         user_status['banned_until'] = user_ban.expires_on
 
     try:
         online_tracker = user.online_tracker
-        is_hidden = user.is_hiding_presence and not viewer.acl_cache['can_see_hidden_users']
+        is_hidden = user.is_hiding_presence and not request.user_acl['can_see_hidden_users']
 
         if online_tracker and not is_hidden:
             if online_tracker.last_click >= timezone.now() - ACTIVITY_CUTOFF:
@@ -38,7 +58,7 @@ def get_user_status(viewer, user):
         pass
 
     if user_status['is_hidden']:
-        if viewer.acl_cache['can_see_hidden_users']:
+        if request.user_acl['can_see_hidden_users']:
             user_status['is_hidden'] = False
             if user_status['is_online']:
                 user_status['is_online_hidden'] = True
@@ -55,22 +75,3 @@ def get_user_status(viewer, user):
             user_status['is_offline'] = True
 
     return user_status
-
-
-def make_users_status_aware(viewer, users, fetch_state=False):
-    users_dict = {}
-    for user in users:
-        users_dict[user.pk] = user
-
-    if fetch_state:
-        # Fill ban cache on users
-        for ban_cache in BanCache.objects.filter(user__in=users_dict.keys()):
-            users_dict[ban_cache.user_id].ban_cache = ban_cache
-
-        # Fill user online trackers
-        for online_tracker in Online.objects.filter(user__in=users_dict.keys()):
-            users_dict[online_tracker.user_id].online_tracker = online_tracker
-
-    # Fill user states
-    for user in users:
-        user.status = get_user_status(viewer, user)

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

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

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

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

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

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

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

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

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

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

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

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

+ 9 - 2
misago/users/registration.py

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

+ 1 - 1
misago/users/search.py

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

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

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

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

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

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

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

+ 29 - 0
misago/users/setupnewuser.py

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

+ 2 - 2
misago/users/signatures.py

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

+ 15 - 11
misago/users/social/pipeline.py

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

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

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

+ 0 - 11
misago/users/tests/test_activepostersranking.py

@@ -4,8 +4,6 @@ from django.contrib.auth import get_user_model
 from django.utils import timezone
 
 from misago.categories.models import Category
-from misago.core import threadstore
-from misago.core.cache import cache
 from misago.threads.testutils import post_thread
 from misago.users.activepostersranking import (
     build_active_posters_ranking, get_active_posters_ranking)
@@ -19,17 +17,8 @@ class TestActivePostersRanking(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        cache.clear()
-        threadstore.clear()
-
         self.category = Category.objects.get(slug='first-category')
 
-    def tearDown(self):
-        super().tearDown()
-
-        cache.clear()
-        threadstore.clear()
-
     def test_get_active_posters_ranking(self):
         """get_active_posters_ranking returns list of active posters"""
         # no posts, empty tanking

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

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

+ 18 - 14
misago/users/tests/test_bans.py

@@ -4,13 +4,16 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.utils import timezone
 
+from misago.conftest import get_cache_versions
 from misago.users.bans import (
     ban_ip, ban_user, get_email_ban, get_ip_ban, get_request_ip_ban, get_user_ban, get_username_ban)
+from misago.users.constants import BANS_CACHE
 from misago.users.models import Ban
 
-
 UserModel = get_user_model()
 
+cache_versions = get_cache_versions()
+
 
 class GetBanTests(TestCase):
     def test_get_username_ban(self):
@@ -40,13 +43,13 @@ class GetBanTests(TestCase):
         )
         self.assertEqual(get_username_ban('admiral').pk, valid_ban.pk)
 
-        regitration_ban = Ban.objects.create(
+        registration_ban = Ban.objects.create(
             banned_value='bob*',
             expires_on=timezone.now() + timedelta(days=7),
             registration_only=True,
         )
         self.assertIsNone(get_username_ban('boberson'))
-        self.assertEqual(get_username_ban('boberson', True).pk, regitration_ban.pk)
+        self.assertEqual(get_username_ban('boberson', True).pk, registration_ban.pk)
 
     def test_get_email_ban(self):
         """get_email_ban returns valid ban"""
@@ -77,14 +80,14 @@ class GetBanTests(TestCase):
         )
         self.assertEqual(get_email_ban('banned@mail.ru').pk, valid_ban.pk)
 
-        regitration_ban = Ban.objects.create(
+        registration_ban = Ban.objects.create(
             banned_value='*.ua',
             check_type=Ban.EMAIL,
             expires_on=timezone.now() + timedelta(days=7),
             registration_only=True,
         )
         self.assertIsNone(get_email_ban('banned@mail.ua'))
-        self.assertEqual(get_email_ban('banned@mail.ua', True).pk, regitration_ban.pk)
+        self.assertEqual(get_email_ban('banned@mail.ua', True).pk, registration_ban.pk)
 
     def test_get_ip_ban(self):
         """get_ip_ban returns valid ban"""
@@ -115,14 +118,14 @@ class GetBanTests(TestCase):
         )
         self.assertEqual(get_ip_ban('125.0.0.1').pk, valid_ban.pk)
 
-        regitration_ban = Ban.objects.create(
+        registration_ban = Ban.objects.create(
             banned_value='188.*',
             check_type=Ban.IP,
             expires_on=timezone.now() + timedelta(days=7),
             registration_only=True,
         )
         self.assertIsNone(get_ip_ban('188.12.12.41'))
-        self.assertEqual(get_ip_ban('188.12.12.41', True).pk, regitration_ban.pk)
+        self.assertEqual(get_ip_ban('188.12.12.41', True).pk, registration_ban.pk)
 
 
 class UserBansTests(TestCase):
@@ -131,7 +134,7 @@ class UserBansTests(TestCase):
 
     def test_no_ban(self):
         """user is not caught by ban"""
-        self.assertIsNone(get_user_ban(self.user))
+        self.assertIsNone(get_user_ban(self.user, cache_versions))
         self.assertFalse(self.user.ban_cache.is_banned)
 
     def test_permanent_ban(self):
@@ -142,7 +145,7 @@ class UserBansTests(TestCase):
             staff_message='Staff reason',
         )
 
-        user_ban = get_user_ban(self.user)
+        user_ban = get_user_ban(self.user, cache_versions)
         self.assertIsNotNone(user_ban)
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
@@ -157,7 +160,7 @@ class UserBansTests(TestCase):
             expires_on=timezone.now() + timedelta(days=7),
         )
 
-        user_ban = get_user_ban(self.user)
+        user_ban = get_user_ban(self.user, cache_versions)
         self.assertIsNotNone(user_ban)
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
@@ -170,7 +173,7 @@ class UserBansTests(TestCase):
             expires_on=timezone.now() - timedelta(days=7),
         )
 
-        self.assertIsNone(get_user_ban(self.user))
+        self.assertIsNone(get_user_ban(self.user, cache_versions))
         self.assertFalse(self.user.ban_cache.is_banned)
 
     def test_expired_non_flagged_ban(self):
@@ -181,7 +184,7 @@ class UserBansTests(TestCase):
         )
         Ban.objects.update(is_checked=True)
 
-        self.assertIsNone(get_user_ban(self.user))
+        self.assertIsNone(get_user_ban(self.user, cache_versions))
         self.assertFalse(self.user.ban_cache.is_banned)
 
 
@@ -189,6 +192,7 @@ class MockRequest(object):
     def __init__(self):
         self.user_ip = '127.0.0.1'
         self.session = {}
+        self.cache_versions = cache_versions
 
 
 class RequestIPBansTests(TestCase):
@@ -255,7 +259,7 @@ class BanUserTests(TestCase):
         self.assertEqual(ban.user_message, 'User reason')
         self.assertEqual(ban.staff_message, 'Staff reason')
 
-        db_ban = get_user_ban(user)
+        db_ban = get_user_ban(user, cache_versions)
         self.assertEqual(ban.pk, db_ban.ban_id)
 
 
@@ -267,4 +271,4 @@ class BanIpTests(TestCase):
         self.assertEqual(ban.staff_message, 'Staff reason')
 
         db_ban = get_ip_ban('127.0.0.1')
-        self.assertEqual(ban.pk, db_ban.pk)
+        self.assertEqual(ban.pk, db_ban.pk)

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

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

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

+ 4 - 2
misago/users/tests/test_invalidatebans.py

@@ -6,6 +6,8 @@ from django.core.management import call_command
 from django.test import TestCase
 from django.utils import timezone
 
+from misago.cache.versions import get_cache_versions
+
 from misago.users import bans
 from misago.users.management.commands import invalidatebans
 from misago.users.models import Ban, BanCache
@@ -41,7 +43,7 @@ class InvalidateBansTests(TestCase):
 
         # ban user
         Ban.objects.create(banned_value="bob")
-        user_ban = bans.get_user_ban(user)
+        user_ban = bans.get_user_ban(user, get_cache_versions())
 
         self.assertIsNotNone(user_ban)
         self.assertEqual(Ban.objects.filter(is_checked=True).count(), 1)
@@ -74,4 +76,4 @@ class InvalidateBansTests(TestCase):
 
         # see if user is banned anymore
         user = UserModel.objects.get(id=user.id)
-        self.assertIsNone(bans.get_user_ban(user))
+        self.assertIsNone(bans.get_user_ban(user, get_cache_versions()))

+ 4 - 10
misago/users/tests/test_joinip_profilefield.py

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

+ 8 - 20
misago/users/tests/test_lists_views.py

@@ -1,32 +1,21 @@
-from django.contrib.auth import get_user_model
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.threads.testutils import post_thread
 from misago.users.activepostersranking import build_active_posters_ranking
 from misago.users.models import Rank
-from misago.users.testutils import AuthenticatedUserTestCase
-
-
-UserModel = get_user_model()
+from misago.users.testutils import AuthenticatedUserTestCase, create_test_user
 
 
 class UsersListTestCase(AuthenticatedUserTestCase):
-    def setUp(self):
-        super().setUp()
-        override_acl(self.user, {
-            'can_browse_users_list': 1,
-        })
+    pass
 
 
 class UsersListLanderTests(UsersListTestCase):
+    @patch_user_acl({'can_browse_users_list': 0})
     def test_lander_no_permission(self):
         """lander returns 403 if user has no permission"""
-        override_acl(self.user, {
-            'can_browse_users_list': 0,
-        })
-
         response = self.client.get(reverse('misago:users'))
         self.assertEqual(response.status_code, 403)
 
@@ -55,10 +44,9 @@ class ActivePostersTests(UsersListTestCase):
 
         # Create 50 test users and see if errors appeared
         for i in range(50):
-            user = UserModel.objects.create_user(
+            user = create_test_user(
                 'Bob%s' % i,
                 'm%s@te.com' % i,
-                'Pass.123',
                 posts=12345,
             )
             post_thread(category, poster=user)
@@ -72,7 +60,7 @@ class ActivePostersTests(UsersListTestCase):
 class UsersRankTests(UsersListTestCase):
     def test_ranks(self):
         """ranks lists are handled correctly"""
-        rank_user = UserModel.objects.create_user('Visible', 'visible@te.com', 'Pass.123')
+        rank_user = create_test_user('Visible', 'visible@te.com')
 
         for rank in Rank.objects.iterator():
             rank_user.rank = rank
@@ -94,7 +82,7 @@ class UsersRankTests(UsersListTestCase):
 
     def test_disabled_users(self):
         """ranks lists excludes disabled accounts"""
-        rank_user = UserModel.objects.create_user(
+        rank_user = create_test_user(
             'Visible',
             'visible@te.com',
             'Pass.123',
@@ -124,7 +112,7 @@ class UsersRankTests(UsersListTestCase):
         self.user.is_staff = True
         self.user.save()
 
-        rank_user = UserModel.objects.create_user(
+        rank_user = create_test_user(
             'Visible',
             'visible@te.com',
             'Pass.123',

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

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

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

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

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

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

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

@@ -1,36 +0,0 @@
-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()
-
-        get_user_status(self.user, 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,
-        })
-
-        get_user_status(self.user, self.other_user)
-
-    def test_user_not_hiding_presence(self):
-        """get_user_status has no showstoppers for non-hidden user"""
-        get_user_status(self.user, self.other_user)

+ 29 - 36
misago/users/tests/test_profile_views.py

@@ -1,11 +1,13 @@
 from django.contrib.auth import get_user_model
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.users.models import Ban
-from misago.users.testutils import AuthenticatedUserTestCase
+from misago.users.testutils import (
+    AuthenticatedUserTestCase, create_test_user
+)
 
 
 UserModel = get_user_model()
@@ -34,7 +36,7 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
         self.user.is_staff = False
         self.user.save()
 
-        test_user = UserModel.objects.create_user('Tyrael', 't123@test.com', 'pass123')
+        test_user = create_test_user('Tyrael', 't123@test.com')
 
         test_user.is_active = False
         test_user.save()
@@ -50,7 +52,7 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
 
         # profile page displays notice about user being disabled
         response = self.client.get(response['location'])
-        self.assertContains(response, "account has been disabled", status_code=200)
+        self.assertContains(response, "account has been disabled")
 
     def test_user_posts_list(self):
         """user profile posts list has no showstoppers"""
@@ -184,46 +186,37 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
 
     def test_user_ban_details(self):
         """user ban details page has no showstoppers"""
-        override_acl(self.user, {
-            'can_see_ban_details': 0,
-        })
-
-        test_user = UserModel.objects.create_user("Bob", "bob@bob.com", 'pass.123')
+        test_user = create_test_user("Bob", "bob@bob.com", 'pass.123')
         link_kwargs = {'slug': test_user.slug, 'pk': test_user.pk}
 
-        response = self.client.get(reverse(
-            'misago:user-ban',
-            kwargs=link_kwargs,
-        ))
-        self.assertEqual(response.status_code, 404)
-
-        override_acl(self.user, {
-            'can_see_ban_details': 1,
-        })
+        with patch_user_acl({'can_see_ban_details': 0}):
+            response = self.client.get(reverse(
+                'misago:user-ban',
+                kwargs=link_kwargs,
+            ))
+            self.assertEqual(response.status_code, 404)
 
-        response = self.client.get(reverse(
-            'misago:user-ban',
-            kwargs=link_kwargs,
-        ))
-        self.assertEqual(response.status_code, 404)
-
-        override_acl(self.user, {
-            'can_see_ban_details': 1,
-        })
-        test_user.ban_cache.delete()
+        with patch_user_acl({'can_see_ban_details': 1}):
+            response = self.client.get(reverse(
+                'misago:user-ban',
+                kwargs=link_kwargs,
+            ))
+            self.assertEqual(response.status_code, 404)
 
         Ban.objects.create(
             banned_value=test_user.username,
             user_message="User m3ss4ge.",
             staff_message="Staff m3ss4ge.",
             is_checked=True,
-        )
+        )      
+        test_user.ban_cache.delete()
 
-        response = self.client.get(reverse(
-            'misago:user-ban',
-            kwargs=link_kwargs,
-        ))
+        with patch_user_acl({'can_see_ban_details': 1}):
+            response = self.client.get(reverse(
+                'misago:user-ban',
+                kwargs=link_kwargs,
+            ))
 
-        self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'User m3ss4ge')
-        self.assertContains(response, 'Staff m3ss4ge')
+            self.assertEqual(response.status_code, 200)
+            self.assertContains(response, 'User m3ss4ge')
+            self.assertContains(response, 'Staff m3ss4ge')

+ 55 - 0
misago/users/tests/test_rankadmin_views.py

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

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

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

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

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

+ 45 - 21
misago/users/tests/test_social_pipeline.py

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

+ 35 - 60
misago/users/tests/test_user_avatar_api.py

@@ -4,17 +4,17 @@ from pathlib import Path
 
 from django.contrib.auth import get_user_model
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.conf import settings
+from misago.conf.test import override_dynamic_settings
 from misago.users.avatars import gallery, store
 from misago.users.models import AvatarGallery
 from misago.users.testutils import AuthenticatedUserTestCase
 
-
 TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
 TEST_AVATAR_PATH = os.path.join(TESTFILES_DIR, 'avatar.png')
 
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class UserAvatarTests(AuthenticatedUserTestCase):
@@ -26,40 +26,40 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.client.post(self.link, data={'avatar': 'generated'})
 
     def get_current_user(self):
-        return UserModel.objects.get(pk=self.user.pk)
+        return User.objects.get(pk=self.user.pk)
 
     def assertOldAvatarsAreDeleted(self, user):
         self.assertEqual(
             user.avatar_set.count(), len(settings.MISAGO_AVATARS_SIZES)
         )
 
+    @override_dynamic_settings(allow_custom_avatars=False)
     def test_avatars_off(self):
         """custom avatars are not allowed"""
-        with self.settings(allow_custom_avatars=False):
-            response = self.client.get(self.link)
-            self.assertEqual(response.status_code, 200)
-
-            options = response.json()
-            self.assertTrue(options['generated'])
-            self.assertFalse(options['gravatar'])
-            self.assertFalse(options['crop_src'])
-            self.assertFalse(options['crop_tmp'])
-            self.assertFalse(options['upload'])
-            self.assertFalse(options['galleries'])
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
 
+        options = response.json()
+        self.assertTrue(options['generated'])
+        self.assertFalse(options['gravatar'])
+        self.assertFalse(options['crop_src'])
+        self.assertFalse(options['crop_tmp'])
+        self.assertFalse(options['upload'])
+        self.assertFalse(options['galleries'])
+
+    @override_dynamic_settings(allow_custom_avatars=True)
     def test_avatars_on(self):
-        """custom avatars are not allowed"""
-        with self.settings(allow_custom_avatars=True):
-            response = self.client.get(self.link)
-            self.assertEqual(response.status_code, 200)
+        """custom avatars are allowed"""
+        response = self.client.get(self.link)
+        self.assertEqual(response.status_code, 200)
 
-            options = response.json()
-            self.assertTrue(options['generated'])
-            self.assertTrue(options['gravatar'])
-            self.assertFalse(options['crop_src'])
-            self.assertFalse(options['crop_tmp'])
-            self.assertTrue(options['upload'])
-            self.assertFalse(options['galleries'])
+        options = response.json()
+        self.assertTrue(options['generated'])
+        self.assertTrue(options['gravatar'])
+        self.assertFalse(options['crop_src'])
+        self.assertFalse(options['crop_tmp'])
+        self.assertTrue(options['upload'])
+        self.assertFalse(options['galleries'])
 
     def test_gallery_exists(self):
         """api returns gallery"""
@@ -95,7 +95,7 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         })
 
         self.login_user(
-            UserModel.objects.create_user("BobUser", "bob@bob.com", self.USER_PASSWORD)
+            User.objects.create_user("BobUser", "bob@bob.com", self.USER_PASSWORD)
         )
 
         response = self.client.get(self.link)
@@ -347,28 +347,22 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.other_user = UserModel.objects.create_user("OtherUser", "other@user.com", "pass123")
+        self.other_user = User.objects.create_user("OtherUser", "other@user.com", "pass123")
 
         self.link = '/api/users/%s/moderate-avatar/' % self.other_user.pk
 
+    @patch_user_acl({'can_moderate_avatars': 0})
     def test_no_permission(self):
         """no permission to moderate avatar"""
-        override_acl(self.user, {
-            'can_moderate_avatars': 0,
-        })
-
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't moderate avatars.",
         })
 
+    @patch_user_acl({'can_moderate_avatars': 1})
     def test_moderate_avatar(self):
         """moderate avatar"""
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
@@ -381,10 +375,6 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], self.other_user.avatar_lock_staff_message
         )
 
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -396,7 +386,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        other_user = UserModel.objects.get(pk=self.other_user.pk)
+        other_user = User.objects.get(pk=self.other_user.pk)
 
         options = response.json()
         self.assertEqual(other_user.is_avatar_locked, True)
@@ -410,10 +400,6 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
         )
 
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -425,7 +411,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        other_user = UserModel.objects.get(pk=self.other_user.pk)
+        other_user = User.objects.get(pk=self.other_user.pk)
         self.assertFalse(other_user.is_avatar_locked)
         self.assertIsNone(other_user.avatar_lock_user_message)
         self.assertIsNone(other_user.avatar_lock_staff_message)
@@ -438,10 +424,6 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
         )
 
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -453,7 +435,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        other_user = UserModel.objects.get(pk=self.other_user.pk)
+        other_user = User.objects.get(pk=self.other_user.pk)
         self.assertTrue(other_user.is_avatar_locked)
         self.assertEqual(other_user.avatar_lock_user_message, '')
         self.assertEqual(other_user.avatar_lock_staff_message, '')
@@ -466,10 +448,6 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
         )
 
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -479,7 +457,7 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        other_user = UserModel.objects.get(pk=self.other_user.pk)
+        other_user = User.objects.get(pk=self.other_user.pk)
         self.assertFalse(other_user.is_avatar_locked)
         self.assertEqual(other_user.avatar_lock_user_message, '')
         self.assertEqual(other_user.avatar_lock_staff_message, '')
@@ -492,11 +470,8 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
         )
 
+    @patch_user_acl({'can_moderate_avatars': 1})
     def test_moderate_own_avatar(self):
         """moderate own avatar"""
-        override_acl(self.user, {
-            'can_moderate_avatars': 1,
-        })
-
         response = self.client.get('/api/users/%s/moderate-avatar/' % self.user.pk)
         self.assertEqual(response.status_code, 200)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 12 - 39
misago/users/tests/test_user_username_api.py

@@ -2,8 +2,8 @@ import json
 
 from django.contrib.auth import get_user_model
 
-from misago.acl.testutils import override_acl
-from misago.conf import settings
+from misago.acl.test import patch_user_acl
+from misago.conf.test import override_dynamic_settings
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -17,6 +17,7 @@ class UserUsernameTests(AuthenticatedUserTestCase):
         super().setUp()
         self.link = '/api/users/%s/username/' % self.user.pk
 
+    @override_dynamic_settings(username_length_min=2, username_length_max=4)
     def test_get_change_username_options(self):
         """get to API returns options"""
         response = self.client.get(self.link)
@@ -25,8 +26,8 @@ class UserUsernameTests(AuthenticatedUserTestCase):
         response_json = response.json()
 
         self.assertIsNotNone(response_json['changes_left'])
-        self.assertEqual(response_json['length_min'], settings.username_length_min)
-        self.assertEqual(response_json['length_max'], settings.username_length_max)
+        self.assertEqual(response_json['length_min'], 2)
+        self.assertEqual(response_json['length_max'], 4)
         self.assertIsNone(response_json['next_on'])
 
         for i in range(response_json['changes_left']):
@@ -117,44 +118,31 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
 
         self.link = '/api/users/%s/moderate-username/' % self.other_user.pk
 
+    @patch_user_acl({'can_rename_users': 0})
     def test_no_permission(self):
-        """no permission to moderate avatar"""
-        override_acl(self.user, {
-            'can_rename_users': 0,
-        })
-
+        """no permission to moderate username"""
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't rename users.",
         })
 
-        override_acl(self.user, {
-            'can_rename_users': 0,
-        })
-
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't rename users.",
         })
 
+    @patch_user_acl({'can_rename_users': 1})
+    @override_dynamic_settings(username_length_min=3, username_length_max=12)
     def test_moderate_username(self):
         """moderate username"""
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
         options = response.json()
-        self.assertEqual(options['length_min'], settings.username_length_min)
-        self.assertEqual(options['length_max'], settings.username_length_max)
-
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
+        self.assertEqual(options['length_min'], 3)
+        self.assertEqual(options['length_max'], 12)
 
         response = self.client.post(
             self.link,
@@ -168,10 +156,6 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             "detail": "Enter new username.",
         })
 
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -184,10 +168,6 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             "detail": "Username can only contain latin alphabet letters and digits.",
         })
 
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -200,10 +180,6 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             "detail": "Username must be at least 3 characters long.",
         })
 
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -223,11 +199,8 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
         self.assertEqual(options['username'], other_user.username)
         self.assertEqual(options['slug'], other_user.slug)
 
+    @patch_user_acl({'can_rename_users': 1})
     def test_moderate_own_username(self):
         """moderate own username"""
-        override_acl(self.user, {
-            'can_rename_users': 1,
-        })
-
         response = self.client.get('/api/users/%s/moderate-username/' % self.user.pk)
         self.assertEqual(response.status_code, 200)

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

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

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

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

+ 56 - 91
misago/users/tests/test_users_api.py

@@ -6,18 +6,15 @@ from django.test import override_settings
 from django.urls import reverse
 from django.utils.encoding import smart_str
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
-from misago.core import threadstore
-from misago.core.cache import cache
 from misago.threads.models import Post, Thread
 from misago.threads.testutils import post_thread
 from misago.users.activepostersranking import build_active_posters_ranking
 from misago.users.models import Ban, Rank
 from misago.users.testutils import AuthenticatedUserTestCase
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class ActivePostersListTests(AuthenticatedUserTestCase):
@@ -25,10 +22,8 @@ class ActivePostersListTests(AuthenticatedUserTestCase):
 
     def setUp(self):
         super().setUp()
-        self.link = '/api/users/?list=active'
 
-        cache.clear()
-        threadstore.clear()
+        self.link = '/api/users/?list=active'
 
         self.category = Category.objects.all_categories()[:1][0]
         self.category.labels = []
@@ -86,7 +81,7 @@ class FollowersListTests(AuthenticatedUserTestCase):
 
     def test_filled_list(self):
         """user with followers returns 200"""
-        test_follower = UserModel.objects.create_user(
+        test_follower = User.objects.create_user(
             "TestFollower",
             "test@follower.com",
             self.USER_PASSWORD,
@@ -99,7 +94,7 @@ class FollowersListTests(AuthenticatedUserTestCase):
 
     def test_filled_list_search(self):
         """followers list is searchable"""
-        test_follower = UserModel.objects.create_user(
+        test_follower = User.objects.create_user(
             "TestFollower",
             "test@follower.com",
             self.USER_PASSWORD,
@@ -132,7 +127,7 @@ class FollowsListTests(AuthenticatedUserTestCase):
 
     def test_filled_list(self):
         """user with follows returns 200"""
-        test_follower = UserModel.objects.create_user(
+        test_follower = User.objects.create_user(
             "TestFollower",
             "test@follower.com",
             self.USER_PASSWORD,
@@ -145,7 +140,7 @@ class FollowsListTests(AuthenticatedUserTestCase):
 
     def test_filled_list_search(self):
         """follows list is searchable"""
-        test_follower = UserModel.objects.create_user(
+        test_follower = User.objects.create_user(
             "TestFollower",
             "test@follower.com",
             self.USER_PASSWORD,
@@ -214,7 +209,7 @@ class RankListTests(AuthenticatedUserTestCase):
             is_tab=True,
         )
 
-        test_user = UserModel.objects.create_user(
+        test_user = User.objects.create_user(
             'Visible',
             'visible@te.com',
             'Pass.123',
@@ -255,7 +250,7 @@ class UserRetrieveTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.test_user = UserModel.objects.create_user('Tyrael', 't123@test.com', 'pass123')
+        self.test_user = User.objects.create_user('Tyrael', 't123@test.com', 'pass123')
         self.link = reverse(
             'misago:api:user-detail', kwargs={
                 'pk': self.test_user.pk,
@@ -399,7 +394,7 @@ class UserFollowTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.other_user = UserModel.objects.create_user("OtherUser", "other@user.com", "pass123")
+        self.other_user = User.objects.create_user("OtherUser", "other@user.com", "pass123")
 
         self.link = '/api/users/%s/follow/' % self.other_user.pk
 
@@ -421,12 +416,9 @@ class UserFollowTests(AuthenticatedUserTestCase):
             "detail": "You can't add yourself to followed.",
         })
 
+    @patch_user_acl({'can_follow_users': 0})
     def test_cant_follow(self):
         """no permission to follow users"""
-        override_acl(self.user, {
-            'can_follow_users': 0,
-        })
-
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
@@ -438,13 +430,13 @@ class UserFollowTests(AuthenticatedUserTestCase):
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 200)
 
-        user = UserModel.objects.get(pk=self.user.pk)
+        user = User.objects.get(pk=self.user.pk)
         self.assertEqual(user.followers, 0)
         self.assertEqual(user.following, 1)
         self.assertEqual(user.follows.count(), 1)
         self.assertEqual(user.followed_by.count(), 0)
 
-        followed = UserModel.objects.get(pk=self.other_user.pk)
+        followed = User.objects.get(pk=self.other_user.pk)
         self.assertEqual(followed.followers, 1)
         self.assertEqual(followed.following, 0)
         self.assertEqual(followed.follows.count(), 0)
@@ -453,13 +445,13 @@ class UserFollowTests(AuthenticatedUserTestCase):
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 200)
 
-        user = UserModel.objects.get(pk=self.user.pk)
+        user = User.objects.get(pk=self.user.pk)
         self.assertEqual(user.followers, 0)
         self.assertEqual(user.following, 0)
         self.assertEqual(user.follows.count(), 0)
         self.assertEqual(user.followed_by.count(), 0)
 
-        followed = UserModel.objects.get(pk=self.other_user.pk)
+        followed = User.objects.get(pk=self.other_user.pk)
         self.assertEqual(followed.followers, 0)
         self.assertEqual(followed.following, 0)
         self.assertEqual(followed.follows.count(), 0)
@@ -472,32 +464,29 @@ class UserBanTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.other_user = UserModel.objects.create_user("OtherUser", "other@user.com", "pass123")
+        self.other_user = User.objects.create_user("OtherUser", "other@user.com", "pass123")
 
         self.link = '/api/users/%s/ban/' % self.other_user.pk
 
+    @patch_user_acl({'can_see_ban_details': 0})
     def test_no_permission(self):
         """user has no permission to access ban"""
-        override_acl(self.user, {'can_see_ban_details': 0})
-
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             "detail": "You can't see users bans details.",
         })
 
+    @patch_user_acl({'can_see_ban_details': 1})
     def test_no_ban(self):
         """api returns empty json"""
-        override_acl(self.user, {'can_see_ban_details': 1})
-
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), {})
 
+    @patch_user_acl({'can_see_ban_details': 1})
     def test_ban_details(self):
         """api returns ban json"""
-        override_acl(self.user, {'can_see_ban_details': 1})
-
         Ban.objects.create(
             check_type=Ban.USERNAME,
             banned_value=self.other_user.username,
@@ -590,7 +579,7 @@ class UserDeleteTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.other_user = UserModel.objects.create_user("OtherUser", "other@user.com", "pass123")
+        self.other_user = User.objects.create_user("OtherUser", "other@user.com", "pass123")
 
         self.link = '/api/users/%s/delete/' % self.other_user.pk
 
@@ -604,30 +593,24 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         self.other_user.threads = 1
         self.other_user.save()
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 0,
+        'can_delete_users_with_less_posts_than': 0,
+    })
     def test_delete_no_permission(self):
         """raises 403 error when no permission to delete"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 0,
-                'can_delete_users_with_less_posts_than': 0,
-            }
-        )
-
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             'detail': "You can't delete users.",
         })
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 0,
+        'can_delete_users_with_less_posts_than': 5,
+    })
     def test_delete_too_many_posts(self):
         """raises 403 error when user has too many posts"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 0,
-                'can_delete_users_with_less_posts_than': 5,
-            }
-        )
-
         self.other_user.posts = 6
         self.other_user.save()
 
@@ -637,15 +620,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete users that made more than 5 posts.",
         })
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 5,
+        'can_delete_users_with_less_posts_than': 0,
+    })
     def test_delete_too_old_member(self):
         """raises 403 error when user is too old"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 5,
-                'can_delete_users_with_less_posts_than': 0,
-            }
-        )
-
         self.other_user.joined_on -= timedelta(days=6)
         self.other_user.save()
 
@@ -656,30 +636,24 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete users that are members for more than 5 days.",
         })
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_self(self):
         """raises 403 error when attempting to delete oneself"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         response = self.client.post('/api/users/%s/delete/' % self.user.pk)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
             'detail': "You can't delete your account.",
         })
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_admin(self):
         """raises 403 error when attempting to delete admin"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         self.other_user.is_staff = True
         self.other_user.save()
 
@@ -689,15 +663,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete administrators.",
         })
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_superadmin(self):
         """raises 403 error when attempting to delete superadmin"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         self.other_user.is_superuser = True
         self.other_user.save()
 
@@ -707,15 +678,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete administrators.",
         })
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_with_content(self):
         """returns 200 and deletes user with content"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -725,21 +693,18 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        with self.assertRaises(UserModel.DoesNotExist):
-            UserModel.objects.get(pk=self.other_user.pk)
+        with self.assertRaises(User.DoesNotExist):
+            User.objects.get(pk=self.other_user.pk)
 
         self.assertEqual(Thread.objects.count(), self.threads)
         self.assertEqual(Post.objects.count(), self.posts)
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_without_content(self):
         """returns 200 and deletes user without content"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         response = self.client.post(
             self.link,
             json.dumps({
@@ -749,8 +714,8 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         )
         self.assertEqual(response.status_code, 200)
 
-        with self.assertRaises(UserModel.DoesNotExist):
-            UserModel.objects.get(pk=self.other_user.pk)
+        with self.assertRaises(User.DoesNotExist):
+            User.objects.get(pk=self.other_user.pk)
 
         self.assertEqual(Thread.objects.count(), self.threads + 1)
         self.assertEqual(Post.objects.count(), self.posts + 2)

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

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

+ 34 - 12
misago/users/testutils.py

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

+ 18 - 16
misago/users/validators.py

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

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

@@ -7,7 +7,7 @@ from misago.users.serializers import UserCardSerializer
 class ActivePosters(object):
     def __init__(self, request):
         ranking = get_active_posters_ranking()
-        make_users_status_aware(request.user, ranking['users'], fetch_state=True)
+        make_users_status_aware(request, ranking['users'], fetch_state=True)
 
         self.count = ranking['users_count']
         self.tracked_period = settings.MISAGO_RANKING_LENGTH

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

@@ -21,7 +21,7 @@ class Followers(object):
                 raise Http404()
 
         list_page = paginate(queryset, page, settings.MISAGO_USERS_PER_PAGE, 4)
-        make_users_status_aware(request.user, list_page.object_list)
+        make_users_status_aware(request, list_page.object_list)
 
         self.users = list_page.object_list
         self.paginator = pagination_dict(list_page)

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

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

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

@@ -16,7 +16,7 @@ class RankUsers(object):
             queryset = queryset.filter(is_active=True)
 
         list_page = paginate(queryset, page, settings.MISAGO_USERS_PER_PAGE, 4)
-        make_users_status_aware(request.user, list_page.object_list)
+        make_users_status_aware(request, list_page.object_list)
 
         self.users = list_page.object_list
         self.paginator = pagination_dict(list_page)

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

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

+ 1 - 1
misago/users/views/activation.py

@@ -53,7 +53,7 @@ def activate_by_token(request, pk, token):
             )
             raise ActivationError(message % {'user': inactive_user.username})
 
-        ban = get_user_ban(inactive_user)
+        ban = get_user_ban(inactive_user, request.cache_versions)
         if ban:
             raise Banned(ban)
     except ActivationStopped as e:

+ 37 - 15
misago/users/views/admin/users.py

@@ -5,29 +5,33 @@ from django.http import JsonResponse
 from django.shortcuts import redirect
 from django.utils.translation import gettext_lazy as _
 
+from misago.acl.useracl import get_user_acl
 from misago.admin.auth import start_admin_session
 from misago.admin.views import generic
 from misago.categories.models import Category
-from misago.conf import settings
 from misago.core.mail import mail_users
 from misago.core.pgutils import chunk_queryset
 from misago.threads.models import Thread
 from misago.users.avatars.dynamic import set_avatar as set_dynamic_avatar
-from misago.users.datadownloads import request_user_data_download, user_has_data_download_request
+from misago.users.datadownloads import (
+    request_user_data_download, user_has_data_download_request
+)
 from misago.users.forms.admin import (
-    BanUsersForm, EditUserForm, EditUserFormFactory, NewUserForm, SearchUsersForm)
+    BanUsersForm, EditUserForm, EditUserFormFactory, NewUserForm,
+    create_search_users_form
+)
 from misago.users.models import Ban
 from misago.users.profilefields import profilefields
+from misago.users.setupnewuser import setup_new_user
 from misago.users.signatures import set_user_signature
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 class UserAdmin(generic.AdminBaseMixin):
     root_link = 'misago:admin:users:accounts:index'
     templates_dir = 'misago/admin/users'
-    model = UserModel
+    model = User
 
     def create_form_type(self, request, target):
         add_is_active_fields = False
@@ -101,7 +105,7 @@ class UsersList(UserAdmin, generic.ListView):
         return qs.select_related('rank')
 
     def get_search_form(self, request):
-        return SearchUsersForm
+        return create_search_users_form()
 
     def action_activate(self, request, users):
         inactive_users = []
@@ -114,13 +118,18 @@ class UsersList(UserAdmin, generic.ListView):
             raise generic.MassActionError(message)
         else:
             activated_users_pks = [u.pk for u in inactive_users]
-            queryset = UserModel.objects.filter(pk__in=activated_users_pks)
-            queryset.update(requires_activation=UserModel.ACTIVATION_NONE)
+            queryset = User.objects.filter(pk__in=activated_users_pks)
+            queryset.update(requires_activation=User.ACTIVATION_NONE)
 
             subject = _("Your account on %(forum_name)s forums has been activated")
-            mail_subject = subject % {'forum_name': settings.forum_name}
+            mail_subject = subject % {'forum_name': request.settings.forum_name}
 
-            mail_users(inactive_users, mail_subject, 'misago/emails/activation/by_admin')
+            mail_users(
+                inactive_users,
+                mail_subject,
+                'misago/emails/activation/by_admin',
+                context={"settings": request.settings},
+            )
 
             messages.success(request, _("Selected users accounts have been activated."))
 
@@ -246,15 +255,25 @@ class NewUser(UserAdmin, generic.ModelFormView):
     template = 'new.html'
     message_submit = _('New user "%(user)s" has been registered.')
 
+    def initialize_form(self, form, request, target):
+        if request.method == 'POST':
+            return form(
+                request.POST,
+                request.FILES,
+                instance=target,
+                request=request,
+            )
+        else:
+            return form(instance=target, request=request)
+            
     def handle_form(self, form, request, target):
-        new_user = UserModel.objects.create_user(
+        new_user = User.objects.create_user(
             form.cleaned_data['username'],
             form.cleaned_data['email'],
             form.cleaned_data['new_password'],
             title=form.cleaned_data['title'],
             rank=form.cleaned_data.get('rank'),
             joined_from_ip=request.user_ip,
-            set_default_avatar=True
         )
 
         if form.cleaned_data.get('staff_level'):
@@ -264,7 +283,7 @@ class NewUser(UserAdmin, generic.ModelFormView):
             new_user.roles.add(*form.cleaned_data['roles'])
 
         new_user.update_acl_key()
-        new_user.save()
+        setup_new_user(request.settings, new_user)
 
         messages.success(request, self.message_submit % {'user': target.username})
         return redirect('misago:admin:users:accounts:edit', pk=new_user.pk)
@@ -325,7 +344,10 @@ class EditUser(UserAdmin, generic.ModelFormView):
         target.roles.clear()
         target.roles.add(*form.cleaned_data['roles'])
 
-        set_user_signature(request, target, form.cleaned_data.get('signature'))
+        target_acl = get_user_acl(target, request.cache_versions)
+        set_user_signature(
+            request, target, target_acl, form.cleaned_data.get('signature')
+        )
 
         profilefields.update_user_profile_fields(request, target, form)
 

+ 1 - 1
misago/users/views/forgottenpassword.py

@@ -42,7 +42,7 @@ def reset_password_form(request, pk, token):
             message = _("%(user)s, your link is invalid. Please try again or request new link.")
             raise ResetError(message % {'user': requesting_user.username})
 
-        ban = get_user_ban(requesting_user)
+        ban = get_user_ban(requesting_user, request.cache_versions)
         if ban:
             raise Banned(ban)
     except ResetError as e:

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

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

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

@@ -3,7 +3,7 @@ from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect, render
 from django.views import View
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.shortcuts import paginate, pagination_dict, validate_slug
 from misago.users.bans import get_user_ban
 from misago.users.online.utils import get_user_status
@@ -27,7 +27,7 @@ class ProfileView(View):
         if not active_section:
             raise Http404()
 
-        profile.status = get_user_status(request.user, profile)
+        profile.status = get_user_status(request, profile)
         context_data = self.get_context_data(request, profile)
 
         self.complete_frontend_context(request, profile, sections)
@@ -44,7 +44,7 @@ class ProfileView(View):
             raise Http404()
 
         validate_slug(profile, slug)
-        add_acl(request.user, profile)
+        add_acl_to_obj(request.user_acl, profile)
 
         return profile
 
@@ -67,7 +67,7 @@ class ProfileView(View):
             })
 
         request.frontend_context['PROFILE'] = UserProfileSerializer(
-            profile, context={'user': request.user}
+            profile, context={'request': request}
         ).data
 
         if not profile.is_active:
@@ -92,7 +92,7 @@ class ProfileView(View):
             })
 
             if not context['show_email']:
-                context['show_email'] = request.user.acl_cache['can_see_users_emails']
+                context['show_email'] = request.user_acl['can_see_users_emails']
         else:
             context.update({
                 'is_authenticated_user': False,
@@ -184,7 +184,7 @@ class UserBanView(ProfileView):
     template_name = 'misago/profile/ban_details.html'
 
     def get_context_data(self, request, profile):
-        ban = get_user_ban(profile)
+        ban = get_user_ban(profile, request.cache_versions)
 
         request.frontend_context['PROFILE_BAN'] = BanDetailsSerializer(ban).data