Browse Source

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

Remove global state
Rafał Pitoń 6 years ago
parent
commit
948eb54805
300 changed files with 5802 additions and 5797 deletions
  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 apps
     'misago.admin',
     'misago.admin',
     'misago.acl',
     'misago.acl',
+    'misago.cache',
     'misago.core',
     'misago.core',
     'misago.conf',
     'misago.conf',
     'misago.markup',
     'misago.markup',
@@ -223,12 +224,14 @@ MIDDLEWARE = [
     'django.contrib.messages.middleware.MessageMiddleware',
     'django.contrib.messages.middleware.MessageMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
 
 
+    'misago.cache.middleware.cache_versions_middleware',
+    'misago.conf.middleware.dynamic_settings_middleware',
     'misago.users.middleware.UserMiddleware',
     'misago.users.middleware.UserMiddleware',
+    'misago.acl.middleware.user_acl_middleware',
     'misago.core.middleware.ExceptionHandlerMiddleware',
     'misago.core.middleware.ExceptionHandlerMiddleware',
     'misago.users.middleware.OnlineTrackerMiddleware',
     'misago.users.middleware.OnlineTrackerMiddleware',
     'misago.admin.middleware.AdminAuthMiddleware',
     'misago.admin.middleware.AdminAuthMiddleware',
     'misago.threads.middleware.UnreadThreadsCountMiddleware',
     'misago.threads.middleware.UnreadThreadsCountMiddleware',
-    'misago.core.middleware.ThreadStoreMiddleware',
 ]
 ]
 
 
 ROOT_URLCONF = 'devproject.urls'
 ROOT_URLCONF = 'devproject.urls'
@@ -283,12 +286,12 @@ TEMPLATES = [
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.auth.context_processors.auth',
                 'django.contrib.messages.context_processors.messages',
                 'django.contrib.messages.context_processors.messages',
 
 
+                'misago.acl.context_processors.user_acl',
+                'misago.conf.context_processors.conf',
                 'misago.core.context_processors.site_address',
                 'misago.core.context_processors.site_address',
                 'misago.core.context_processors.momentjs_locale',
                 'misago.core.context_processors.momentjs_locale',
-                'misago.conf.context_processors.settings',
                 'misago.search.context_processors.search_providers',
                 'misago.search.context_processors.search_providers',
                 'misago.users.context_processors.user_links',
                 'misago.users.context_processors.user_links',
-                'misago.legal.context_processors.legal_links',
 
 
                 # Data preloaders
                 # Data preloaders
                 'misago.conf.context_processors.preload_settings_json',
                 'misago.conf.context_processors.preload_settings_json',

+ 1 - 2
devproject/test_settings.py

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

+ 2 - 2
misago/acl/__init__.py

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

+ 0 - 65
misago/acl/api.py

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

+ 1 - 0
misago/acl/apps.py

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

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


+ 23 - 0
misago/acl/cache.py

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

+ 0 - 1
misago/acl/constants.py

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

+ 2 - 0
misago/acl/context_processors.py

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

+ 12 - 0
misago/acl/middleware.py

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

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

@@ -1,20 +1,12 @@
 from django.db import migrations
 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):
 class Migration(migrations.Migration):
+    """Superseded by 0004"""
 
 
     dependencies = [
     dependencies = [
         ('misago_acl', '0001_initial'),
         ('misago_acl', '0001_initial'),
         ('misago_core', '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.db import models
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from . import version as acl_version
+from .cache import clear_acl_cache
 
 
 
 
 def permissions_default():
 def permissions_default():
@@ -22,11 +22,11 @@ class BaseRole(models.Model):
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
         if self.pk:
         if self.pk:
-            acl_version.invalidate()
+            clear_acl_cache()
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)
 
 
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
-        acl_version.invalidate()
+        clear_acl_cache()
         return super().delete(*args, **kwargs)
         return super().delete(*args, **kwargs)
 
 
 
 

+ 18 - 0
misago/acl/objectacl.py

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

+ 1 - 1
misago/acl/panels.py

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

+ 15 - 13
misago/acl/providers.py

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

+ 56 - 0
misago/acl/test.py

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

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

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

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

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

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

+ 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):
     def test_fake_post_data_for_role(self):
         """fake data was created for Role"""
         """fake data was created for Role"""
         test_data = fake_post_data(Role(), {'can_fly': 1})
         test_data = fake_post_data(Role(), {'can_fly': 1})
-
         self.assertIn('can_fly', test_data)
         self.assertIn('can_fly', test_data)

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

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

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

+ 30 - 0
misago/acl/useracl.py

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

+ 0 - 15
misago/acl/version.py

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

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

@@ -116,15 +116,15 @@ class ListView(AdminView):
                 # So address ball contains copy-friendly link
                 # So address ball contains copy-friendly link
                 refresh_querystring = True
                 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)
             active_filters = self.get_filtering_method_to_use(filtering_methods)
             if request.GET.get('clear_filters'):
             if request.GET.get('clear_filters'):
                 # Clear filters from querystring
                 # Clear filters from querystring
                 request.session.pop(self.filters_session_key, None)
                 request.session.pop(self.filters_session_key, None)
                 active_filters = {}
                 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
             if (filtering_methods['GET'] and
                     filtering_methods['GET'] != filtering_methods['session']):
                     filtering_methods['GET'] != filtering_methods['session']):
@@ -181,12 +181,23 @@ class ListView(AdminView):
     def filters_session_key(self):
     def filters_session_key(self):
         return 'misago_admin_%s_filters' % self.root_link
         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 = search_form(request.GET)
         form.is_valid()
         form.is_valid()
         return self.clean_filtering_data(form.cleaned_data)
         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, {})
         session_filters = request.session.get(self.filters_session_key, {})
         form = search_form(session_filters)
         form = search_form(session_filters)
         form.is_valid()
         form.is_valid()
@@ -198,18 +209,6 @@ class ListView(AdminView):
                 del data[key]
                 del data[key]
         return data
         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):
     def get_filtering_method_to_use(self, methods):
         for method in ('GET', 'session'):
         for method in ('GET', 'session'):
             if methods.get(method):
             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):
 class CategoryViewSet(viewsets.ViewSet):
     def list(self, request):
     def list(self, request):
-        categories_tree = get_categories_tree(request.user, join_posters=True)
+        categories_tree = get_categories_tree(request.user, request.user_acl, join_posters=True)
         return Response(CategorySerializer(categories_tree, many=True).data)
         return Response(CategorySerializer(categories_tree, many=True).data)

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

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

+ 2 - 2
misago/categories/models.py

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

+ 11 - 13
misago/categories/permissions.py

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

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

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

+ 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 import THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.categories.models import Category
-from misago.core.testutils import MisagoTestCase
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.threadtypes import trees_map
 from misago.threads.threadtypes import trees_map
 
 
 
 
-class CategoryManagerTests(MisagoTestCase):
+class CategoryManagerTests(TestCase):
     def test_private_threads(self):
     def test_private_threads(self):
         """private_threads returns private threads category"""
         """private_threads returns private threads category"""
         category = Category.objects.private_threads()
         category = Category.objects.private_threads()
@@ -45,7 +46,7 @@ class CategoryManagerTests(MisagoTestCase):
                 self.assertNotIn(category.id, test_dict)
                 self.assertNotIn(category.id, test_dict)
 
 
 
 
-class CategoryModelTests(MisagoTestCase):
+class CategoryModelTests(TestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         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.core.management import call_command
 from django.test import TestCase
 from django.test import TestCase
 
 
+from misago.acl import ACL_CACHE
+from misago.cache.test import assert_invalidates_cache
 from misago.categories.management.commands import fixcategoriestree
 from misago.categories.management.commands import fixcategoriestree
 from misago.categories.models import Category
 from misago.categories.models import Category
 
 
@@ -82,3 +84,8 @@ class FixCategoriesTreeTests(TestCase):
             (self.test_category, 1, 2, 3),
             (self.test_category, 1, 2, 3),
             (self.first_category, 1, 4, 5),
             (self.first_category, 1, 4, 5),
         ])
         ])
+
+    def test_fixing_categories_tree_invalidates_acl_cache(self):
+        with assert_invalidates_cache(ACL_CACHE):
+            run_command()
+

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

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

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

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

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

+ 6 - 6
misago/categories/utils.py

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

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

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

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

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

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

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

+ 5 - 1
misago/conf/__init__.py

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

+ 23 - 0
misago/conf/cache.py

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

+ 22 - 24
misago/conf/context_processors.py

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

+ 0 - 96
misago/conf/dbsettings.py

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

+ 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
 from misago.admin.forms import YesNoSwitch
 
 
-
-__ALL__ = ['ChangeSettingsForm']
+__all__ = ['ChangeSettingsForm']
 
 
 
 
 class ValidateChoicesNum(object):
 class ValidateChoicesNum(object):

+ 0 - 22
misago/conf/gateway.py

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

+ 14 - 0
misago/conf/middleware.py

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

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

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

+ 0 - 7
misago/conf/migrationutils.py

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

+ 18 - 0
misago/conf/staticsettings.py

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

+ 21 - 0
misago/conf/test.py

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

+ 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):
 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('/')
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, '"SETTINGS": {"')
         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 import migrationutils
 from misago.conf.models import SettingsGroup
 from misago.conf.models import SettingsGroup
-from misago.core import threadstore
 
 
 
 
 class DBConfMigrationUtilsTests(TestCase):
 class DBConfMigrationUtilsTests(TestCase):
@@ -36,9 +35,6 @@ class DBConfMigrationUtilsTests(TestCase):
         migrationutils.migrate_settings_group(apps, self.test_group)
         migrationutils.migrate_settings_group(apps, self.test_group)
         self.groups_count = SettingsGroup.objects.count()
         self.groups_count = SettingsGroup.objects.count()
 
 
-    def tearDown(self):
-        threadstore.clear()
-
     def test_get_custom_group_and_settings(self):
     def test_get_custom_group_and_settings(self):
         """tests setup created settings group"""
         """tests setup created settings group"""
         custom_group = migrationutils.get_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 misago.admin.views import render as mi_render
 
 
-from . import db_settings
+from .cache import clear_settings_cache
 from .forms import ChangeSettingsForm
 from .forms import ChangeSettingsForm
 from .models import SettingsGroup
 from .models import SettingsGroup
 
 
@@ -44,7 +44,7 @@ def group(request, key):
                 setting.value = new_values[setting.setting]
                 setting.value = new_values[setting.setting]
                 setting.save(update_fields=['dry_value'])
                 setting.save(update_fields=['dry_value'])
 
 
-            db_settings.flush_cache()
+            clear_settings_cache()
 
 
             messages.success(request, _("Changes in settings have been saved!"))
             messages.success(request, _("Changes in settings have been saved!"))
             return redirect('misago:admin:system:settings:group', key=key)
             return redirect('misago:admin:system:settings:group', key=key)

+ 10 - 0
misago/conftest.py

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

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

+ 1 - 7
misago/core/middleware.py

@@ -1,6 +1,6 @@
 from django.utils.deprecation import MiddlewareMixin
 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
 from misago.core.utils import is_request_to_misago
 
 
 
 
@@ -19,9 +19,3 @@ class FrontendContextMiddleware(MiddlewareMixin):
     def process_request(self, request):
     def process_request(self, request):
         request.include_frontend_context = True
         request.include_frontend_context = True
         request.frontend_context = {}
         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.test.client import RequestFactory
 from django.urls import reverse
 from django.urls import reverse
 
 
+from misago.acl.useracl import get_user_acl
+from misago.users.models import AnonymousUser
+from misago.conf.dynamicsettings import DynamicSettings
+from misago.conftest import get_cache_versions
 from misago.core.testproject.views import mock_custom_403_error_page, mock_custom_404_error_page
 from misago.core.testproject.views import mock_custom_403_error_page, mock_custom_404_error_page
 from misago.core.utils import encode_json_html
 from misago.core.utils import encode_json_html
-from misago.users.models import AnonymousUser
 
 
 
 
 class CSRFErrorViewTests(TestCase):
 class CSRFErrorViewTests(TestCase):
@@ -73,20 +76,22 @@ class ErrorPageViewsTests(TestCase):
         self.assertContains(response, "Banned in auth!", status_code=403)
         self.assertContains(response, "Banned in auth!", status_code=403)
 
 
 
 
+def test_request(url):
+    request = RequestFactory().get(url)
+    request.cache_versions = 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')
 @override_settings(ROOT_URLCONF='misago.core.testproject.urlswitherrorhandlers')
 class CustomErrorPagesTests(TestCase):
 class CustomErrorPagesTests(TestCase):
     def setUp(self):
     def setUp(self):
-        self.misago_request = RequestFactory().get(reverse('misago:index'))
-        self.site_request = RequestFactory().get(reverse('raise-403'))
-
-        self.misago_request.user = AnonymousUser()
-        self.site_request.user = AnonymousUser()
-
-        self.misago_request.include_frontend_context = True
-        self.site_request.include_frontend_context = True
-
-        self.misago_request.frontend_context = {}
-        self.site_request.frontend_context = {}
+        self.misago_request = test_request(reverse('misago:index'))
+        self.site_request = test_request(reverse('raise-403'))
 
 
     def test_shared_403_decorator(self):
     def test_shared_403_decorator(self):
         """shared_403_decorator calls correct error handler"""
         """shared_403_decorator calls correct error handler"""

+ 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.test.client import RequestFactory
 from django.urls import reverse
 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.core.middleware import ExceptionHandlerMiddleware
+from misago.conftest import get_cache_versions
 from misago.users.models import AnonymousUser
 from misago.users.models import AnonymousUser
 
 
+from misago.core.middleware import ExceptionHandlerMiddleware
 
 
-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):
     def test_middleware_returns_response_for_supported_exception(self):
         """Middleware returns HttpResponse for supported exception"""
         """Middleware returns HttpResponse for supported exception"""
-        exception = Http404()
         middleware = ExceptionHandlerMiddleware()
         middleware = ExceptionHandlerMiddleware()
-
-        self.assertTrue(middleware.process_exception(self.request, exception))
+        exception = Http404()
+        assert middleware.process_exception(test_request(), exception)
 
 
     def test_middleware_returns_none_for_non_supported_exception(self):
     def test_middleware_returns_none_for_non_supported_exception(self):
         """Middleware returns None for non-supported exception"""
         """Middleware returns None for non-supported exception"""
-        exception = TypeError()
         middleware = ExceptionHandlerMiddleware()
         middleware = ExceptionHandlerMiddleware()
-
-        self.assertFalse(middleware.process_exception(self.request, exception))
+        exception = TypeError()
+        assert middleware.process_exception(test_request(), exception) is None

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

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

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

+ 3 - 1
misago/markup/api.py

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

+ 4 - 4
misago/markup/flavours.py

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

+ 2 - 1
misago/markup/serializers.py

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

+ 3 - 3
misago/readtracker/categoriestracker.py

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

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

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

+ 2 - 2
misago/readtracker/threadstracker.py

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

+ 1 - 1
misago/search/api.py

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

+ 1 - 1
misago/search/context_processors.py

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

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

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

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

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

+ 2 - 2
misago/search/views.py

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 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 import timezone
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.core.shortcuts import get_int_or_404
 from misago.core.shortcuts import get_int_or_404
 from misago.markup import common_flavour
 from misago.markup import common_flavour
 from misago.threads.checksums import update_post_checksum
 from misago.threads.checksums import update_post_checksum
@@ -71,10 +71,10 @@ def revert_post_endpoint(request, post):
     post.is_new = False
     post.is_new = False
     post.edits = post_edits + 1
     post.edits = post_edits + 1
 
 
-    add_acl(request.user, post)
+    add_acl_to_obj(request.user_acl, post)
 
 
     if post.poster:
     if post.poster:
-        make_users_status_aware(request.user, [post.poster])
+        make_users_status_aware(request, [post.poster])
 
 
     return Response(PostSerializer(post, context={'user': request.user}).data)
     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.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from misago.acl import add_acl
+from misago.acl.objectacl import add_acl_to_obj
 from misago.threads.serializers import MergePostsSerializer, PostSerializer
 from misago.threads.serializers import MergePostsSerializer, PostSerializer
 
 
 
 
@@ -15,7 +15,7 @@ def posts_merge_endpoint(request, thread):
         data=request.data,
         data=request.data,
         context={
         context={
             'thread': thread,
             'thread': thread,
-            'user': request.user,
+            'user_acl': request.user_acl,
         },
         },
     )
     )
 
 
@@ -55,6 +55,6 @@ def posts_merge_endpoint(request, thread):
     first_post.thread = thread
     first_post.thread = thread
     first_post.category = thread.category
     first_post.category = thread.category
 
 
-    add_acl(request.user, first_post)
+    add_acl_to_obj(request.user_acl, first_post)
 
 
     return Response(PostSerializer(first_post, context={'user': request.user}).data)
     return Response(PostSerializer(first_post, context={'user': request.user}).data)

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

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

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

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

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

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

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

@@ -15,8 +15,9 @@ def posts_split_endpoint(request, thread):
     serializer = SplitPostsSerializer(
     serializer = SplitPostsSerializer(
         data=request.data,
         data=request.data,
         context={
         context={
+            'settings': request.settings,
             'thread': thread,
             'thread': thread,
-            'user': 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
         # build kwargs dict for passing to middlewares
         self.kwargs = kwargs
         self.kwargs = kwargs
-        self.kwargs.update({'mode': mode, 'request': request, 'user': request.user})
+        self.kwargs.update({
+            'mode': mode,
+            'request': request,
+            'settings': request.settings,
+            'user': request.user,
+            'user_acl': request.user_acl,
+        })
 
 
         self.__dict__.update(kwargs)
         self.__dict__.update(kwargs)
 
 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 3 - 3
misago/threads/middleware.py

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

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

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

+ 5 - 2
misago/threads/participants.py

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

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

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

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

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

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

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

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

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

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

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

+ 1 - 1
misago/threads/search.py

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

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

+ 109 - 0
misago/threads/test.py

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -1,9 +1,10 @@
 from django.contrib.auth import get_user_model
 from django.contrib.auth import get_user_model
 from django.urls import reverse
 from django.urls import reverse
 
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
+from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 
 
@@ -14,19 +15,6 @@ class SubscriptionMiddlewareTestCase(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
         self.category = Category.objects.get(slug='first-category')
         self.category = Category.objects.get(slug='first-category')
-        self.override_acl()
-
-    def override_acl(self):
-        new_acl = self.user.acl_cache
-        new_acl['can_omit_flood_protection'] = True
-        new_acl['categories'][self.category.pk].update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_start_threads': 1,
-            'can_reply_threads': 1,
-        })
-
-        override_acl(self.user, new_acl)
 
 
 
 
 class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
 class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
@@ -34,10 +22,11 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         super().setUp()
         super().setUp()
         self.api_link = reverse('misago:api:thread-list')
         self.api_link = reverse('misago:api:thread-list')
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_dont_subscribe(self):
     def test_dont_subscribe(self):
         """middleware makes no subscription to thread"""
         """middleware makes no subscription to thread"""
-        self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NONE
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NOTIFY
+        self.user.subscribe_to_started_threads = UserModel.SUBSCRIPTION_NONE
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_NOTIFY
         self.user.save()
         self.user.save()
 
 
         response = self.client.post(
         response = self.client.post(
@@ -53,9 +42,10 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         # user has no subscriptions
         # user has no subscriptions
         self.assertEqual(self.user.subscription_set.count(), 0)
         self.assertEqual(self.user.subscription_set.count(), 0)
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_subscribe(self):
     def test_subscribe(self):
         """middleware subscribes thread"""
         """middleware subscribes thread"""
-        self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NOTIFY
+        self.user.subscribe_to_started_threads = UserModel.SUBSCRIPTION_NOTIFY
         self.user.save()
         self.user.save()
 
 
         response = self.client.post(
         response = self.client.post(
@@ -75,9 +65,10 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertFalse(subscription.send_email)
         self.assertFalse(subscription.send_email)
 
 
+    @patch_category_acl({"can_start_threads": True})
     def test_email_subscribe(self):
     def test_email_subscribe(self):
         """middleware subscribes thread with an email"""
         """middleware subscribes thread with an email"""
-        self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_ALL
+        self.user.subscribe_to_started_threads = UserModel.SUBSCRIPTION_ALL
         self.user.save()
         self.user.save()
 
 
         response = self.client.post(
         response = self.client.post(
@@ -108,10 +99,11 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
             }
             }
         )
         )
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_dont_subscribe(self):
     def test_dont_subscribe(self):
         """middleware makes no subscription to thread"""
         """middleware makes no subscription to thread"""
-        self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NOTIFY
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NONE
+        self.user.subscribe_to_started_threads = UserModel.SUBSCRIPTION_NOTIFY
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_NONE
         self.user.save()
         self.user.save()
 
 
         response = self.client.post(
         response = self.client.post(
@@ -124,9 +116,10 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         # user has no subscriptions
         # user has no subscriptions
         self.assertEqual(self.user.subscription_set.count(), 0)
         self.assertEqual(self.user.subscription_set.count(), 0)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_subscribe(self):
     def test_subscribe(self):
         """middleware subscribes thread"""
         """middleware subscribes thread"""
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NOTIFY
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_NOTIFY
         self.user.save()
         self.user.save()
 
 
         response = self.client.post(
         response = self.client.post(
@@ -142,9 +135,10 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertFalse(subscription.send_email)
         self.assertFalse(subscription.send_email)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_email_subscribe(self):
     def test_email_subscribe(self):
         """middleware subscribes thread with an email"""
         """middleware subscribes thread with an email"""
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_ALL
         self.user.save()
         self.user.save()
 
 
         response = self.client.post(
         response = self.client.post(
@@ -160,9 +154,10 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertTrue(subscription.send_email)
         self.assertTrue(subscription.send_email)
 
 
+    @patch_category_acl({"can_reply_threads": True})
     def test_subscribe_with_events(self):
     def test_subscribe_with_events(self):
         """middleware omits events when testing for replied thread"""
         """middleware omits events when testing for replied thread"""
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_ALL
         self.user.save()
         self.user.save()
 
 
         # set event in thread
         # set event in thread
@@ -182,9 +177,11 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertEqual(subscription.category_id, self.category.id)
         self.assertTrue(subscription.send_email)
         self.assertTrue(subscription.send_email)
 
 
+    @patch_category_acl({"can_reply_threads": True})
+    @patch_user_acl({"can_omit_flood_protection": True})
     def test_dont_subscribe_replied(self):
     def test_dont_subscribe_replied(self):
         """middleware omits threads user already replied"""
         """middleware omits threads user already replied"""
-        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
+        self.user.subscribe_to_replied_threads = UserModel.SUBSCRIPTION_ALL
         self.user.save()
         self.user.save()
 
 
         response = self.client.post(
         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 django.urls import reverse
 
 
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.models import Thread
 from misago.threads.models import Thread
+from misago.threads.test import patch_category_acl, patch_other_category_acl
 
 
 from .test_threads_api import ThreadsApiTestCase
 from .test_threads_api import ThreadsApiTestCase
 
 
@@ -183,10 +183,9 @@ class ThreadAddAclApiTests(ThreadsBulkPatchApiTestCase):
 
 
 
 
 class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
 class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
+    @patch_category_acl({"can_edit_threads": 2})
     def test_change_thread_title(self):
     def test_change_thread_title(self):
         """api changes thread title and resyncs the category"""
         """api changes thread title and resyncs the category"""
-        self.override_acl({'can_edit_threads': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link,
             self.api_link,
             {
             {
@@ -210,13 +209,12 @@ class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
         for thread in Thread.objects.filter(id__in=self.ids):
         for thread in Thread.objects.filter(id__in=self.ids):
             self.assertEqual(thread.title, 'Changed the title!')
             self.assertEqual(thread.title, 'Changed the title!')
 
 
-        category = Category.objects.get(pk=self.category.pk)
+        category = Category.objects.get(pk=self.category.id)
         self.assertEqual(category.last_thread_title, 'Changed the title!')
         self.assertEqual(category.last_thread_title, 'Changed the title!')
 
 
+    @patch_category_acl({"can_edit_threads": 0})
     def test_change_thread_title_no_permission(self):
     def test_change_thread_title_no_permission(self):
         """api validates permission to change title, returns errors"""
         """api validates permission to change title, returns errors"""
-        self.override_acl({'can_edit_threads': 0})
-
         response = self.patch(
         response = self.patch(
             self.api_link,
             self.api_link,
             {
             {
@@ -246,46 +244,19 @@ class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
         super().setUp()
         super().setUp()
 
 
         Category(
         Category(
-            name='Category B',
-            slug='category-b',
+            name='Other Category',
+            slug='other-category',
         ).insert_at(
         ).insert_at(
             self.category,
             self.category,
             position='last-child',
             position='last-child',
             save=True,
             save=True,
         )
         )
-        self.category_b = Category.objects.get(slug='category-b')
-
-    def override_other_acl(self, acl):
-        other_category_acl = self.user.acl_cache['categories'][self.category.pk].copy()
-        other_category_acl.update({
-            'can_see': 1,
-            'can_browse': 1,
-            'can_see_all_threads': 1,
-            'can_see_own_threads': 0,
-            'can_hide_threads': 0,
-            'can_approve_content': 0,
-        })
-        other_category_acl.update(acl)
-
-        categories_acl = self.user.acl_cache['categories']
-        categories_acl[self.category_b.pk] = other_category_acl
-
-        visible_categories = [self.category.pk]
-        if other_category_acl['can_see']:
-            visible_categories.append(self.category_b.pk)
-
-        override_acl(
-            self.user, {
-                'visible_categories': visible_categories,
-                'categories': categories_acl,
-            }
-        )
+        self.other_category = Category.objects.get(slug='other-category')
 
 
+    @patch_category_acl({"can_move_threads": True})
+    @patch_other_category_acl({"can_start_threads": 2})
     def test_move_thread(self):
     def test_move_thread(self):
         """api moves threads to other category and syncs both categories"""
         """api moves threads to other category and syncs both categories"""
-        self.override_acl({'can_move_threads': True})
-        self.override_other_acl({'can_start_threads': 2})
-
         response = self.patch(
         response = self.patch(
             self.api_link,
             self.api_link,
             {
             {
@@ -294,7 +265,7 @@ class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
                     {
                     {
                         'op': 'replace',
                         'op': 'replace',
                         'path': 'category',
                         'path': 'category',
-                        'value': self.category_b.pk,
+                        'value': self.other_category.id,
                     },
                     },
                     {
                     {
                         'op': 'replace',
                         'op': 'replace',
@@ -309,23 +280,22 @@ class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
         response_json = response.json()
         response_json = response.json()
         for i, thread in enumerate(self.threads):
         for i, thread in enumerate(self.threads):
             self.assertEqual(response_json[i]['id'], thread.id)
             self.assertEqual(response_json[i]['id'], thread.id)
-            self.assertEqual(response_json[i]['category'], self.category_b.pk)
+            self.assertEqual(response_json[i]['category'], self.other_category.id)
 
 
         for thread in Thread.objects.filter(id__in=self.ids):
         for thread in Thread.objects.filter(id__in=self.ids):
-            self.assertEqual(thread.category_id, self.category_b.pk)
+            self.assertEqual(thread.category_id, self.other_category.id)
 
 
-        category = Category.objects.get(pk=self.category.pk)
+        category = Category.objects.get(pk=self.category.id)
         self.assertEqual(category.threads, self.category.threads - 3)
         self.assertEqual(category.threads, self.category.threads - 3)
 
 
-        new_category = Category.objects.get(pk=self.category_b.pk)
+        new_category = Category.objects.get(pk=self.other_category.id)
         self.assertEqual(new_category.threads, 3)
         self.assertEqual(new_category.threads, 3)
 
 
 
 
 class BulkThreadsHideApiTests(ThreadsBulkPatchApiTestCase):
 class BulkThreadsHideApiTests(ThreadsBulkPatchApiTestCase):
+    @patch_category_acl({"can_hide_threads": 1})
     def test_hide_thread(self):
     def test_hide_thread(self):
         """api makes it possible to hide thread"""
         """api makes it possible to hide thread"""
-        self.override_acl({'can_hide_threads': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link,
             self.api_link,
             {
             {
@@ -349,11 +319,12 @@ class BulkThreadsHideApiTests(ThreadsBulkPatchApiTestCase):
         for thread in Thread.objects.filter(id__in=self.ids):
         for thread in Thread.objects.filter(id__in=self.ids):
             self.assertTrue(thread.is_hidden)
             self.assertTrue(thread.is_hidden)
 
 
-        category = Category.objects.get(pk=self.category.pk)
+        category = Category.objects.get(pk=self.category.id)
         self.assertNotIn(category.last_thread_id, self.ids)
         self.assertNotIn(category.last_thread_id, self.ids)
 
 
 
 
 class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
 class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
+    @patch_category_acl({"can_approve_content": True})
     def test_approve_thread(self):
     def test_approve_thread(self):
         """api approvse threads and syncs category"""
         """api approvse threads and syncs category"""
         for thread in self.threads:
         for thread in self.threads:
@@ -369,8 +340,6 @@ class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
         self.category.synchronize()
         self.category.synchronize()
         self.category.save()
         self.category.save()
 
 
-        self.override_acl({'can_approve_content': 1})
-
         response = self.patch(
         response = self.patch(
             self.api_link,
             self.api_link,
             {
             {
@@ -396,5 +365,5 @@ class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
             self.assertFalse(thread.is_unapproved)
             self.assertFalse(thread.is_unapproved)
             self.assertFalse(thread.has_unapproved_posts)
             self.assertFalse(thread.has_unapproved_posts)
 
 
-        category = Category.objects.get(pk=self.category.pk)
+        category = Category.objects.get(pk=self.category.id)
         self.assertIn(category.last_thread_id, self.ids)
         self.assertIn(category.last_thread_id, self.ids)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 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.categories.models import Category
-from misago.core.testutils import MisagoTestCase
 from misago.threads import testutils
 from misago.threads import testutils
 from misago.threads.utils import add_categories_to_items, get_thread_id_from_url
 from misago.threads.utils import add_categories_to_items, get_thread_id_from_url
 
 
 
 
-class AddCategoriesToItemsTests(MisagoTestCase):
+class AddCategoriesToItemsTests(TestCase):
     def setUp(self):
     def setUp(self):
         """
         """
         Create categories tree for test cases:
         Create categories tree for test cases:
@@ -19,7 +20,6 @@ class AddCategoriesToItemsTests(MisagoTestCase):
         Category E
         Category E
           + Subcategory F
           + Subcategory F
         """
         """
-
         super().setUp()
         super().setUp()
 
 
         self.root = Category.objects.root_category()
         self.root = Category.objects.root_category()
@@ -90,8 +90,6 @@ class AddCategoriesToItemsTests(MisagoTestCase):
             save=True,
             save=True,
         )
         )
 
 
-        self.clear_state()
-
         Category.objects.partial_rebuild(self.root.tree_id)
         Category.objects.partial_rebuild(self.root.tree_id)
 
 
         self.root = Category.objects.root_category()
         self.root = Category.objects.root_category()
@@ -176,7 +174,7 @@ class MockRequest(object):
         return self.scheme == 'https'
         return self.scheme == 'https'
 
 
 
 
-class GetThreadIdFromUrlTests(MisagoTestCase):
+class GetThreadIdFromUrlTests(TestCase):
     def test_get_thread_id_from_valid_urls(self):
     def test_get_thread_id_from_valid_urls(self):
         """get_thread_id_from_url extracts thread pk from valid urls"""
         """get_thread_id_from_url extracts thread pk from valid urls"""
         TEST_CASES = [
         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.core.exceptions import ValidationError
 from django.test import TestCase
 from django.test import TestCase
 
 
-from misago.conf import settings
-from misago.threads.validators import validate_post_length, validate_title
+from misago.threads.validators import validate_post_length, validate_thread_title
 
 
 
 
 class ValidatePostLengthTests(TestCase):
 class ValidatePostLengthTests(TestCase):
-    def test_valid_post(self):
+    def test_valid_post_length_passes_validation(self):
         """valid post passes validation"""
         """valid post passes validation"""
-        validate_post_length("Lorem ipsum dolor met sit amet elit.")
+        settings = Mock(post_length_min=1, post_length_max=50)
+        validate_post_length(settings, "Lorem ipsum dolor met sit amet elit.")
 
 
-    def test_empty_post(self):
+    def test_for_empty_post_validation_error_is_raised(self):
         """empty post is rejected"""
         """empty post is rejected"""
+        settings = Mock(post_length_min=3)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
-            validate_post_length("")
+            validate_post_length(settings, "")
 
 
-    def test_too_short_post(self):
+    def test_for_too_short_post_validation_error_is_raised(self):
         """too short post is rejected"""
         """too short post is rejected"""
+        settings = Mock(post_length_min=3)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
-            post = 'a' * settings.post_length_min
-            validate_post_length(post[1:])
+            validate_post_length(settings, "a")
 
 
-    def test_too_long_post(self):
+    def test_for_too_long_post_validation_error_is_raised(self):
         """too long post is rejected"""
         """too long post is rejected"""
+        settings = Mock(post_length_min=1, post_length_max=2)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             post = 'a' * settings.post_length_max
             post = 'a' * settings.post_length_max
-            validate_post_length(post * 2)
+            validate_post_length(settings, "abc")
+
 
 
+class ValidateThreadTitleTests(TestCase):
+    def test_valid_thread_titles_pass_validation(self):
+        """validate_thread_title is ok with valid titles"""
+        settings = Mock(thread_title_length_min=1, thread_title_length_max=50)
 
 
-class ValidateTitleTests(TestCase):
-    def test_valid_titles(self):
-        """validate_title is ok with valid titles"""
         VALID_TITLES = [
         VALID_TITLES = [
             'Lorem ipsum dolor met',
             'Lorem ipsum dolor met',
             '123 456 789 112'
             '123 456 789 112'
@@ -38,27 +44,28 @@ class ValidateTitleTests(TestCase):
         ]
         ]
 
 
         for title in VALID_TITLES:
         for title in VALID_TITLES:
-            validate_title(title)
+            validate_thread_title(settings, title)
 
 
-    def test_empty_title(self):
+    def test_for_empty_thread_title_validation_error_is_raised(self):
         """empty title is rejected"""
         """empty title is rejected"""
+        settings = Mock(thread_title_length_min=3)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
-            validate_title("")
+            validate_thread_title(settings, "")
 
 
-    def test_too_short_title(self):
+    def test_for_too_short_thread_title_validation_error_is_raised(self):
         """too short title is rejected"""
         """too short title is rejected"""
+        settings = Mock(thread_title_length_min=3)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
-            title = 'a' * settings.thread_title_length_min
-            validate_title(title[1:])
+            validate_thread_title(settings, "a")
 
 
-    def test_too_long_title(self):
+    def test_for_too_long_thread_title_validation_error_is_raised(self):
         """too long title is rejected"""
         """too long title is rejected"""
+        settings = Mock(thread_title_length_min=1, thread_title_length_max=2)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
-            title = 'a' * settings.thread_title_length_max
-            validate_title(title * 2)
+            validate_thread_title(settings, "abc")
 
 
-    def test_unsluggable_title(self):
+    def test_for_unsluggable_thread_title_valdiation_error_is_raised(self):
         """unsluggable title is rejected"""
         """unsluggable title is rejected"""
+        settings = Mock(thread_title_length_min=1, thread_title_length_max=9)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
-            title = '--' * settings.thread_title_length_min
-            validate_title(title)
+            validate_thread_title(settings, "-#%^&-")

+ 26 - 24
misago/threads/validators.py

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 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.core.exceptions import PermissionDenied
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from misago.conf import settings
 from misago.core.utils import format_plaintext_for_html
 from misago.core.utils import format_plaintext_for_html
 from misago.users.serializers import EditSignatureSerializer
 from misago.users.serializers import EditSignatureSerializer
 from misago.users.signatures import is_user_signature_valid, set_user_signature
 from misago.users.signatures import is_user_signature_valid, set_user_signature
 
 
 
 
 def signature_endpoint(request):
 def signature_endpoint(request):
-    user = request.user
-
-    if not user.acl_cache['can_have_signature']:
+    if not request.user_acl['can_have_signature']:
         raise PermissionDenied(_("You don't have permission to change signature."))
         raise PermissionDenied(_("You don't have permission to change signature."))
 
 
+    user = request.user
+    
     if user.is_signature_locked:
     if user.is_signature_locked:
         if user.signature_lock_user_message:
         if user.signature_lock_user_message:
             reason = format_plaintext_for_html(user.signature_lock_user_message)
             reason = format_plaintext_for_html(user.signature_lock_user_message)
         else:
         else:
             reason = None
             reason = None
 
 
-        return Response({
-            'detail': _("Your signature is locked. You can't change it."),
-            'reason': reason
-        },
-                        status=status.HTTP_403_FORBIDDEN)
+        return Response(
+            {
+                'detail': _("Your signature is locked. You can't change it."),
+                'reason': reason
+            },
+            status=status.HTTP_403_FORBIDDEN
+        )
 
 
     if request.method == 'POST':
     if request.method == 'POST':
         return edit_signature(request, user)
         return edit_signature(request, user)
 
 
-    return get_signature_options(user)
+    return get_signature_options(request.settings, user)
 
 
 
 
-def get_signature_options(user):
+def get_signature_options(settings, user):
     options = {
     options = {
         'signature': None,
         'signature': None,
         'limit': settings.signature_length_max,
         'limit': settings.signature_length_max,
@@ -53,13 +54,16 @@ def get_signature_options(user):
 
 
 
 
 def edit_signature(request, user):
 def edit_signature(request, user):
-    serializer = EditSignatureSerializer(user, data=request.data)
+    serializer = EditSignatureSerializer(
+        user, data=request.data, context={"settings": request.settings}
+    )
     if serializer.is_valid():
     if serializer.is_valid():
-        set_user_signature(request, user, serializer.validated_data['signature'])
+        signature = serializer.validated_data['signature']
+        set_user_signature(request, user, request.user_acl, signature)
         user.save(update_fields=['signature', 'signature_parsed', 'signature_checksum'])
         user.save(update_fields=['signature', 'signature_parsed', 'signature_checksum'])
-        return get_signature_options(user)
-    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.db import IntegrityError
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from misago.conf import settings
-from misago.users.namechanges import UsernameChanges
+from misago.users.namechanges import get_username_options
 from misago.users.serializers import ChangeUsernameSerializer
 from misago.users.serializers import ChangeUsernameSerializer
 
 
 
 
@@ -13,17 +12,14 @@ def username_endpoint(request):
     if request.method == 'POST':
     if request.method == 'POST':
         return change_username(request)
         return change_username(request)
     else:
     else:
-        return options_response(get_username_options(request.user))
+        options = get_username_options_from_request(request)
+        return options_response(options)
 
 
 
 
-def get_username_options(user):
-    options = UsernameChanges(user)
-    return {
-        'changes_left': options.left,
-        'next_on': options.next_on,
-        'length_min': settings.username_length_min,
-        'length_max': settings.username_length_max,
-    }
+def get_username_options_from_request(request):
+    return get_username_options(
+        request.settings, request.user, request.user_acl
+    )
 
 
 
 
 def options_response(options):
 def options_response(options):
@@ -33,39 +29,54 @@ def options_response(options):
 
 
 
 
 def change_username(request):
 def change_username(request):
-    options = get_username_options(request.user)
+    options = get_username_options_from_request(request)
     if not options['changes_left']:
     if not options['changes_left']:
-        return Response({
-            'detail': _("You can't change your username now."),
-            'options': options
-        },
-                        status=status.HTTP_400_BAD_REQUEST)
-
-    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():
     if serializer.is_valid():
         try:
         try:
             serializer.change_username(changed_by=request.user)
             serializer.change_username(changed_by=request.user)
+            updated_options = get_username_options_from_request(request)
+            if updated_options['next_on']:
+                updated_options['next_on'] = updated_options['next_on'].isoformat()
+
             return Response({
             return Response({
                 'username': request.user.username,
                 'username': request.user.username,
                 'slug': request.user.slug,
                 'slug': request.user.slug,
-                'options': get_username_options(request.user)
+                'options': updated_options,
             })
             })
         except IntegrityError:
         except IntegrityError:
-            return Response({
-                'detail': _("Error changing username. Please try again."),
-            },
-                            status=status.HTTP_400_BAD_REQUEST)
+            return Response(
+                {
+                    'detail': _("Error changing username. Please try again."),
+                },
+                status=status.HTTP_400_BAD_REQUEST
+            )
     else:
     else:
-        return Response({
-            'detail': serializer.errors['non_field_errors'][0]
-        },
-                        status=status.HTTP_400_BAD_REQUEST)
+        return Response(
+            {
+                'detail': serializer.errors['non_field_errors'][0]
+            },
+            status=status.HTTP_400_BAD_REQUEST
+        )
 
 
 
 
 def moderate_username_endpoint(request, profile):
 def moderate_username_endpoint(request, profile):
     if request.method == 'POST':
     if request.method == 'POST':
-        serializer = ChangeUsernameSerializer(data=request.data, context={'user': profile})
+        serializer = ChangeUsernameSerializer(
+            data=request.data,
+            context={'settings': request.settings, 'user': profile},
+        )
 
 
         if serializer.is_valid():
         if serializer.is_valid():
             try:
             try:
@@ -75,17 +86,21 @@ def moderate_username_endpoint(request, profile):
                     'slug': profile.slug,
                     'slug': profile.slug,
                 })
                 })
             except IntegrityError:
             except IntegrityError:
-                return Response({
-                    'detail': _("Error changing username. Please try again."),
-                },
-                                status=status.HTTP_400_BAD_REQUEST)
+                return Response(
+                    {
+                        'detail': _("Error changing username. Please try again."),
+                    },
+                    status=status.HTTP_400_BAD_REQUEST
+                )
         else:
         else:
-            return Response({
-                'detail': serializer.errors['non_field_errors'][0]
-            },
-                            status=status.HTTP_400_BAD_REQUEST)
+            return Response(
+                {
+                    'detail': serializer.errors['non_field_errors'][0]
+                },
+                status=status.HTTP_400_BAD_REQUEST
+            )
     else:
     else:
         return Response({
         return Response({
-            'length_min': settings.username_length_min,
-            'length_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:
         if user_pk == request.user.pk:
             return True
             return True
-        elif not request.user.acl_cache.get('can_see_users_name_history'):
+        elif not request.user_acl.get('can_see_users_name_history'):
             raise PermissionDenied(_("You don't have permission to see other users name history."))
             raise PermissionDenied(_("You don't have permission to see other users name history."))
         return True
         return True
 
 

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

+ 3 - 3
misago/users/apps.py

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

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

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

+ 8 - 11
misago/users/bans.py

@@ -9,13 +9,10 @@ from datetime import timedelta
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.dateparse import parse_datetime
 from django.utils.dateparse import parse_datetime
 
 
-from misago.core import cachebuster
-
+from .constants import BANS_CACHE
 from .models import Ban, BanCache
 from .models import Ban, BanCache
 
 
-
 CACHE_SESSION_KEY = 'misago_ip_check'
 CACHE_SESSION_KEY = 'misago_ip_check'
-VERSION_KEY = 'misago_bans'
 
 
 
 
 def get_username_ban(username, registration_only=False):
 def get_username_ban(username, registration_only=False):
@@ -39,7 +36,7 @@ def get_ip_ban(ip, registration_only=False):
         return None
         return None
 
 
 
 
-def get_user_ban(user):
+def get_user_ban(user, cache_versions):
     """
     """
     This function checks if user is banned
     This function checks if user is banned
 
 
@@ -49,11 +46,11 @@ def get_user_ban(user):
     """
     """
     try:
     try:
         ban_cache = user.ban_cache
         ban_cache = user.ban_cache
-        if not ban_cache.is_valid:
+        if not ban_cache.is_valid(cache_versions):
             _set_user_ban_cache(user)
             _set_user_ban_cache(user)
     except BanCache.DoesNotExist:
     except BanCache.DoesNotExist:
         user.ban_cache = BanCache(user=user)
         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:
     if user.ban_cache.ban:
         return user.ban_cache
         return user.ban_cache
@@ -61,9 +58,9 @@ def get_user_ban(user):
         return None
         return None
 
 
 
 
-def _set_user_ban_cache(user):
+def _set_user_ban_cache(user, cache_versions):
     ban_cache = user.ban_cache
     ban_cache = user.ban_cache
-    ban_cache.bans_version = cachebuster.get_version(VERSION_KEY)
+    ban_cache.cache_version = cache_versions[BANS_CACHE]
 
 
     try:
     try:
         user_ban = Ban.objects.get_ban(
         user_ban = Ban.objects.get_ban(
@@ -103,7 +100,7 @@ def get_request_ip_ban(request):
     found_ban = get_ip_ban(request.user_ip)
     found_ban = get_ip_ban(request.user_ip)
 
 
     ban_cache = request.session[CACHE_SESSION_KEY] = {
     ban_cache = request.session[CACHE_SESSION_KEY] = {
-        'version': cachebuster.get_version(VERSION_KEY),
+        'version': request.cache_versions[BANS_CACHE],
         'ip': request.user_ip,
         'ip': request.user_ip,
     }
     }
 
 
@@ -128,7 +125,7 @@ def _get_session_bancache(request):
         ban_cache = _hydrate_session_cache(ban_cache)
         ban_cache = _hydrate_session_cache(ban_cache)
         if ban_cache['ip'] != request.user_ip:
         if ban_cache['ip'] != request.user_ip:
             return None
             return None
-        if not cachebuster.is_valid(VERSION_KEY, ban_cache['version']):
+        if ban_cache['version'] != request.cache_versions[BANS_CACHE]:
             return None
             return None
         if ban_cache.get('expires_on'):
         if ban_cache.get('expires_on'):
             if ban_cache['expires_on'] < timezone.today():
             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.core.exceptions import ValidationError
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from misago.conf import settings
-
 
 
 def recaptcha_test(request):
 def recaptcha_test(request):
     r = requests.post(
     r = requests.post(
         'https://www.google.com/recaptcha/api/siteverify',
         'https://www.google.com/recaptcha/api/siteverify',
         data={
         data={
-            'secret': settings.recaptcha_secret_key,
+            'secret': request.settings.recaptcha_secret_key,
             'response': request.data.get('captcha'),
             'response': request.data.get('captcha'),
             'remoteip': request.user_ip
             'remoteip': request.user_ip
         }
         }
@@ -25,15 +23,17 @@ def recaptcha_test(request):
 
 
 
 
 def qacaptcha_test(request):
 def qacaptcha_test(request):
-    answer = request.data.get('captcha', '').lower()
-    for predefined_answer in settings.qa_answers.lower().splitlines():
-        predefined_answer = predefined_answer.strip().lower()
-        if answer == predefined_answer:
-            break
-    else:
+    answer = request.data.get('captcha', '').lower().strip()
+    valid_answers = get_valid_qacaptcha_answers(request.settings)
+    if answer not in valid_answers:
         raise ValidationError(_("Entered answer is incorrect."))
         raise ValidationError(_("Entered answer is incorrect."))
 
 
 
 
+def get_valid_qacaptcha_answers(settings):
+    valid_answers = [i.strip() for i in settings.qa_answers.lower().splitlines()]
+    return filter(len, valid_answers)
+
+
 def nocaptcha_test(request):
 def nocaptcha_test(request):
     return  # no captcha means no validation
     return  # no captcha means no validation
 
 
@@ -46,4 +46,4 @@ CAPTCHA_TESTS = {
 
 
 
 
 def test_request(request):
 def test_request(request):
-    CAPTCHA_TESTS[settings.captcha_type](request)
+    CAPTCHA_TESTS[request.settings.captcha_type](request)

+ 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:
     if request.user.is_authenticated:
-        request.frontend_context.update({'user': AuthenticatedUserSerializer(request.user).data})
+        serializer = AuthenticatedUserSerializer
     else:
     else:
-        request.frontend_context.update({'user': AnonymousUserSerializer(request.user).data})
+        serializer = AnonymousUserSerializer
+
+    serialized_user = serializer(request.user, context={"acl": request.user_acl}).data
+    request.frontend_context.update({'user': serialized_user})
 
 
     return {}
     return {}

+ 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.acl.models import Role
 from misago.admin.forms import IsoDateTimeField, YesNoSwitch
 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.core.validators import validate_sluggable
+
 from misago.users.models import Ban, DataDownload, Rank
 from misago.users.models import Ban, DataDownload, Rank
 from misago.users.profilefields import profilefields
 from misago.users.profilefields import profilefields
 from misago.users.utils import hash_email
 from misago.users.utils import hash_email
 from misago.users.validators import validate_email, validate_username
 from misago.users.validators import validate_email, validate_username
 
 
-
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
 
 
@@ -28,9 +26,15 @@ class UserBaseForm(forms.ModelForm):
         model = UserModel
         model = UserModel
         fields = ['username', 'email', 'title']
         fields = ['username', 'email', 'title']
 
 
+    def __init__(self, *args, **kwargs):
+        self.request = kwargs.pop('request')
+        self.settings = self.request.settings
+
+        super().__init__(*args, **kwargs)
+
     def clean_username(self):
     def clean_username(self):
         data = self.cleaned_data['username']
         data = self.cleaned_data['username']
-        validate_username(data, exclude=self.instance)
+        validate_username(self.settings, data, exclude=self.instance)
         return data
         return data
 
 
     def clean_email(self):
     def clean_email(self):
@@ -165,10 +169,10 @@ class EditUserForm(UserBaseForm):
     )
     )
 
 
     subscribe_to_started_threads = forms.TypedChoiceField(
     subscribe_to_started_threads = forms.TypedChoiceField(
-        label=_("Started threads"), coerce=int, choices=UserModel.SUBSCRIBE_CHOICES
+        label=_("Started threads"), coerce=int, choices=UserModel.SUBSCRIPTION_CHOICES
     )
     )
     subscribe_to_replied_threads = forms.TypedChoiceField(
     subscribe_to_replied_threads = forms.TypedChoiceField(
-        label=_("Replid threads"), coerce=int, choices=UserModel.SUBSCRIBE_CHOICES
+        label=_("Replid threads"), coerce=int, choices=UserModel.SUBSCRIPTION_CHOICES
     )
     )
 
 
     class Meta:
     class Meta:
@@ -191,10 +195,7 @@ class EditUserForm(UserBaseForm):
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
-        self.request = kwargs.pop('request')
-
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
-
         profilefields.add_fields_to_admin_form(self.request, self.instance, self)
         profilefields.add_fields_to_admin_form(self.request, self.instance, self)
 
 
     def get_profile_fields_groups(self):
     def get_profile_fields_groups(self):
@@ -214,7 +215,7 @@ class EditUserForm(UserBaseForm):
     def clean_signature(self):
     def clean_signature(self):
         data = self.cleaned_data['signature']
         data = self.cleaned_data['signature']
 
 
-        length_limit = settings.signature_length_max
+        length_limit = self.settings.signature_length_max
         if len(data) > length_limit:
         if len(data) > length_limit:
             raise forms.ValidationError(
             raise forms.ValidationError(
                 ngettext(
                 ngettext(
@@ -305,7 +306,7 @@ def EditUserFormFactory(FormType, instance, add_is_active_fields=False, add_admi
     return FormType
     return FormType
 
 
 
 
-class SearchUsersFormBase(forms.Form):
+class BaseSearchUsersForm(forms.Form):
     username = forms.CharField(label=_("Username starts with"), required=False)
     username = forms.CharField(label=_("Username starts with"), required=False)
     email = forms.CharField(label=_("E-mail starts with"), required=False)
     email = forms.CharField(label=_("E-mail starts with"), required=False)
     profilefields = forms.CharField(label=_("Profile fields contain"), required=False)
     profilefields = forms.CharField(label=_("Profile fields contain"), required=False)
@@ -346,25 +347,19 @@ class SearchUsersFormBase(forms.Form):
         return queryset
         return queryset
 
 
 
 
-def SearchUsersForm(*args, **kwargs):
+def create_search_users_form():
     """
     """
     Factory that uses cache for ranks and roles,
     Factory that uses cache for ranks and roles,
     and makes those ranks and roles typed choice fields that play nice
     and makes those ranks and roles typed choice fields that play nice
     with passing values via GET
     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 = {
     extra_fields = {
         'rank': forms.TypedChoiceField(
         '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):
 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):
     def confirm_user_not_banned(self, user):
         if not user.is_staff:
         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:
             if self.user_ban:
                 raise ValidationError('', code='banned')
                 raise ValidationError('', code='banned')
 
 
@@ -62,6 +62,10 @@ class AuthenticationForm(MisagoAuthMixin, BaseAuthenticationForm):
         widget=forms.PasswordInput,
         widget=forms.PasswordInput,
     )
     )
 
 
+    def __init__(self, *args, request=None, **kwargs):
+        self.request = request
+        super().__init__(*args, **kwargs)
+
     def clean(self):
     def clean(self):
         username = self.cleaned_data.get('username')
         username = self.cleaned_data.get('username')
         password = self.cleaned_data.get('password')
         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.core.exceptions import ValidationError
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 
 
-from misago.users import validators
 from misago.users.bans import get_email_ban, get_ip_ban, get_username_ban
 from misago.users.bans import get_email_ban, get_ip_ban, get_username_ban
+from misago.users.validators import (
+    validate_email, validate_new_registration, validate_username
+)
 
 
 
 
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
 
 
 class BaseRegisterForm(forms.Form):
 class BaseRegisterForm(forms.Form):
-    username = forms.CharField(validators=[validators.validate_username])
-    email = forms.CharField(validators=[validators.validate_email])
+    username = forms.CharField()
+    email = forms.CharField(validators=[validate_email])
 
 
     terms_of_service = forms.IntegerField(required=False)
     terms_of_service = forms.IntegerField(required=False)
     privacy_policy = forms.IntegerField(required=False)
     privacy_policy = forms.IntegerField(required=False)
@@ -26,6 +28,7 @@ class BaseRegisterForm(forms.Form):
     def clean_username(self):
     def clean_username(self):
         data = self.cleaned_data['username']
         data = self.cleaned_data['username']
 
 
+        validate_username(self.request.settings, data)
         ban = get_username_ban(data, registration_only=True)
         ban = get_username_ban(data, registration_only=True)
         if ban:
         if ban:
             if ban.user_message:
             if ban.user_message:
@@ -67,7 +70,7 @@ class SocialAuthRegisterForm(BaseRegisterForm):
         self.clean_agreements(cleaned_data)
         self.clean_agreements(cleaned_data)
         self.raise_if_ip_banned()
         self.raise_if_ip_banned()
 
 
-        validators.validate_new_registration(self.request, cleaned_data, self)
+        validate_new_registration(self.request, cleaned_data, self)
 
 
         return cleaned_data
         return cleaned_data
 
 
@@ -99,6 +102,6 @@ class RegisterForm(BaseRegisterForm):
         except forms.ValidationError as e:
         except forms.ValidationError as e:
             self.add_error('password', e)
             self.add_error('password', e)
 
 
-        validators.validate_new_registration(self.request, cleaned_data, self.add_error)
+        validate_new_registration(self.request, cleaned_data, self.add_error)
 
 
         return cleaned_data
         return cleaned_data

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

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

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

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

+ 4 - 1
misago/users/middleware.py

@@ -20,7 +20,10 @@ class UserMiddleware(MiddlewareMixin):
         if request.user.is_anonymous:
         if request.user.is_anonymous:
             request.user = AnonymousUser()
             request.user = AnonymousUser()
         elif not request.user.is_staff:
         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)
                 logout(request)
                 request.user = AnonymousUser()
                 request.user = AnonymousUser()
 
 

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

@@ -1,20 +1,12 @@
 from django.db import migrations
 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):
 class Migration(migrations.Migration):
+    """Migration superseded by 0016"""
 
 
     dependencies = [
     dependencies = [
         ('misago_users', '0002_users_settings'),
         ('misago_users', '0002_users_settings'),
         ('misago_core', '0001_initial'),
         ('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
 # Generated by Django 1.10.5 on 2017-02-05 14:34
 from django.db import migrations
 from django.db import migrations
 
 
-from misago.conf.migrationutils import delete_settings_cache, migrate_settings_group
-
+from misago.conf.migrationutils import migrate_settings_group
 
 
 _ = lambda s: s
 _ = lambda s: s
 
 
@@ -165,8 +164,6 @@ def update_users_settings(apps, schema_editor):
         }
         }
     )
     )
 
 
-    delete_settings_cache()
-
 
 
 class Migration(migrations.Migration):
 class Migration(migrations.Migration):
 
 

+ 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 .rank import Rank
-from .user import AnonymousUser, Online, User, UsernameChange
+from .online import Online
+from .user import AnonymousUser, User, UsernameChange
 from .activityranking import ActivityRanking
 from .activityranking import ActivityRanking
 from .avatar import Avatar
 from .avatar import Avatar
 from .audittrail import AuditTrail
 from .audittrail import AuditTrail

+ 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 import timezone
 from django.utils.translation import gettext_lazy as _
 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):
 class BansManager(models.Manager):
@@ -29,7 +29,7 @@ class BansManager(models.Manager):
         )
         )
 
 
     def invalidate_cache(self):
     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):
     def get_ban(self, username=None, email=None, ip=None, registration_only=False):
         checks = []
         checks = []
@@ -131,7 +131,7 @@ class BanCache(models.Model):
         blank=True,
         blank=True,
         on_delete=models.SET_NULL,
         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)
     user_message = models.TextField(null=True, blank=True)
     staff_message = models.TextField(null=True, blank=True)
     staff_message = models.TextField(null=True, blank=True)
     expires_on = models.DateTimeField(null=True, blank=True)
     expires_on = models.DateTimeField(null=True, blank=True)
@@ -157,9 +157,8 @@ class BanCache(models.Model):
     def is_banned(self):
     def is_banned(self):
         return bool(self.ban)
         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.db import models, transaction
 from django.urls import reverse
 from django.urls import reverse
 
 
-from misago.acl import version as acl_version
+from misago.acl.cache import clear_acl_cache
 from misago.core.utils import slugify
 from misago.core.utils import slugify
 
 
 
 
@@ -39,11 +39,13 @@ class Rank(models.Model):
         if not self.pk:
         if not self.pk:
             self.set_order()
             self.set_order()
         else:
         else:
-            acl_version.invalidate()
+            clear_acl_cache()
+        if not self.slug:
+            self.slug = slugify(self.name)
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)
 
 
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
-        acl_version.invalidate()
+        clear_acl_cache()
         return super().delete(*args, **kwargs)
         return super().delete(*args, **kwargs)
 
 
     def get_absolute_url(self):
     def get_absolute_url(self):

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

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

+ 41 - 29
misago/users/namechanges.py

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

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

@@ -9,7 +9,27 @@ from misago.users.models import BanCache, Online
 ACTIVITY_CUTOFF = timedelta(minutes=2)
 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 = {
     user_status = {
         'is_banned': False,
         'is_banned': False,
         'is_hidden': user.is_hiding_presence,
         '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,
         '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:
     if user_ban:
         user_status['is_banned'] = True
         user_status['is_banned'] = True
         user_status['banned_until'] = user_ban.expires_on
         user_status['banned_until'] = user_ban.expires_on
 
 
     try:
     try:
         online_tracker = user.online_tracker
         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 and not is_hidden:
             if online_tracker.last_click >= timezone.now() - ACTIVITY_CUTOFF:
             if online_tracker.last_click >= timezone.now() - ACTIVITY_CUTOFF:
@@ -38,7 +58,7 @@ def get_user_status(viewer, user):
         pass
         pass
 
 
     if user_status['is_hidden']:
     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
             user_status['is_hidden'] = False
             if user_status['is_online']:
             if user_status['is_online']:
                 user_status['is_online_hidden'] = True
                 user_status['is_online_hidden'] = True
@@ -55,22 +75,3 @@ def get_user_status(viewer, user):
             user_status['is_offline'] = True
             user_status['is_offline'] = True
 
 
     return user_status
     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 authenticated_only(f):
-    def perm_decorator(user, target):
-        if user.is_authenticated:
-            return f(user, target)
-        else:
-            messsage = _("You have to sig in to perform this action.")
-            raise PermissionDenied(messsage)
+    def perm_decorator(user_acl, target):
+        if user_acl["is_authenticated"]:
+            return f(user_acl, target)
+        else: 
+            raise PermissionDenied(
+                _("You have to sig in to perform this action.")
+            )
 
 
     return perm_decorator
     return perm_decorator
 
 
 
 
 def anonymous_only(f):
 def anonymous_only(f):
-    def perm_decorator(user, target):
-        if user.is_anonymous:
-            return f(user, target)
+    def perm_decorator(user_acl, target):
+        if user_acl["is_anonymous"]:
+            return f(user_acl, target)
         else:
         else:
-            messsage = _("Only guests can perform this action.")
-            raise PermissionDenied(messsage)
+            raise PermissionDenied(
+                _("Only guests can perform this action.")
+            )
 
 
     return perm_decorator
     return perm_decorator

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

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

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

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

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

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

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

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

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

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

+ 9 - 2
misago/users/registration.py

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

+ 1 - 1
misago/users/search.py

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

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

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

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

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

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

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

+ 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
 from misago.markup import checksums, signature_flavour
 
 
 
 
-def set_user_signature(request, user, signature):
+def set_user_signature(request, user, user_acl, signature):
     user.signature = signature
     user.signature = signature
 
 
     if signature:
     if signature:
-        user.signature_parsed = signature_flavour(request, user, signature)
+        user.signature_parsed = signature_flavour(request, user, user_acl, signature)
         user.signature_checksum = make_signature_checksum(user.signature_parsed, user)
         user.signature_checksum = make_signature_checksum(user.signature_parsed, user)
     else:
     else:
         user.signature_parsed = ''
         user.signature_parsed = ''

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

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

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

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

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

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

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

+ 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.test import TestCase
 from django.utils import timezone
 from django.utils import timezone
 
 
+from misago.conftest import get_cache_versions
 from misago.users.bans import (
 from misago.users.bans import (
     ban_ip, ban_user, get_email_ban, get_ip_ban, get_request_ip_ban, get_user_ban, get_username_ban)
     ban_ip, ban_user, get_email_ban, get_ip_ban, get_request_ip_ban, get_user_ban, get_username_ban)
+from misago.users.constants import BANS_CACHE
 from misago.users.models import Ban
 from misago.users.models import Ban
 
 
-
 UserModel = get_user_model()
 UserModel = get_user_model()
 
 
+cache_versions = get_cache_versions()
+
 
 
 class GetBanTests(TestCase):
 class GetBanTests(TestCase):
     def test_get_username_ban(self):
     def test_get_username_ban(self):
@@ -40,13 +43,13 @@ class GetBanTests(TestCase):
         )
         )
         self.assertEqual(get_username_ban('admiral').pk, valid_ban.pk)
         self.assertEqual(get_username_ban('admiral').pk, valid_ban.pk)
 
 
-        regitration_ban = Ban.objects.create(
+        registration_ban = Ban.objects.create(
             banned_value='bob*',
             banned_value='bob*',
             expires_on=timezone.now() + timedelta(days=7),
             expires_on=timezone.now() + timedelta(days=7),
             registration_only=True,
             registration_only=True,
         )
         )
         self.assertIsNone(get_username_ban('boberson'))
         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):
     def test_get_email_ban(self):
         """get_email_ban returns valid ban"""
         """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)
         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',
             banned_value='*.ua',
             check_type=Ban.EMAIL,
             check_type=Ban.EMAIL,
             expires_on=timezone.now() + timedelta(days=7),
             expires_on=timezone.now() + timedelta(days=7),
             registration_only=True,
             registration_only=True,
         )
         )
         self.assertIsNone(get_email_ban('banned@mail.ua'))
         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):
     def test_get_ip_ban(self):
         """get_ip_ban returns valid ban"""
         """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)
         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.*',
             banned_value='188.*',
             check_type=Ban.IP,
             check_type=Ban.IP,
             expires_on=timezone.now() + timedelta(days=7),
             expires_on=timezone.now() + timedelta(days=7),
             registration_only=True,
             registration_only=True,
         )
         )
         self.assertIsNone(get_ip_ban('188.12.12.41'))
         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):
 class UserBansTests(TestCase):
@@ -131,7 +134,7 @@ class UserBansTests(TestCase):
 
 
     def test_no_ban(self):
     def test_no_ban(self):
         """user is not caught by ban"""
         """user is not caught by ban"""
-        self.assertIsNone(get_user_ban(self.user))
+        self.assertIsNone(get_user_ban(self.user, cache_versions))
         self.assertFalse(self.user.ban_cache.is_banned)
         self.assertFalse(self.user.ban_cache.is_banned)
 
 
     def test_permanent_ban(self):
     def test_permanent_ban(self):
@@ -142,7 +145,7 @@ class UserBansTests(TestCase):
             staff_message='Staff reason',
             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.assertIsNotNone(user_ban)
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
@@ -157,7 +160,7 @@ class UserBansTests(TestCase):
             expires_on=timezone.now() + timedelta(days=7),
             expires_on=timezone.now() + timedelta(days=7),
         )
         )
 
 
-        user_ban = get_user_ban(self.user)
+        user_ban = get_user_ban(self.user, cache_versions)
         self.assertIsNotNone(user_ban)
         self.assertIsNotNone(user_ban)
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.user_message, 'User reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
         self.assertEqual(user_ban.staff_message, 'Staff reason')
@@ -170,7 +173,7 @@ class UserBansTests(TestCase):
             expires_on=timezone.now() - timedelta(days=7),
             expires_on=timezone.now() - timedelta(days=7),
         )
         )
 
 
-        self.assertIsNone(get_user_ban(self.user))
+        self.assertIsNone(get_user_ban(self.user, cache_versions))
         self.assertFalse(self.user.ban_cache.is_banned)
         self.assertFalse(self.user.ban_cache.is_banned)
 
 
     def test_expired_non_flagged_ban(self):
     def test_expired_non_flagged_ban(self):
@@ -181,7 +184,7 @@ class UserBansTests(TestCase):
         )
         )
         Ban.objects.update(is_checked=True)
         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)
         self.assertFalse(self.user.ban_cache.is_banned)
 
 
 
 
@@ -189,6 +192,7 @@ class MockRequest(object):
     def __init__(self):
     def __init__(self):
         self.user_ip = '127.0.0.1'
         self.user_ip = '127.0.0.1'
         self.session = {}
         self.session = {}
+        self.cache_versions = cache_versions
 
 
 
 
 class RequestIPBansTests(TestCase):
 class RequestIPBansTests(TestCase):
@@ -255,7 +259,7 @@ class BanUserTests(TestCase):
         self.assertEqual(ban.user_message, 'User reason')
         self.assertEqual(ban.user_message, 'User reason')
         self.assertEqual(ban.staff_message, 'Staff reason')
         self.assertEqual(ban.staff_message, 'Staff reason')
 
 
-        db_ban = get_user_ban(user)
+        db_ban = get_user_ban(user, cache_versions)
         self.assertEqual(ban.pk, db_ban.ban_id)
         self.assertEqual(ban.pk, db_ban.ban_id)
 
 
 
 
@@ -267,4 +271,4 @@ class BanIpTests(TestCase):
         self.assertEqual(ban.staff_message, 'Staff reason')
         self.assertEqual(ban.staff_message, 'Staff reason')
 
 
         db_ban = get_ip_ban('127.0.0.1')
         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.test import TestCase
 from django.urls import reverse
 from django.urls import reverse
 
 
-from misago.conf import settings
+from misago.conf.test import override_dynamic_settings
+
+test_qa_question = "Do you like pies?"
+test_qa_help_text = 'Type in "yes".'
 
 
 
 
 class AuthenticateApiTests(TestCase):
 class AuthenticateApiTests(TestCase):
     def setUp(self):
     def setUp(self):
         self.api_link = reverse('misago:api:captcha-question')
         self.api_link = reverse('misago:api:captcha-question')
 
 
-    def tearDown(self):
-        settings.reset_settings()
-
+    @override_dynamic_settings(qa_question="")
     def test_api_no_qa_is_set(self):
     def test_api_no_qa_is_set(self):
         """qa api returns 404 if no QA question is set"""
         """qa api returns 404 if no QA question is set"""
-        settings.override_setting('qa_question', '')
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
         self.assertEqual(response.status_code, 404)
 
 
+    @override_dynamic_settings(
+        qa_question=test_qa_question, qa_help_text=test_qa_help_text
+    )
     def test_api_get_question(self):
     def test_api_get_question(self):
         """qa api returns valid QA question"""
         """qa api returns valid QA question"""
-        settings.override_setting('qa_question', 'Do you like pies?')
-        settings.override_setting('qa_help_text', 'Type in "yes".')
-
         response = self.client.get(self.api_link)
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
 
 
         response_json = response.json()
         response_json = response.json()
-        self.assertEqual(response_json['question'], 'Do you like pies?')
-        self.assertEqual(response_json['help_text'], 'Type in "yes".')
+        self.assertEqual(response_json['question'], test_qa_question)
+        self.assertEqual(response_json['help_text'], test_qa_help_text)

+ 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.test import TestCase
 from django.utils import timezone
 from django.utils import timezone
 
 
+from misago.cache.versions import get_cache_versions
+
 from misago.users import bans
 from misago.users import bans
 from misago.users.management.commands import invalidatebans
 from misago.users.management.commands import invalidatebans
 from misago.users.models import Ban, BanCache
 from misago.users.models import Ban, BanCache
@@ -41,7 +43,7 @@ class InvalidateBansTests(TestCase):
 
 
         # ban user
         # ban user
         Ban.objects.create(banned_value="bob")
         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.assertIsNotNone(user_ban)
         self.assertEqual(Ban.objects.filter(is_checked=True).count(), 1)
         self.assertEqual(Ban.objects.filter(is_checked=True).count(), 1)
@@ -74,4 +76,4 @@ class InvalidateBansTests(TestCase):
 
 
         # see if user is banned anymore
         # see if user is banned anymore
         user = UserModel.objects.get(id=user.id)
         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.contrib.auth import get_user_model
 from django.urls import reverse
 from django.urls import reverse
 
 
+from misago.acl.test import patch_user_acl
 from misago.admin.testutils import AdminTestCase
 from misago.admin.testutils import AdminTestCase
-from misago.acl.testutils import override_acl
 
 
 
 
 UserModel = get_user_model()
 UserModel = get_user_model()
@@ -27,7 +27,7 @@ class JoinIpProfileFieldTests(AdminTestCase):
         self.assertNotContains(response, "Join IP")
         self.assertNotContains(response, "Join IP")
 
 
     def test_admin_edits_field(self):
     def test_admin_edits_field(self):
-        """admin form allows admins to edit field"""
+        """join_ip is non-editable by admin"""
         response = self.client.post(
         response = self.client.post(
             self.test_link,
             self.test_link,
             data={
             data={
@@ -74,6 +74,7 @@ class JoinIpProfileFieldTests(AdminTestCase):
         self.assertContains(response, "Join IP")
         self.assertContains(response, "Join IP")
         self.assertContains(response, "127.0.0.1")
         self.assertContains(response, "127.0.0.1")
 
 
+    @patch_user_acl({'can_see_users_ips': 0})
     def test_field_hidden_no_permission(self):
     def test_field_hidden_no_permission(self):
         """field is hidden on user profile if user has no permission"""
         """field is hidden on user profile if user has no permission"""
         test_link = reverse(
         test_link = reverse(
@@ -84,10 +85,6 @@ class JoinIpProfileFieldTests(AdminTestCase):
             },
             },
         )
         )
 
 
-        override_acl(self.user, {
-            'can_see_users_ips': 0
-        })
-
         response = self.client.get(test_link)
         response = self.client.get(test_link)
         self.assertNotContains(response, "IP address")
         self.assertNotContains(response, "IP address")
         self.assertNotContains(response, "Join IP")
         self.assertNotContains(response, "Join IP")
@@ -132,14 +129,11 @@ class JoinIpProfileFieldTests(AdminTestCase):
             ]
             ]
         )
         )
 
 
+    @patch_user_acl({'can_see_users_ips': 0})
     def test_field_hidden_no_permission_json(self):
     def test_field_hidden_no_permission_json(self):
         """field is not included in display json if user has no permission"""
         """field is not included in display json if user has no permission"""
         test_link = reverse('misago:api:user-details', kwargs={'pk': self.user.pk})
         test_link = reverse('misago:api:user-details', kwargs={'pk': self.user.pk})
 
 
-        override_acl(self.user, {
-            'can_see_users_ips': 0
-        })
-
         response = self.client.get(test_link)
         response = self.client.get(test_link)
         self.assertEqual(response.json()['groups'], [])
         self.assertEqual(response.json()['groups'], [])
 
 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 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.urls import reverse
 from django.utils.encoding import smart_str
 from django.utils.encoding import smart_str
 
 
-from misago.acl.testutils import override_acl
+from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.categories.models import Category
-from misago.core import threadstore
-from misago.core.cache import cache
 from misago.threads.models import Post, Thread
 from misago.threads.models import Post, Thread
 from misago.threads.testutils import post_thread
 from misago.threads.testutils import post_thread
 from misago.users.activepostersranking import build_active_posters_ranking
 from misago.users.activepostersranking import build_active_posters_ranking
 from misago.users.models import Ban, Rank
 from misago.users.models import Ban, Rank
 from misago.users.testutils import AuthenticatedUserTestCase
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-
-UserModel = get_user_model()
+User = get_user_model()
 
 
 
 
 class ActivePostersListTests(AuthenticatedUserTestCase):
 class ActivePostersListTests(AuthenticatedUserTestCase):
@@ -25,10 +22,8 @@ class ActivePostersListTests(AuthenticatedUserTestCase):
 
 
     def setUp(self):
     def setUp(self):
         super().setUp()
         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 = Category.objects.all_categories()[:1][0]
         self.category.labels = []
         self.category.labels = []
@@ -86,7 +81,7 @@ class FollowersListTests(AuthenticatedUserTestCase):
 
 
     def test_filled_list(self):
     def test_filled_list(self):
         """user with followers returns 200"""
         """user with followers returns 200"""
-        test_follower = UserModel.objects.create_user(
+        test_follower = User.objects.create_user(
             "TestFollower",
             "TestFollower",
             "test@follower.com",
             "test@follower.com",
             self.USER_PASSWORD,
             self.USER_PASSWORD,
@@ -99,7 +94,7 @@ class FollowersListTests(AuthenticatedUserTestCase):
 
 
     def test_filled_list_search(self):
     def test_filled_list_search(self):
         """followers list is searchable"""
         """followers list is searchable"""
-        test_follower = UserModel.objects.create_user(
+        test_follower = User.objects.create_user(
             "TestFollower",
             "TestFollower",
             "test@follower.com",
             "test@follower.com",
             self.USER_PASSWORD,
             self.USER_PASSWORD,
@@ -132,7 +127,7 @@ class FollowsListTests(AuthenticatedUserTestCase):
 
 
     def test_filled_list(self):
     def test_filled_list(self):
         """user with follows returns 200"""
         """user with follows returns 200"""
-        test_follower = UserModel.objects.create_user(
+        test_follower = User.objects.create_user(
             "TestFollower",
             "TestFollower",
             "test@follower.com",
             "test@follower.com",
             self.USER_PASSWORD,
             self.USER_PASSWORD,
@@ -145,7 +140,7 @@ class FollowsListTests(AuthenticatedUserTestCase):
 
 
     def test_filled_list_search(self):
     def test_filled_list_search(self):
         """follows list is searchable"""
         """follows list is searchable"""
-        test_follower = UserModel.objects.create_user(
+        test_follower = User.objects.create_user(
             "TestFollower",
             "TestFollower",
             "test@follower.com",
             "test@follower.com",
             self.USER_PASSWORD,
             self.USER_PASSWORD,
@@ -214,7 +209,7 @@ class RankListTests(AuthenticatedUserTestCase):
             is_tab=True,
             is_tab=True,
         )
         )
 
 
-        test_user = UserModel.objects.create_user(
+        test_user = User.objects.create_user(
             'Visible',
             'Visible',
             'visible@te.com',
             'visible@te.com',
             'Pass.123',
             'Pass.123',
@@ -255,7 +250,7 @@ class UserRetrieveTests(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         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(
         self.link = reverse(
             'misago:api:user-detail', kwargs={
             'misago:api:user-detail', kwargs={
                 'pk': self.test_user.pk,
                 'pk': self.test_user.pk,
@@ -399,7 +394,7 @@ class UserFollowTests(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
 
 
-        self.other_user = UserModel.objects.create_user("OtherUser", "other@user.com", "pass123")
+        self.other_user = User.objects.create_user("OtherUser", "other@user.com", "pass123")
 
 
         self.link = '/api/users/%s/follow/' % self.other_user.pk
         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.",
             "detail": "You can't add yourself to followed.",
         })
         })
 
 
+    @patch_user_acl({'can_follow_users': 0})
     def test_cant_follow(self):
     def test_cant_follow(self):
         """no permission to follow users"""
         """no permission to follow users"""
-        override_acl(self.user, {
-            'can_follow_users': 0,
-        })
-
         response = self.client.post(self.link)
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
@@ -438,13 +430,13 @@ class UserFollowTests(AuthenticatedUserTestCase):
         response = self.client.post(self.link)
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 200)
         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.followers, 0)
         self.assertEqual(user.following, 1)
         self.assertEqual(user.following, 1)
         self.assertEqual(user.follows.count(), 1)
         self.assertEqual(user.follows.count(), 1)
         self.assertEqual(user.followed_by.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, 1)
         self.assertEqual(followed.followers, 1)
         self.assertEqual(followed.following, 0)
         self.assertEqual(followed.following, 0)
         self.assertEqual(followed.follows.count(), 0)
         self.assertEqual(followed.follows.count(), 0)
@@ -453,13 +445,13 @@ class UserFollowTests(AuthenticatedUserTestCase):
         response = self.client.post(self.link)
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 200)
         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.followers, 0)
         self.assertEqual(user.following, 0)
         self.assertEqual(user.following, 0)
         self.assertEqual(user.follows.count(), 0)
         self.assertEqual(user.follows.count(), 0)
         self.assertEqual(user.followed_by.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.followers, 0)
         self.assertEqual(followed.following, 0)
         self.assertEqual(followed.following, 0)
         self.assertEqual(followed.follows.count(), 0)
         self.assertEqual(followed.follows.count(), 0)
@@ -472,32 +464,29 @@ class UserBanTests(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
 
 
-        self.other_user = UserModel.objects.create_user("OtherUser", "other@user.com", "pass123")
+        self.other_user = User.objects.create_user("OtherUser", "other@user.com", "pass123")
 
 
         self.link = '/api/users/%s/ban/' % self.other_user.pk
         self.link = '/api/users/%s/ban/' % self.other_user.pk
 
 
+    @patch_user_acl({'can_see_ban_details': 0})
     def test_no_permission(self):
     def test_no_permission(self):
         """user has no permission to access ban"""
         """user has no permission to access ban"""
-        override_acl(self.user, {'can_see_ban_details': 0})
-
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             "detail": "You can't see users bans details.",
             "detail": "You can't see users bans details.",
         })
         })
 
 
+    @patch_user_acl({'can_see_ban_details': 1})
     def test_no_ban(self):
     def test_no_ban(self):
         """api returns empty json"""
         """api returns empty json"""
-        override_acl(self.user, {'can_see_ban_details': 1})
-
         response = self.client.get(self.link)
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), {})
         self.assertEqual(response.json(), {})
 
 
+    @patch_user_acl({'can_see_ban_details': 1})
     def test_ban_details(self):
     def test_ban_details(self):
         """api returns ban json"""
         """api returns ban json"""
-        override_acl(self.user, {'can_see_ban_details': 1})
-
         Ban.objects.create(
         Ban.objects.create(
             check_type=Ban.USERNAME,
             check_type=Ban.USERNAME,
             banned_value=self.other_user.username,
             banned_value=self.other_user.username,
@@ -590,7 +579,7 @@ class UserDeleteTests(AuthenticatedUserTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
 
 
-        self.other_user = UserModel.objects.create_user("OtherUser", "other@user.com", "pass123")
+        self.other_user = User.objects.create_user("OtherUser", "other@user.com", "pass123")
 
 
         self.link = '/api/users/%s/delete/' % self.other_user.pk
         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.threads = 1
         self.other_user.save()
         self.other_user.save()
 
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 0,
+        'can_delete_users_with_less_posts_than': 0,
+    })
     def test_delete_no_permission(self):
     def test_delete_no_permission(self):
         """raises 403 error when no permission to delete"""
         """raises 403 error when no permission to delete"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 0,
-                'can_delete_users_with_less_posts_than': 0,
-            }
-        )
-
         response = self.client.post(self.link)
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             'detail': "You can't delete users.",
             'detail': "You can't delete users.",
         })
         })
 
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 0,
+        'can_delete_users_with_less_posts_than': 5,
+    })
     def test_delete_too_many_posts(self):
     def test_delete_too_many_posts(self):
         """raises 403 error when user has too many posts"""
         """raises 403 error when user has too many posts"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 0,
-                'can_delete_users_with_less_posts_than': 5,
-            }
-        )
-
         self.other_user.posts = 6
         self.other_user.posts = 6
         self.other_user.save()
         self.other_user.save()
 
 
@@ -637,15 +620,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete users that made more than 5 posts.",
             'detail': "You can't delete users that made more than 5 posts.",
         })
         })
 
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 5,
+        'can_delete_users_with_less_posts_than': 0,
+    })
     def test_delete_too_old_member(self):
     def test_delete_too_old_member(self):
         """raises 403 error when user is too old"""
         """raises 403 error when user is too old"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 5,
-                'can_delete_users_with_less_posts_than': 0,
-            }
-        )
-
         self.other_user.joined_on -= timedelta(days=6)
         self.other_user.joined_on -= timedelta(days=6)
         self.other_user.save()
         self.other_user.save()
 
 
@@ -656,30 +636,24 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete users that are members for more than 5 days.",
             'detail': "You can't delete users that are members for more than 5 days.",
         })
         })
 
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_self(self):
     def test_delete_self(self):
         """raises 403 error when attempting to delete oneself"""
         """raises 403 error when attempting to delete oneself"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         response = self.client.post('/api/users/%s/delete/' % self.user.pk)
         response = self.client.post('/api/users/%s/delete/' % self.user.pk)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.json(), {
         self.assertEqual(response.json(), {
             'detail': "You can't delete your account.",
             'detail': "You can't delete your account.",
         })
         })
 
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_admin(self):
     def test_delete_admin(self):
         """raises 403 error when attempting to delete admin"""
         """raises 403 error when attempting to delete admin"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         self.other_user.is_staff = True
         self.other_user.is_staff = True
         self.other_user.save()
         self.other_user.save()
 
 
@@ -689,15 +663,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete administrators.",
             'detail': "You can't delete administrators.",
         })
         })
 
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_superadmin(self):
     def test_delete_superadmin(self):
         """raises 403 error when attempting to delete superadmin"""
         """raises 403 error when attempting to delete superadmin"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         self.other_user.is_superuser = True
         self.other_user.is_superuser = True
         self.other_user.save()
         self.other_user.save()
 
 
@@ -707,15 +678,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
             'detail': "You can't delete administrators.",
             'detail': "You can't delete administrators.",
         })
         })
 
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_with_content(self):
     def test_delete_with_content(self):
         """returns 200 and deletes user with content"""
         """returns 200 and deletes user with content"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         response = self.client.post(
         response = self.client.post(
             self.link,
             self.link,
             json.dumps({
             json.dumps({
@@ -725,21 +693,18 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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(Thread.objects.count(), self.threads)
         self.assertEqual(Post.objects.count(), self.posts)
         self.assertEqual(Post.objects.count(), self.posts)
 
 
+    @patch_user_acl({
+        'can_delete_users_newer_than': 10,
+        'can_delete_users_with_less_posts_than': 10,
+    })
     def test_delete_without_content(self):
     def test_delete_without_content(self):
         """returns 200 and deletes user without content"""
         """returns 200 and deletes user without content"""
-        override_acl(
-            self.user, {
-                'can_delete_users_newer_than': 10,
-                'can_delete_users_with_less_posts_than': 10,
-            }
-        )
-
         response = self.client.post(
         response = self.client.post(
             self.link,
             self.link,
             json.dumps({
             json.dumps({
@@ -749,8 +714,8 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         )
         )
         self.assertEqual(response.status_code, 200)
         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(Thread.objects.count(), self.threads + 1)
         self.assertEqual(Post.objects.count(), self.posts + 2)
         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.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.test import TestCase
 from django.test import TestCase
 
 
-from misago.conf import settings
 from misago.users.models import Ban
 from misago.users.models import Ban
 from misago.users.validators import (
 from misago.users.validators import (
     validate_email, validate_email_available, validate_email_banned, validate_gmail_email,
     validate_email, validate_email_available, validate_email_banned, validate_gmail_email,
@@ -56,14 +57,15 @@ class ValidateEmailTests(TestCase):
 class ValidateUsernameTests(TestCase):
 class ValidateUsernameTests(TestCase):
     def test_validate_username(self):
     def test_validate_username(self):
         """validate_username has no crashes"""
         """validate_username has no crashes"""
-        validate_username('LeBob')
+        settings = Mock(username_length_min=1, username_length_max=5)
+        validate_username(settings, 'LeBob')
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
-            validate_username('*')
+            validate_username(settings, '*')
 
 
 
 
 class ValidateUsernameAvailableTests(TestCase):
 class ValidateUsernameAvailableTests(TestCase):
     def setUp(self):
     def setUp(self):
-        self.test_user = UserModel.objects.create_user('EricTheFish', 'eric@test.com', 'pass123')
+        self.test_user = UserModel.objects.create_user('EricTheFish', 'eric@test.com')
 
 
     def test_valid_name(self):
     def test_valid_name(self):
         """validate_username_available allows available names"""
         """validate_username_available allows available names"""
@@ -117,15 +119,17 @@ class ValidateUsernameContentTests(TestCase):
 class ValidateUsernameLengthTests(TestCase):
 class ValidateUsernameLengthTests(TestCase):
     def test_valid_name(self):
     def test_valid_name(self):
         """validate_username_length allows valid names"""
         """validate_username_length allows valid names"""
-        validate_username_length('a' * settings.username_length_min)
-        validate_username_length('a' * settings.username_length_max)
+        settings = Mock(username_length_min=1, username_length_max=5)
+        validate_username_length(settings, 'a' * settings.username_length_min)
+        validate_username_length(settings, 'a' * settings.username_length_max)
 
 
     def test_invalid_name(self):
     def test_invalid_name(self):
         """validate_username_length disallows invalid names"""
         """validate_username_length disallows invalid names"""
+        settings = Mock(username_length_min=1, username_length_max=5)
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
-            validate_username_length('a' * (settings.username_length_min - 1))
+            validate_username_length(settings, 'a' * (settings.username_length_min - 1))
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
-            validate_username_length('a' * (settings.username_length_max + 1))
+            validate_username_length(settings, 'a' * (settings.username_length_max + 1))
 
 
 
 
 class ValidateGmailEmailTests(TestCase):
 class ValidateGmailEmailTests(TestCase):

+ 34 - 12
misago/users/testutils.py

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

+ 18 - 16
misago/users/validators.py

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

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

@@ -7,7 +7,7 @@ from misago.users.serializers import UserCardSerializer
 class ActivePosters(object):
 class ActivePosters(object):
     def __init__(self, request):
     def __init__(self, request):
         ranking = get_active_posters_ranking()
         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.count = ranking['users_count']
         self.tracked_period = settings.MISAGO_RANKING_LENGTH
         self.tracked_period = settings.MISAGO_RANKING_LENGTH

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

@@ -21,7 +21,7 @@ class Followers(object):
                 raise Http404()
                 raise Http404()
 
 
         list_page = paginate(queryset, page, settings.MISAGO_USERS_PER_PAGE, 4)
         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.users = list_page.object_list
         self.paginator = pagination_dict(list_page)
         self.paginator = pagination_dict(list_page)

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

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

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

@@ -16,7 +16,7 @@ class RankUsers(object):
             queryset = queryset.filter(is_active=True)
             queryset = queryset.filter(is_active=True)
 
 
         list_page = paginate(queryset, page, settings.MISAGO_USERS_PER_PAGE, 4)
         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.users = list_page.object_list
         self.paginator = pagination_dict(list_page)
         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.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.core.shortcuts import paginate, pagination_dict
 from misago.threads.permissions import exclude_invisible_threads
 from misago.threads.permissions import exclude_invisible_threads
@@ -33,8 +33,8 @@ class UserThreads(object):
 
 
         add_categories_to_items(root_category.unwrap(), threads_categories, posts + threads)
         add_categories_to_items(root_category.unwrap(), threads_categories, posts + threads)
 
 
-        add_acl(request.user, threads)
-        add_acl(request.user, posts)
+        add_acl_to_obj(request.user_acl, threads)
+        add_acl_to_obj(request.user_acl, posts)
 
 
         self._user = request.user
         self._user = request.user
 
 
@@ -42,7 +42,7 @@ class UserThreads(object):
         self.paginator = paginator
         self.paginator = paginator
 
 
     def get_threads_queryset(self, request, threads_categories, profile):
     def get_threads_queryset(self, request, threads_categories, profile):
-        return exclude_invisible_threads(request.user, threads_categories, profile.thread_set)
+        return exclude_invisible_threads(request.user_acl, threads_categories, profile.thread_set)
 
 
     def get_posts_queryset(self, user, profile, threads_queryset):
     def get_posts_queryset(self, user, profile, threads_queryset):
         return profile.post_set.select_related('thread', 'poster').filter(
         return profile.post_set.select_related('thread', 'poster').filter(

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

+ 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.")
             message = _("%(user)s, your link is invalid. Please try again or request new link.")
             raise ResetError(message % {'user': requesting_user.username})
             raise ResetError(message % {'user': requesting_user.username})
 
 
-        ban = get_user_ban(requesting_user)
+        ban = get_user_ban(requesting_user, request.cache_versions)
         if ban:
         if ban:
             raise Banned(ban)
             raise Banned(ban)
     except ResetError as e:
     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):
 class ListView(View):
     def get(self, request, *args, **kwargs):
     def get(self, request, *args, **kwargs):
-        allow_browse_users_list(request.user)
+        allow_browse_users_list(request.user_acl)
 
 
         context_data = self.get_context_data(request, *args, **kwargs)
         context_data = self.get_context_data(request, *args, **kwargs)
 
 
@@ -62,7 +62,7 @@ class ListView(View):
 
 
 
 
 def landing(request):
 def landing(request):
-    allow_browse_users_list(request.user)
+    allow_browse_users_list(request.user_acl)
     return redirect(users_list.get_default_link())
     return redirect(users_list.get_default_link())
 
 
 
 

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