Browse Source

Merge pull request #1153 from rafalp/add-black-pylint

Improve code formatting
Rafał Pitoń 6 years ago
parent
commit
e752dbca42
636 changed files with 20499 additions and 22573 deletions
  1. 0 11
      .style.yapf
  2. 23 14
      .travis.yml
  3. 153 186
      devproject/settings.py
  4. 24 36
      devproject/test_settings.py
  5. 12 17
      devproject/urls.py
  6. 1 1
      misago/__init__.py
  7. 1 1
      misago/acl/__init__.py
  8. 17 17
      misago/acl/admin.py
  9. 2 2
      misago/acl/apps.py
  10. 1 1
      misago/acl/buildacl.py
  11. 1 1
      misago/acl/cache.py
  12. 6 11
      misago/acl/forms.py
  13. 1 0
      misago/acl/middleware.py
  14. 17 12
      misago/acl/migrations/0001_initial.py
  15. 1 4
      misago/acl/migrations/0002_acl_version_tracker.py
  16. 117 143
      misago/acl/migrations/0003_default_roles.py
  17. 3 5
      misago/acl/migrations/0004_cache_version.py
  18. 1 1
      misago/acl/objectacl.py
  19. 4 6
      misago/acl/panels.py
  20. 1 1
      misago/acl/providers.py
  21. 1 2
      misago/acl/test.py
  22. 21 27
      misago/acl/tests/test_acl_algebra.py
  23. 16 14
      misago/acl/tests/test_getting_user_acl.py
  24. 1 1
      misago/acl/tests/test_mock_role_admin_form_data.py
  25. 4 2
      misago/acl/tests/test_patching_user_acl.py
  26. 2 5
      misago/acl/tests/test_providers.py
  27. 43 59
      misago/acl/tests/test_roleadmin_views.py
  28. 1 1
      misago/acl/tests/test_user_acl_context_processor.py
  29. 11 15
      misago/acl/views.py
  30. 4 4
      misago/admin/__init__.py
  31. 3 3
      misago/admin/admin.py
  32. 2 2
      misago/admin/apps.py
  33. 5 5
      misago/admin/auth.py
  34. 4 4
      misago/admin/discoverer.py
  35. 7 10
      misago/admin/forms.py
  36. 51 45
      misago/admin/hierarchy.py
  37. 2 2
      misago/admin/middleware.py
  38. 2 6
      misago/admin/templatetags/misago_admin_form.py
  39. 38 29
      misago/admin/tests/test_admin_form_templatetags.py
  40. 10 10
      misago/admin/tests/test_admin_hierarchy.py
  41. 29 20
      misago/admin/tests/test_admin_index.py
  42. 43 57
      misago/admin/tests/test_admin_views.py
  43. 9 9
      misago/admin/tests/test_forms.py
  44. 3 6
      misago/admin/testutils.py
  45. 17 22
      misago/admin/urlpatterns.py
  46. 3 3
      misago/admin/urls.py
  47. 8 8
      misago/admin/views/__init__.py
  48. 11 11
      misago/admin/views/auth.py
  49. 10 7
      misago/admin/views/errorpages.py
  50. 1 1
      misago/admin/views/generic/__init__.py
  51. 4 4
      misago/admin/views/generic/base.py
  52. 12 12
      misago/admin/views/generic/formsbuttons.py
  53. 101 99
      misago/admin/views/generic/list.py
  54. 1 0
      misago/admin/views/generic/mixin.py
  55. 28 29
      misago/admin/views/index.py
  56. 1 1
      misago/cache/__init__.py
  57. 2 2
      misago/cache/apps.py
  58. 1 1
      misago/cache/management/commands/invalidateversionedcaches.py
  59. 1 0
      misago/cache/middleware.py
  60. 13 6
      misago/cache/migrations/0001_initial.py
  61. 5 3
      misago/cache/operations.py
  62. 0 1
      misago/cache/test.py
  63. 1 1
      misago/cache/tests/conftest.py
  64. 14 14
      misago/cache/tests/test_getting_cache_versions.py
  65. 1 1
      misago/cache/tests/test_invalidate_caches_management_command.py
  66. 8 6
      misago/cache/tests/test_invalidating_caches.py
  67. 2 2
      misago/cache/versions.py
  68. 1 1
      misago/categories/__init__.py
  69. 54 35
      misago/categories/admin.py
  70. 3 1
      misago/categories/api.py
  71. 2 2
      misago/categories/apps.py
  72. 2 2
      misago/categories/constants.py
  73. 59 49
      misago/categories/forms.py
  74. 2 1
      misago/categories/management/commands/fixcategoriestree.py
  75. 3 2
      misago/categories/management/commands/prunecategories.py
  76. 4 4
      misago/categories/management/commands/synchronizecategories.py
  77. 102 70
      misago/categories/migrations/0001_initial.py
  78. 9 13
      misago/categories/migrations/0002_default_categories.py
  79. 76 95
      misago/categories/migrations/0003_categories_roles.py
  80. 8 8
      misago/categories/migrations/0004_category_last_thread.py
  81. 7 9
      misago/categories/migrations/0005_auto_20170303_2027.py
  82. 8 12
      misago/categories/migrations/0006_moderation_queue_roles.py
  83. 15 19
      misago/categories/migrations/0007_best_answers_roles.py
  84. 23 27
      misago/categories/models.py
  85. 29 34
      misago/categories/permissions.py
  86. 39 36
      misago/categories/serializers.py
  87. 1 2
      misago/categories/signals.py
  88. 304 291
      misago/categories/tests/test_categories_admin_views.py
  89. 7 12
      misago/categories/tests/test_category_model.py
  90. 40 24
      misago/categories/tests/test_fixcategoriestree.py
  91. 143 156
      misago/categories/tests/test_permissions_admin_views.py
  92. 6 16
      misago/categories/tests/test_prunecategories.py
  93. 1 1
      misago/categories/tests/test_synchronizecategories.py
  94. 27 55
      misago/categories/tests/test_utils.py
  95. 14 14
      misago/categories/tests/test_views.py
  96. 4 5
      misago/categories/urls/__init__.py
  97. 1 1
      misago/categories/urls/api.py
  98. 5 5
      misago/categories/utils.py
  99. 23 23
      misago/categories/views/categoriesadmin.py
  100. 15 9
      misago/categories/views/categorieslist.py
  101. 47 51
      misago/categories/views/permsadmin.py
  102. 1 1
      misago/conf/__init__.py
  103. 7 7
      misago/conf/admin.py
  104. 2 2
      misago/conf/apps.py
  105. 30 26
      misago/conf/context_processors.py
  106. 1 1
      misago/conf/debugtoolbar.py
  107. 126 119
      misago/conf/defaults.py
  108. 6 6
      misago/conf/dynamicsettings.py
  109. 53 53
      misago/conf/forms.py
  110. 10 9
      misago/conf/hydrators.py
  111. 2 0
      misago/conf/middleware.py
  112. 37 29
      misago/conf/migrations/0001_initial.py
  113. 2 6
      misago/conf/migrations/0002_cache_version.py
  114. 22 20
      misago/conf/migrationutils.py
  115. 3 3
      misago/conf/models.py
  116. 2 1
      misago/conf/test.py
  117. 9 13
      misago/conf/tests/test_admin_views.py
  118. 2 2
      misago/conf/tests/test_context_processors.py
  119. 2 2
      misago/conf/tests/test_dynamic_settings_middleware.py
  120. 14 12
      misago/conf/tests/test_getting_dynamic_settings_values.py
  121. 1 1
      misago/conf/tests/test_getting_static_settings_values.py
  122. 23 23
      misago/conf/tests/test_hydrators.py
  123. 38 48
      misago/conf/tests/test_migrationutils.py
  124. 12 25
      misago/conf/tests/test_models.py
  125. 20 16
      misago/conf/views.py
  126. 1 5
      misago/conftest.py
  127. 7 8
      misago/core/__init__.py
  128. 5 5
      misago/core/admin.py
  129. 24 36
      misago/core/apipatch.py
  130. 23 21
      misago/core/apirouter.py
  131. 2 2
      misago/core/apps.py
  132. 1 1
      misago/core/cache.py
  133. 14 18
      misago/core/context_processors.py
  134. 3 2
      misago/core/decorators.py
  135. 27 28
      misago/core/errorpages.py
  136. 6 9
      misago/core/exceptionhandler.py
  137. 4 0
      misago/core/exceptions.py
  138. 16 13
      misago/core/mail.py
  139. 7 7
      misago/core/management/progressbar.py
  140. 12 8
      misago/core/migrations/0001_initial.py
  141. 52 59
      misago/core/migrations/0002_basic_settings.py
  142. 3 3
      misago/core/momentjs.py
  143. 37 25
      misago/core/page.py
  144. 29 31
      misago/core/pgutils.py
  145. 9 9
      misago/core/serializers.py
  146. 14 8
      misago/core/setup.py
  147. 28 24
      misago/core/shortcuts.py
  148. 1 1
      misago/core/slugify.py
  149. 4 4
      misago/core/templatetags/misago_absoluteurl.py
  150. 4 4
      misago/core/templatetags/misago_capture.py
  151. 4 4
      misago/core/templatetags/misago_pagetitle.py
  152. 1 1
      misago/core/templatetags/misago_stringutils.py
  153. 1 1
      misago/core/testproject/searchfilters.py
  154. 1 3
      misago/core/testproject/serializers.py
  155. 57 38
      misago/core/testproject/urls.py
  156. 2 2
      misago/core/testproject/urlswitherrorhandlers.py
  157. 2 2
      misago/core/testproject/validators.py
  158. 7 14
      misago/core/testproject/views.py
  159. 104 168
      misago/core/tests/test_apipatch.py
  160. 5 5
      misago/core/tests/test_checks.py
  161. 2 3
      misago/core/tests/test_chunk_queryset.py
  162. 1 1
      misago/core/tests/test_common_middleware_redirect.py
  163. 31 38
      misago/core/tests/test_context_processors.py
  164. 4 4
      misago/core/tests/test_decorators.py
  165. 28 21
      misago/core/tests/test_errorpages.py
  166. 1 1
      misago/core/tests/test_exceptionhandler_middleware.py
  167. 8 5
      misago/core/tests/test_exceptionhandlers.py
  168. 11 7
      misago/core/tests/test_jsi18n.py
  169. 15 11
      misago/core/tests/test_mail.py
  170. 18 9
      misago/core/tests/test_momentjs.py
  171. 8 15
      misago/core/tests/test_page.py
  172. 37 56
      misago/core/tests/test_pgpartialindex.py
  173. 33 33
      misago/core/tests/test_serializers.py
  174. 7 7
      misago/core/tests/test_setup.py
  175. 91 123
      misago/core/tests/test_shortcuts.py
  176. 39 45
      misago/core/tests/test_templatetags.py
  177. 135 116
      misago/core/tests/test_utils.py
  178. 8 8
      misago/core/tests/test_validators.py
  179. 7 7
      misago/core/tests/test_views.py
  180. 31 32
      misago/core/utils.py
  181. 4 2
      misago/core/validators.py
  182. 1 1
      misago/core/views.py
  183. 1 1
      misago/faker/__init__.py
  184. 2 2
      misago/faker/apps.py
  185. 2 2
      misago/faker/englishcorpus.py
  186. 26 26
      misago/faker/management/commands/createfakebans.py
  187. 11 15
      misago/faker/management/commands/createfakecategories.py
  188. 7 7
      misago/faker/management/commands/createfakefollowers.py
  189. 22 22
      misago/faker/management/commands/createfakethreads.py
  190. 8 12
      misago/faker/management/commands/createfakeusers.py
  191. 1 1
      misago/legal/__init__.py
  192. 24 16
      misago/legal/admin.py
  193. 4 4
      misago/legal/api.py
  194. 2 2
      misago/legal/apps.py
  195. 24 23
      misago/legal/context_processors.py
  196. 12 15
      misago/legal/forms.py
  197. 63 82
      misago/legal/migrations/0001_initial.py
  198. 87 22
      misago/legal/migrations/0002_agreement_useragreement.py
  199. 34 35
      misago/legal/migrations/0003_create_agreements_from_settings.py
  200. 16 19
      misago/legal/models.py
  201. 3 5
      misago/legal/signals.py
  202. 102 121
      misago/legal/tests/test_admin_views.py
  203. 28 31
      misago/legal/tests/test_api.py
  204. 115 99
      misago/legal/tests/test_context_processors.py
  205. 13 19
      misago/legal/tests/test_required_agreement.py
  206. 31 41
      misago/legal/tests/test_utils.py
  207. 20 20
      misago/legal/tests/test_views.py
  208. 3 3
      misago/legal/urls/__init__.py
  209. 1 1
      misago/legal/urls/api.py
  210. 12 15
      misago/legal/utils.py
  211. 1 1
      misago/legal/views/__init__.py
  212. 24 20
      misago/legal/views/admin.py
  213. 6 8
      misago/legal/views/legal.py
  214. 1 1
      misago/markup/__init__.py
  215. 5 11
      misago/markup/api.py
  216. 2 2
      misago/markup/apps.py
  217. 28 25
      misago/markup/bbcode/blocks.py
  218. 16 10
      misago/markup/bbcode/inline.py
  219. 1 1
      misago/markup/checksums.py
  220. 3 3
      misago/markup/context_processors.py
  221. 5 4
      misago/markup/finalise.py
  222. 6 10
      misago/markup/flavours.py
  223. 6 4
      misago/markup/md/shortimgs.py
  224. 2 2
      misago/markup/md/striketrough.py
  225. 10 10
      misago/markup/mentions.py
  226. 86 88
      misago/markup/parser.py
  227. 6 6
      misago/markup/pipeline.py
  228. 1 1
      misago/markup/templatetags/misago_editor.py
  229. 46 39
      misago/markup/tests/test_api.py
  230. 8 8
      misago/markup/tests/test_finalise.py
  231. 31 24
      misago/markup/tests/test_mentions.py
  232. 85 75
      misago/markup/tests/test_parser.py
  233. 1 1
      misago/markup/urls.py
  234. 1 1
      misago/readtracker/__init__.py
  235. 2 2
      misago/readtracker/apps.py
  236. 16 9
      misago/readtracker/categoriestracker.py
  237. 1 3
      misago/readtracker/dates.py
  238. 39 26
      misago/readtracker/migrations/0001_initial.py
  239. 46 11
      misago/readtracker/migrations/0002_postread.py
  240. 9 15
      misago/readtracker/migrations/0003_migrate_reads_to_posts.py
  241. 8 29
      misago/readtracker/migrations/0004_auto_20171015_2010.py
  242. 4 16
      misago/readtracker/models.py
  243. 3 7
      misago/readtracker/poststracker.py
  244. 6 12
      misago/readtracker/signals.py
  245. 9 29
      misago/readtracker/tests/test_categoriestracker.py
  246. 11 5
      misago/readtracker/tests/test_clearreadtracker.py
  247. 9 3
      misago/readtracker/tests/test_dates.py
  248. 2 2
      misago/readtracker/tests/test_poststracker.py
  249. 6 19
      misago/readtracker/tests/test_threadstracker.py
  250. 7 6
      misago/readtracker/threadstracker.py
  251. 1 1
      misago/search/__init__.py
  252. 14 12
      misago/search/api.py
  253. 2 2
      misago/search/apps.py
  254. 19 13
      misago/search/context_processors.py
  255. 4 2
      misago/search/permissions.py
  256. 1 1
      misago/search/searchprovider.py
  257. 15 18
      misago/search/tests/test_api.py
  258. 13 7
      misago/search/tests/test_searchproviders.py
  259. 15 19
      misago/search/tests/test_views.py
  260. 2 2
      misago/search/urls/__init__.py
  261. 2 2
      misago/search/urls/api.py
  262. 6 7
      misago/search/views.py
  263. 1 1
      misago/threads/__init__.py
  264. 26 20
      misago/threads/admin.py
  265. 11 18
      misago/threads/anonymize.py
  266. 26 17
      misago/threads/api/attachments.py
  267. 14 24
      misago/threads/api/pollvotecreateendpoint.py
  268. 10 10
      misago/threads/api/postendpoints/delete.py
  269. 12 12
      misago/threads/api/postendpoints/edits.py
  270. 3 2
      misago/threads/api/postendpoints/likes.py
  271. 7 16
      misago/threads/api/postendpoints/merge.py
  272. 7 11
      misago/threads/api/postendpoints/move.py
  273. 5 5
      misago/threads/api/postendpoints/patch_event.py
  274. 28 34
      misago/threads/api/postendpoints/patch_post.py
  275. 1 1
      misago/threads/api/postendpoints/read.py
  276. 14 16
      misago/threads/api/postendpoints/split.py
  277. 16 13
      misago/threads/api/postingendpoint/__init__.py
  278. 28 26
      misago/threads/api/postingendpoint/attachments.py
  279. 5 3
      misago/threads/api/postingendpoint/category.py
  280. 2 2
      misago/threads/api/postingendpoint/close.py
  281. 12 7
      misago/threads/api/postingendpoint/emailnotification.py
  282. 6 4
      misago/threads/api/postingendpoint/floodprotection.py
  283. 3 3
      misago/threads/api/postingendpoint/hide.py
  284. 2 2
      misago/threads/api/postingendpoint/mentions.py
  285. 8 4
      misago/threads/api/postingendpoint/moderationqueue.py
  286. 16 13
      misago/threads/api/postingendpoint/participants.py
  287. 2 2
      misago/threads/api/postingendpoint/pin.py
  288. 3 3
      misago/threads/api/postingendpoint/protect.py
  289. 8 2
      misago/threads/api/postingendpoint/recordedit.py
  290. 19 19
      misago/threads/api/postingendpoint/reply.py
  291. 6 7
      misago/threads/api/postingendpoint/subscribe.py
  292. 1 2
      misago/threads/api/postingendpoint/syncprivatethreads.py
  293. 7 7
      misago/threads/api/postingendpoint/updatestats.py
  294. 10 17
      misago/threads/api/threadendpoints/delete.py
  295. 18 14
      misago/threads/api/threadendpoints/editor.py
  296. 13 6
      misago/threads/api/threadendpoints/list.py
  297. 51 57
      misago/threads/api/threadendpoints/merge.py
  298. 99 74
      misago/threads/api/threadendpoints/patch.py
  299. 21 17
      misago/threads/api/threadpoll.py
  300. 54 54
      misago/threads/api/threadposts.py
  301. 28 22
      misago/threads/api/threads.py
  302. 2 2
      misago/threads/apps.py
  303. 9 7
      misago/threads/context_processors.py
  304. 2 2
      misago/threads/events.py
  305. 36 32
      misago/threads/forms.py
  306. 4 5
      misago/threads/management/commands/clearattachments.py
  307. 3 3
      misago/threads/management/commands/rebuildpostssearch.py
  308. 1 1
      misago/threads/management/commands/updatepostschecksums.py
  309. 11 11
      misago/threads/mergeconflict.py
  310. 10 9
      misago/threads/middleware.py
  311. 410 321
      misago/threads/migrations/0001_initial.py
  312. 39 53
      misago/threads/migrations/0002_threads_settings.py
  313. 57 58
      misago/threads/migrations/0003_attachment_types.py
  314. 39 52
      misago/threads/migrations/0004_update_settings.py
  315. 5 5
      misago/threads/migrations/0005_index_search_document.py
  316. 55 21
      misago/threads/migrations/0006_redo_partial_indexes.py
  317. 4 6
      misago/threads/migrations/0007_auto_20171008_0131.py
  318. 27 15
      misago/threads/migrations/0008_auto_20180310_2234.py
  319. 8 6
      misago/threads/migrations/0009_auto_20180326_0010.py
  320. 7 27
      misago/threads/migrations/0010_auto_20180609_1523.py
  321. 21 21
      misago/threads/models/attachment.py
  322. 8 4
      misago/threads/models/attachmenttype.py
  323. 18 25
      misago/threads/models/poll.py
  324. 5 19
      misago/threads/models/pollvote.py
  325. 39 45
      misago/threads/models/post.py
  326. 5 10
      misago/threads/models/postedit.py
  327. 5 17
      misago/threads/models/postlike.py
  328. 4 10
      misago/threads/models/subscription.py
  329. 28 49
      misago/threads/models/thread.py
  330. 5 9
      misago/threads/models/threadparticipant.py
  331. 23 19
      misago/threads/moderation/posts.py
  332. 41 42
      misago/threads/moderation/threads.py
  333. 41 42
      misago/threads/participants.py
  334. 16 14
      misago/threads/permissions/attachments.py
  335. 102 100
      misago/threads/permissions/bestanswers.py
  336. 84 84
      misago/threads/permissions/polls.py
  337. 114 93
      misago/threads/permissions/privatethreads.py
  338. 528 472
      misago/threads/permissions/threads.py
  339. 22 22
      misago/threads/search.py
  340. 15 18
      misago/threads/serializers/attachment.py
  341. 6 16
      misago/threads/serializers/feed.py
  342. 177 140
      misago/threads/serializers/moderation.py
  343. 52 72
      misago/threads/serializers/poll.py
  344. 12 26
      misago/threads/serializers/pollvote.py
  345. 60 60
      misago/threads/serializers/post.py
  346. 4 18
      misago/threads/serializers/postedit.py
  347. 7 19
      misago/threads/serializers/postlike.py
  348. 72 78
      misago/threads/serializers/thread.py
  349. 2 2
      misago/threads/serializers/threadparticipant.py
  350. 45 61
      misago/threads/signals.py
  351. 2 2
      misago/threads/subscriptions.py
  352. 14 11
      misago/threads/templatetags/misago_poststags.py
  353. 25 25
      misago/threads/test.py
  354. 89 67
      misago/threads/tests/test_anonymize_data.py
  355. 27 47
      misago/threads/tests/test_attachmentadmin_views.py
  356. 128 186
      misago/threads/tests/test_attachments_api.py
  357. 45 49
      misago/threads/tests/test_attachments_middleware.py
  358. 75 81
      misago/threads/tests/test_attachmenttypeadmin_views.py
  359. 33 47
      misago/threads/tests/test_attachmentview.py
  360. 14 12
      misago/threads/tests/test_clearattachments.py
  361. 10 11
      misago/threads/tests/test_delete_user_likes.py
  362. 17 36
      misago/threads/tests/test_emailnotification_middleware.py
  363. 10 12
      misago/threads/tests/test_events.py
  364. 10 19
      misago/threads/tests/test_floodprotection.py
  365. 2 2
      misago/threads/tests/test_floodprotection_middleware.py
  366. 56 43
      misago/threads/tests/test_gotoviews.py
  367. 148 139
      misago/threads/tests/test_mergeconflict.py
  368. 17 32
      misago/threads/tests/test_paginator.py
  369. 42 48
      misago/threads/tests/test_participants.py
  370. 39 60
      misago/threads/tests/test_post_mentions.py
  371. 13 13
      misago/threads/tests/test_post_model.py
  372. 285 368
      misago/threads/tests/test_privatethread_patch_api.py
  373. 8 6
      misago/threads/tests/test_privatethread_reply_api.py
  374. 122 120
      misago/threads/tests/test_privatethread_start_api.py
  375. 49 55
      misago/threads/tests/test_privatethreads_api.py
  376. 1 1
      misago/threads/tests/test_privatethreads_lists.py
  377. 55 62
      misago/threads/tests/test_search.py
  378. 23 37
      misago/threads/tests/test_subscription_middleware.py
  379. 1 4
      misago/threads/tests/test_subscriptions.py
  380. 3 3
      misago/threads/tests/test_sync_unread_private_threads.py
  381. 115 167
      misago/threads/tests/test_thread_bulkpatch_api.py
  382. 87 141
      misago/threads/tests/test_thread_editreply_api.py
  383. 280 260
      misago/threads/tests/test_thread_merge_api.py
  384. 18 21
      misago/threads/tests/test_thread_model.py
  385. 806 1213
      misago/threads/tests/test_thread_patch_api.py
  386. 10 11
      misago/threads/tests/test_thread_poll_api.py
  387. 78 121
      misago/threads/tests/test_thread_pollcreate_api.py
  388. 30 40
      misago/threads/tests/test_thread_polldelete_api.py
  389. 185 254
      misago/threads/tests/test_thread_polledit_api.py
  390. 105 111
      misago/threads/tests/test_thread_pollvotes_api.py
  391. 114 118
      misago/threads/tests/test_thread_postbulkdelete_api.py
  392. 93 137
      misago/threads/tests/test_thread_postbulkpatch_api.py
  393. 111 117
      misago/threads/tests/test_thread_postdelete_api.py
  394. 34 37
      misago/threads/tests/test_thread_postedits_api.py
  395. 54 47
      misago/threads/tests/test_thread_postlikes_api.py
  396. 242 220
      misago/threads/tests/test_thread_postmerge_api.py
  397. 210 184
      misago/threads/tests/test_thread_postmove_api.py
  398. 253 550
      misago/threads/tests/test_thread_postpatch_api.py
  399. 16 22
      misago/threads/tests/test_thread_postread_api.py
  400. 311 259
      misago/threads/tests/test_thread_postsplit_api.py
  401. 52 64
      misago/threads/tests/test_thread_reply_api.py
  402. 174 153
      misago/threads/tests/test_thread_start_api.py
  403. 11 7
      misago/threads/tests/test_threadparticipant_model.py
  404. 44 52
      misago/threads/tests/test_threads_api.py
  405. 76 79
      misago/threads/tests/test_threads_bulkdelete_api.py
  406. 204 210
      misago/threads/tests/test_threads_editor_api.py
  407. 424 348
      misago/threads/tests/test_threads_merge_api.py
  408. 22 23
      misago/threads/tests/test_threads_moderation.py
  409. 382 455
      misago/threads/tests/test_threadslists.py
  410. 129 113
      misago/threads/tests/test_threadview.py
  411. 37 22
      misago/threads/tests/test_treesmap.py
  412. 1 1
      misago/threads/tests/test_updatepostschecksums.py
  413. 88 121
      misago/threads/tests/test_utils.py
  414. 40 42
      misago/threads/tests/test_validate_post.py
  415. 2 6
      misago/threads/tests/test_validators.py
  416. 79 98
      misago/threads/testutils.py
  417. 2 1
      misago/threads/threadtypes/__init__.py
  418. 29 80
      misago/threads/threadtypes/privatethread.py
  419. 40 120
      misago/threads/threadtypes/thread.py
  420. 1 0
      misago/threads/threadtypes/treesmap.py
  421. 78 42
      misago/threads/urls/__init__.py
  422. 9 7
      misago/threads/urls/api.py
  423. 10 11
      misago/threads/utils.py
  424. 7 20
      misago/threads/validators.py
  425. 26 13
      misago/threads/viewmodels/category.py
  426. 5 5
      misago/threads/viewmodels/post.py
  427. 26 21
      misago/threads/viewmodels/posts.py
  428. 28 23
      misago/threads/viewmodels/thread.py
  429. 71 60
      misago/threads/viewmodels/threads.py
  430. 24 22
      misago/threads/views/admin/attachments.py
  431. 6 6
      misago/threads/views/admin/attachmenttypes.py
  432. 5 5
      misago/threads/views/attachment.py
  433. 22 18
      misago/threads/views/goto.py
  434. 11 8
      misago/threads/views/list.py
  435. 12 12
      misago/threads/views/thread.py
  436. 21 25
      misago/urls.py
  437. 1 1
      misago/users/__init__.py
  438. 9 13
      misago/users/activepostersranking.py
  439. 79 61
      misago/users/admin.py
  440. 48 61
      misago/users/api/auth.py
  441. 6 4
      misago/users/api/captcha.py
  442. 5 9
      misago/users/api/mention.py
  443. 1 1
      misago/users/api/ranks.py
  444. 5 3
      misago/users/api/rest_permissions.py
  445. 62 75
      misago/users/api/userendpoints/avatar.py
  446. 7 11
      misago/users/api/userendpoints/changeemail.py
  447. 8 12
      misago/users/api/userendpoints/changepassword.py
  448. 15 17
      misago/users/api/userendpoints/create.py
  449. 7 14
      misago/users/api/userendpoints/editdetails.py
  450. 5 7
      misago/users/api/userendpoints/list.py
  451. 13 19
      misago/users/api/userendpoints/signature.py
  452. 34 48
      misago/users/api/userendpoints/username.py
  453. 18 16
      misago/users/api/usernamechanges.py
  454. 99 78
      misago/users/api/users.py
  455. 45 47
      misago/users/apps.py
  456. 1 3
      misago/users/audittrail.py
  457. 3 3
      misago/users/authbackends.py
  458. 4 4
      misago/users/avatars/__init__.py
  459. 19 5
      misago/users/avatars/dynamic.py
  460. 15 13
      misago/users/avatars/gallery.py
  461. 1 1
      misago/users/avatars/gravatar.py
  462. 12 9
      misago/users/avatars/store.py
  463. 25 25
      misago/users/avatars/uploaded.py
  464. 17 19
      misago/users/bans.py
  465. 8 12
      misago/users/captcha.py
  466. 1 1
      misago/users/constants.py
  467. 22 20
      misago/users/context_processors.py
  468. 15 11
      misago/users/credentialchange.py
  469. 3 4
      misago/users/datadownloads/__init__.py
  470. 11 11
      misago/users/datadownloads/dataarchive.py
  471. 3 3
      misago/users/decorators.py
  472. 40 19
      misago/users/djangoadmin.py
  473. 193 210
      misago/users/forms/admin.py
  474. 50 36
      misago/users/forms/auth.py
  475. 16 12
      misago/users/forms/register.py
  476. 1 1
      misago/users/management/commands/buildactivepostersranking.py
  477. 31 29
      misago/users/management/commands/createsuperuser.py
  478. 7 7
      misago/users/management/commands/deleteinactiveusers.py
  479. 1 1
      misago/users/management/commands/deletemarkedusers.py
  480. 6 13
      misago/users/management/commands/deleteprofilefield.py
  481. 2 3
      misago/users/management/commands/expireuserdatadownloads.py
  482. 1 1
      misago/users/management/commands/listusedprofilefields.py
  483. 18 10
      misago/users/management/commands/prepareuserdatadownloads.py
  484. 4 2
      misago/users/management/commands/removeoldips.py
  485. 3 7
      misago/users/management/commands/synchronizeusers.py
  486. 6 7
      misago/users/middleware.py
  487. 264 196
      misago/users/migrations/0001_initial.py
  488. 176 201
      misago/users/migrations/0002_users_settings.py
  489. 2 2
      misago/users/migrations/0003_bans_version_tracker.py
  490. 6 8
      misago/users/migrations/0004_default_ranks.py
  491. 12 17
      misago/users/migrations/0005_dj_19_update.py
  492. 104 114
      misago/users/migrations/0006_update_settings.py
  493. 11 12
      misago/users/migrations/0007_auto_20170219_1639.py
  494. 7 9
      misago/users/migrations/0008_ban_registration_only.py
  495. 13 7
      misago/users/migrations/0009_redo_partial_indexes.py
  496. 3 5
      misago/users/migrations/0010_user_profile_fields.py
  497. 9 7
      misago/users/migrations/0011_auto_20180331_2208.py
  498. 36 13
      misago/users/migrations/0012_audittrail.py
  499. 5 13
      misago/users/migrations/0013_auto_20180609_1523.py
  500. 58 16
      misago/users/migrations/0014_datadownload.py
  501. 7 7
      misago/users/migrations/0015_user_agreements.py
  502. 3 5
      misago/users/migrations/0016_cache_version.py
  503. 6 14
      misago/users/migrations/0017_move_bans_to_cache_version.py
  504. 1 3
      misago/users/models/activityranking.py
  505. 2 2
      misago/users/models/audittrail.py
  506. 1 2
      misago/users/models/avatargallery.py
  507. 26 30
      misago/users/models/ban.py
  508. 11 8
      misago/users/models/datadownload.py
  509. 1 1
      misago/users/models/online.py
  510. 5 5
      misago/users/models/rank.py
  511. 47 72
      misago/users/models/user.py
  512. 9 9
      misago/users/namechanges.py
  513. 3 3
      misago/users/online/tracker.py
  514. 27 26
      misago/users/online/utils.py
  515. 3 3
      misago/users/pages.py
  516. 10 10
      misago/users/permissions/account.py
  517. 4 11
      misago/users/permissions/decorators.py
  518. 14 14
      misago/users/permissions/delete.py
  519. 43 44
      misago/users/permissions/moderation.py
  520. 32 28
      misago/users/permissions/profiles.py
  521. 27 30
      misago/users/profilefields/__init__.py
  522. 26 57
      misago/users/profilefields/basefields.py
  523. 21 28
      misago/users/profilefields/default.py
  524. 4 11
      misago/users/profilefields/serializers.py
  525. 16 16
      misago/users/registration.py
  526. 13 11
      misago/users/search.py
  527. 38 29
      misago/users/serializers/auth.py
  528. 4 17
      misago/users/serializers/ban.py
  529. 2 8
      misago/users/serializers/datadownload.py
  530. 10 12
      misago/users/serializers/moderation.py
  531. 31 33
      misago/users/serializers/options.py
  532. 11 11
      misago/users/serializers/rank.py
  533. 58 114
      misago/users/serializers/user.py
  534. 9 9
      misago/users/serializers/usernamechange.py
  535. 5 5
      misago/users/setupnewuser.py
  536. 28 21
      misago/users/signals.py
  537. 2 2
      misago/users/signatures.py
  538. 80 80
      misago/users/social/backendsnames.py
  539. 64 70
      misago/users/social/pipeline.py
  540. 9 7
      misago/users/social/utils.py
  541. 3 3
      misago/users/templatetags/misago_avatars.py
  542. 17 42
      misago/users/tests/test_activation_views.py
  543. 22 16
      misago/users/tests/test_activepostersranking.py
  544. 7 7
      misago/users/tests/test_audittrail.py
  545. 218 300
      misago/users/tests/test_auth_api.py
  546. 12 22
      misago/users/tests/test_auth_backend.py
  547. 24 40
      misago/users/tests/test_auth_views.py
  548. 33 30
      misago/users/tests/test_avatars.py
  549. 12 33
      misago/users/tests/test_avatarserver_views.py
  550. 27 25
      misago/users/tests/test_ban_model.py
  551. 42 61
      misago/users/tests/test_banadmin_views.py
  552. 70 80
      misago/users/tests/test_bans.py
  553. 88 96
      misago/users/tests/test_bio_profilefield.py
  554. 3 3
      misago/users/tests/test_captcha_api.py
  555. 2 4
      misago/users/tests/test_createsuperuser.py
  556. 20 12
      misago/users/tests/test_credentialchange.py
  557. 37 41
      misago/users/tests/test_datadownloads.py
  558. 46 42
      misago/users/tests/test_datadownloads_dataarchive.py
  559. 26 33
      misago/users/tests/test_datadownloadsadmin_views.py
  560. 13 15
      misago/users/tests/test_decorators.py
  561. 4 2
      misago/users/tests/test_deleteinactiveusers.py
  562. 6 6
      misago/users/tests/test_deletemarkedusers.py
  563. 11 7
      misago/users/tests/test_deleteprofilefield.py
  564. 8 11
      misago/users/tests/test_djangoadmin_auth.py
  565. 9 14
      misago/users/tests/test_djangoadmin_user.py
  566. 5 5
      misago/users/tests/test_expireuserdatadownloads.py
  567. 21 38
      misago/users/tests/test_forgottenpassword_views.py
  568. 140 156
      misago/users/tests/test_gender_profilefield.py
  569. 10 6
      misago/users/tests/test_getting_user_status.py
  570. 4 7
      misago/users/tests/test_invalidatebans.py
  571. 46 61
      misago/users/tests/test_joinip_profilefield.py
  572. 16 39
      misago/users/tests/test_lists_views.py
  573. 8 12
      misago/users/tests/test_listusedprofilefields.py
  574. 19 25
      misago/users/tests/test_mention_api.py
  575. 9 18
      misago/users/tests/test_misagoavatars_tags.py
  576. 28 22
      misago/users/tests/test_namechanges.py
  577. 5 5
      misago/users/tests/test_new_user_setup.py
  578. 18 32
      misago/users/tests/test_options_views.py
  579. 2 2
      misago/users/tests/test_populateonlinetracker.py
  580. 9 5
      misago/users/tests/test_prepareuserdatadownloads.py
  581. 48 75
      misago/users/tests/test_profile_views.py
  582. 60 57
      misago/users/tests/test_profilefields.py
  583. 124 188
      misago/users/tests/test_rankadmin_views.py
  584. 6 6
      misago/users/tests/test_realip_middleware.py
  585. 15 10
      misago/users/tests/test_removeoldips.py
  586. 8 30
      misago/users/tests/test_rest_permissions.py
  587. 40 45
      misago/users/tests/test_search.py
  588. 5 5
      misago/users/tests/test_signatures.py
  589. 215 257
      misago/users/tests/test_social_pipeline.py
  590. 41 35
      misago/users/tests/test_social_utils.py
  591. 16 12
      misago/users/tests/test_testutils.py
  592. 6 6
      misago/users/tests/test_tokens.py
  593. 112 118
      misago/users/tests/test_twitter_profilefield.py
  594. 152 184
      misago/users/tests/test_user_avatar_api.py
  595. 37 69
      misago/users/tests/test_user_changeemail_api.py
  596. 34 54
      misago/users/tests/test_user_changepassword_api.py
  597. 189 225
      misago/users/tests/test_user_create_api.py
  598. 9 5
      misago/users/tests/test_user_creation.py
  599. 9 9
      misago/users/tests/test_user_datadownloads_api.py
  600. 15 33
      misago/users/tests/test_user_details_api.py
  601. 32 49
      misago/users/tests/test_user_editdetails_api.py
  602. 37 89
      misago/users/tests/test_user_feeds_api.py
  603. 11 11
      misago/users/tests/test_user_middleware.py
  604. 15 15
      misago/users/tests/test_user_model.py
  605. 17 17
      misago/users/tests/test_user_requestdatadownload_api.py
  606. 30 41
      misago/users/tests/test_user_signature_api.py
  607. 57 88
      misago/users/tests/test_user_username_api.py
  608. 409 521
      misago/users/tests/test_useradmin_views.py
  609. 26 22
      misago/users/tests/test_usernamechanges_api.py
  610. 154 194
      misago/users/tests/test_users_api.py
  611. 2 2
      misago/users/tests/test_utils.py
  612. 47 39
      misago/users/tests/test_validators.py
  613. 1 1
      misago/users/tests/testfiles/profilefields.py
  614. 6 9
      misago/users/testutils.py
  615. 12 8
      misago/users/tokens.py
  616. 102 54
      misago/users/urls/__init__.py
  617. 15 11
      misago/users/urls/api.py
  618. 1 1
      misago/users/utils.py
  619. 22 18
      misago/users/validators.py
  620. 10 10
      misago/users/viewmodels/activeposters.py
  621. 3 6
      misago/users/viewmodels/followers.py
  622. 1 4
      misago/users/viewmodels/follows.py
  623. 5 3
      misago/users/viewmodels/posts.py
  624. 4 9
      misago/users/viewmodels/rankusers.py
  625. 25 17
      misago/users/viewmodels/threads.py
  626. 12 21
      misago/users/views/activation.py
  627. 17 15
      misago/users/views/admin/bans.py
  628. 30 22
      misago/users/views/admin/datadownloads.py
  629. 18 18
      misago/users/views/admin/ranks.py
  630. 118 131
      misago/users/views/admin/users.py
  631. 7 7
      misago/users/views/auth.py
  632. 2 2
      misago/users/views/avatarserver.py
  633. 20 18
      misago/users/views/forgottenpassword.py
  634. 37 34
      misago/users/views/lists.py
  635. 20 22
      misago/users/views/options.py
  636. 86 63
      misago/users/views/profile.py

+ 0 - 11
.style.yapf

@@ -1,11 +0,0 @@
-[style]
-coalesce_brackets = true
-column_limit=99
-dedent_closing_brackets = true
-each_dict_entry_on_separate_line = true
-indent_dictionary_value = true
-join_multiple_lines = false
-split_arguments_when_comma_terminated = true
-split_before_first_argument = true
-split_before_logical_operator = true
-split_before_named_assigns = true

+ 23 - 14
.travis.yml

@@ -1,16 +1,25 @@
 dist: xenial
 language: python
-python:
-  - "3.6"
-addons:
-  postgresql: "9.4"
-install:
-  - pip install -U pip setuptools
-  - python setup.py install
-  - pip install coveralls pytest-cov
-before_script:
-  - psql -c "create database travis_ci_test;" -U postgres
-script:
-  - pytest --cov=misago
-after_success:
-  - coveralls
+jobs:
+  include:
+    - python:
+        - 3.6
+      addons:
+        postgresql: 9.4
+      install:
+        - pip install -U pip setuptools
+        - python setup.py install
+        - pip install coveralls pytest-cov
+      before_script:
+        - psql -c "create database travis_ci_test;" -U postgres
+      script:
+        - pytest --cov=misago
+      after_success:
+        - coveralls
+    - name: "lint"
+      python: 3.6
+      install:
+        - pip install -U pip setuptools
+        - pip install black
+      script:
+        - black --check devproject misago

+ 153 - 186
devproject/settings.py

@@ -27,7 +27,7 @@ _ = lambda s: s
 # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
 
 # SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = '1znyfpwp*_#!r0#l248lht*6)_0b+504n*2-8cxf(2u)fhi0f^'
+SECRET_KEY = "1znyfpwp*_#!r0#l248lht*6)_0b+504n*2-8cxf(2u)fhi0f^"
 
 # SECURITY WARNING: don't run with debug turned on in production!
 DEBUG = True
@@ -43,14 +43,14 @@ ALLOWED_HOSTS = []
 # https://docs.djangoproject.com/en/1.11/ref/settings/#databases
 
 DATABASES = {
-    'default': {
+    "default": {
         # Misago requires PostgreSQL to run
-        'ENGINE': 'django.db.backends.postgresql',
-        'NAME': os.environ.get('POSTGRES_DB'),
-        'USER': os.environ.get('POSTGRES_USER'),
-        'PASSWORD': os.environ.get('POSTGRES_PASSWORD'),
-        'HOST': os.environ.get('POSTGRES_HOST'),
-        'PORT': 5432,
+        "ENGINE": "django.db.backends.postgresql",
+        "NAME": os.environ.get("POSTGRES_DB"),
+        "USER": os.environ.get("POSTGRES_USER"),
+        "PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
+        "HOST": os.environ.get("POSTGRES_HOST"),
+        "PORT": 5432,
     }
 }
 
@@ -59,9 +59,9 @@ DATABASES = {
 # https://docs.djangoproject.com/en/1.11/topics/cache/#setting-up-the-cache
 
 CACHES = {
-    'default': {
+    "default": {
         # Misago doesn't run well with LocMemCache in production environments
-        'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
+        "BACKEND": "django.core.cache.backends.locmem.LocMemCache"
     }
 }
 
@@ -71,32 +71,24 @@ CACHES = {
 
 AUTH_PASSWORD_VALIDATORS = [
     {
-        'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
-        'OPTIONS': {
-            'user_attributes': ['username', 'email'],
-        }
+        "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
+        "OPTIONS": {"user_attributes": ["username", "email"]},
     },
     {
-        'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
-        'OPTIONS': {
-            'min_length': 7,
-        }
-    },
-    {
-        'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
-    },
-    {
-        'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+        "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+        "OPTIONS": {"min_length": 7},
     },
+    {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
+    {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
 ]
 
 
 # Internationalization
 # https://docs.djangoproject.com/en/1.11/topics/i18n/
 
-LANGUAGE_CODE = 'en-us'
+LANGUAGE_CODE = "en-us"
 
-TIME_ZONE = 'UTC'
+TIME_ZONE = "UTC"
 
 USE_I18N = True
 
@@ -108,25 +100,25 @@ USE_TZ = True
 # Static files (CSS, JavaScript, Images)
 # https://docs.djangoproject.com/en/1.11/howto/static-files/
 
-STATIC_URL = '/static/'
+STATIC_URL = "/static/"
 
 
 # User uploads (Avatars, Attachments, files uploaded in other Django apps, ect.)
 # https://docs.djangoproject.com/en/1.11/howto/static-files/
 
-MEDIA_URL = '/media/'
+MEDIA_URL = "/media/"
 
 
 # The absolute path to the directory where collectstatic will collect static files for deployment.
 # https://docs.djangoproject.com/en/1.11/ref/settings/#static-root
 
-STATIC_ROOT = os.path.join(BASE_DIR, 'static')
+STATIC_ROOT = os.path.join(BASE_DIR, "static")
 
 
 # Absolute filesystem path to the directory that will hold user-uploaded files.
 # https://docs.djangoproject.com/en/1.11/ref/settings/#media-root
 
-MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
+MEDIA_ROOT = os.path.join(BASE_DIR, "media")
 
 
 # This setting defines the additional locations the staticfiles app will traverse if the FileSystemFinder finder
@@ -139,196 +131,175 @@ STATICFILES_DIRS = []
 # Email configuration
 # https://docs.djangoproject.com/en/1.11/ref/settings/#email-backend
 
-EMAIL_HOST = 'localhost'
+EMAIL_HOST = "localhost"
 EMAIL_PORT = 25
 
 
 # If either of these settings is empty, Django won't attempt authentication.
 
-EMAIL_HOST_USER = ''
-EMAIL_HOST_PASSWORD = ''
+EMAIL_HOST_USER = ""
+EMAIL_HOST_PASSWORD = ""
 
 
 # Default email address to use for various automated correspondence from the site manager(s).
 
-DEFAULT_FROM_EMAIL = 'Forums <%s>' % EMAIL_HOST_USER
+DEFAULT_FROM_EMAIL = "Forums <%s>" % EMAIL_HOST_USER
 
 
 # Application definition
 
-AUTH_USER_MODEL = 'misago_users.User'
+AUTH_USER_MODEL = "misago_users.User"
 
-AUTHENTICATION_BACKENDS = [
-    'misago.users.authbackends.MisagoBackend',
-]
+AUTHENTICATION_BACKENDS = ["misago.users.authbackends.MisagoBackend"]
 
-CSRF_FAILURE_VIEW = 'misago.core.errorpages.csrf_failure'
+CSRF_FAILURE_VIEW = "misago.core.errorpages.csrf_failure"
 
 INSTALLED_APPS = [
     # Misago overrides for Django core feature
-    'misago',
-    'misago.users',
-
+    "misago",
+    "misago.users",
     # Django apps
-    'django.contrib.admin',
-    'django.contrib.auth',
-    'django.contrib.contenttypes',
-    'django.contrib.postgres',
-    'django.contrib.humanize',
-    'django.contrib.sessions',
-    'django.contrib.messages',
-    'django.contrib.staticfiles',
-
+    "django.contrib.admin",
+    "django.contrib.auth",
+    "django.contrib.contenttypes",
+    "django.contrib.postgres",
+    "django.contrib.humanize",
+    "django.contrib.sessions",
+    "django.contrib.messages",
+    "django.contrib.staticfiles",
     # 3rd party apps used by Misago
-    'debug_toolbar',
-    'mptt',
-    'rest_framework',
-    'social_django',
-
+    "debug_toolbar",
+    "mptt",
+    "rest_framework",
+    "social_django",
     # Misago apps
-    'misago.admin',
-    'misago.acl',
-    'misago.cache',
-    'misago.core',
-    'misago.conf',
-    'misago.markup',
-    'misago.legal',
-    'misago.categories',
-    'misago.threads',
-    'misago.readtracker',
-    'misago.search',
-    'misago.faker',
+    "misago.admin",
+    "misago.acl",
+    "misago.cache",
+    "misago.core",
+    "misago.conf",
+    "misago.markup",
+    "misago.legal",
+    "misago.categories",
+    "misago.threads",
+    "misago.readtracker",
+    "misago.search",
+    "misago.faker",
 ]
 
-INTERNAL_IPS = [
-    '127.0.0.1'
-]
+INTERNAL_IPS = ["127.0.0.1"]
 
-LOGIN_REDIRECT_URL = 'misago:index'
+LOGIN_REDIRECT_URL = "misago:index"
 
-LOGIN_URL = 'misago:login'
+LOGIN_URL = "misago:login"
 
-LOGOUT_URL = 'misago:logout'
+LOGOUT_URL = "misago:logout"
 
 MIDDLEWARE = [
-    'debug_toolbar.middleware.DebugToolbarMiddleware',
-
-    'misago.users.middleware.RealIPMiddleware',
-    'misago.core.middleware.FrontendContextMiddleware',
-
-    'django.middleware.security.SecurityMiddleware',
-    'django.contrib.sessions.middleware.SessionMiddleware',
-    'django.middleware.common.CommonMiddleware',
-    'django.middleware.csrf.CsrfViewMiddleware',
-    'django.contrib.auth.middleware.AuthenticationMiddleware',
-    'django.contrib.messages.middleware.MessageMiddleware',
-    'django.middleware.clickjacking.XFrameOptionsMiddleware',
-
-    'misago.cache.middleware.cache_versions_middleware',
-    'misago.conf.middleware.dynamic_settings_middleware',
-    'misago.users.middleware.UserMiddleware',
-    'misago.acl.middleware.user_acl_middleware',
-    'misago.core.middleware.ExceptionHandlerMiddleware',
-    'misago.users.middleware.OnlineTrackerMiddleware',
-    'misago.admin.middleware.AdminAuthMiddleware',
-    'misago.threads.middleware.UnreadThreadsCountMiddleware',
+    "debug_toolbar.middleware.DebugToolbarMiddleware",
+    "misago.users.middleware.RealIPMiddleware",
+    "misago.core.middleware.FrontendContextMiddleware",
+    "django.middleware.security.SecurityMiddleware",
+    "django.contrib.sessions.middleware.SessionMiddleware",
+    "django.middleware.common.CommonMiddleware",
+    "django.middleware.csrf.CsrfViewMiddleware",
+    "django.contrib.auth.middleware.AuthenticationMiddleware",
+    "django.contrib.messages.middleware.MessageMiddleware",
+    "django.middleware.clickjacking.XFrameOptionsMiddleware",
+    "misago.cache.middleware.cache_versions_middleware",
+    "misago.conf.middleware.dynamic_settings_middleware",
+    "misago.users.middleware.UserMiddleware",
+    "misago.acl.middleware.user_acl_middleware",
+    "misago.core.middleware.ExceptionHandlerMiddleware",
+    "misago.users.middleware.OnlineTrackerMiddleware",
+    "misago.admin.middleware.AdminAuthMiddleware",
+    "misago.threads.middleware.UnreadThreadsCountMiddleware",
 ]
 
-ROOT_URLCONF = 'devproject.urls'
+ROOT_URLCONF = "devproject.urls"
 
 SOCIAL_AUTH_PIPELINE = (
     # Steps required by social pipeline to work - don't delete those!
-    'social_core.pipeline.social_auth.social_details',
-    'social_core.pipeline.social_auth.social_uid',
-    'social_core.pipeline.social_auth.social_user',
-
+    "social_core.pipeline.social_auth.social_details",
+    "social_core.pipeline.social_auth.social_uid",
+    "social_core.pipeline.social_auth.social_user",
     # Uncomment next line to let your users to associate their old forum account with social one.
     # 'misago.users.social.pipeline.associate_by_email',
-
     # Those steps make sure banned users may not join your site or use banned name or email.
-    'misago.users.social.pipeline.validate_ip_not_banned',
-    'misago.users.social.pipeline.validate_user_not_banned',
-
+    "misago.users.social.pipeline.validate_ip_not_banned",
+    "misago.users.social.pipeline.validate_user_not_banned",
     # Reads user data received from social site and tries to create valid and available username
     # Required if you want automatic account creation to work. Otherwhise optional.
-    'misago.users.social.pipeline.get_username',
-
+    "misago.users.social.pipeline.get_username",
     # Uncomment next line to enable automatic account creation if data from social site is valid
     # and get_username found valid name for new user account:
     # 'misago.users.social.pipeline.create_user',
-
     # This step asks user to complete simple, pre filled registration form containing username,
     # email, legal note if you remove it without adding custom one, users will have no fallback
     # for joining your site using their social account.
-    'misago.users.social.pipeline.create_user_with_form',
-
+    "misago.users.social.pipeline.create_user_with_form",
     # Steps finalizing social authentication flow - don't delete those!
-    'social_core.pipeline.social_auth.associate_user',
-    'social_core.pipeline.social_auth.load_extra_data',
-    'misago.users.social.pipeline.require_activation',
+    "social_core.pipeline.social_auth.associate_user",
+    "social_core.pipeline.social_auth.load_extra_data",
+    "misago.users.social.pipeline.require_activation",
 )
 
 SOCIAL_AUTH_POSTGRES_JSONFIELD = True
 
 TEMPLATES = [
     {
-        'BACKEND': 'django.template.backends.django.DjangoTemplates',
-        'DIRS': [],
-        'APP_DIRS': True,
-        'OPTIONS': {
-            'context_processors': [
-                'django.template.context_processors.debug',
-                'django.template.context_processors.i18n',
-                'django.template.context_processors.media',
-                'django.template.context_processors.request',
-                'django.template.context_processors.static',
-                'django.template.context_processors.tz',
-                'django.contrib.auth.context_processors.auth',
-                'django.contrib.messages.context_processors.messages',
-
-                'misago.acl.context_processors.user_acl',
-                'misago.conf.context_processors.conf',
-                'misago.core.context_processors.site_address',
-                'misago.core.context_processors.momentjs_locale',
-                'misago.search.context_processors.search_providers',
-                'misago.users.context_processors.user_links',
-
+        "BACKEND": "django.template.backends.django.DjangoTemplates",
+        "DIRS": [],
+        "APP_DIRS": True,
+        "OPTIONS": {
+            "context_processors": [
+                "django.template.context_processors.debug",
+                "django.template.context_processors.i18n",
+                "django.template.context_processors.media",
+                "django.template.context_processors.request",
+                "django.template.context_processors.static",
+                "django.template.context_processors.tz",
+                "django.contrib.auth.context_processors.auth",
+                "django.contrib.messages.context_processors.messages",
+                "misago.acl.context_processors.user_acl",
+                "misago.conf.context_processors.conf",
+                "misago.core.context_processors.site_address",
+                "misago.core.context_processors.momentjs_locale",
+                "misago.search.context_processors.search_providers",
+                "misago.users.context_processors.user_links",
                 # Data preloaders
-                'misago.conf.context_processors.preload_settings_json',
-                'misago.core.context_processors.current_link',
-                'misago.markup.context_processors.preload_api_url',
-                'misago.threads.context_processors.preload_threads_urls',
-                'misago.users.context_processors.preload_user_json',
-
+                "misago.conf.context_processors.preload_settings_json",
+                "misago.core.context_processors.current_link",
+                "misago.markup.context_processors.preload_api_url",
+                "misago.threads.context_processors.preload_threads_urls",
+                "misago.users.context_processors.preload_user_json",
                 # Note: keep frontend_context processor last for previous processors
                 # to be able to expose data UI app via request.frontend_context
-                'misago.core.context_processors.frontend_context',
-            ],
+                "misago.core.context_processors.frontend_context",
+            ]
         },
-    },
+    }
 ]
 
-WSGI_APPLICATION = 'devproject.wsgi.application'
+WSGI_APPLICATION = "devproject.wsgi.application"
 
 
 # Django Debug Toolbar
 # http://django-debug-toolbar.readthedocs.io/en/stable/configuration.html
 
 DEBUG_TOOLBAR_PANELS = [
-    'debug_toolbar.panels.versions.VersionsPanel',
-    'debug_toolbar.panels.timer.TimerPanel',
-    'debug_toolbar.panels.settings.SettingsPanel',
-    'debug_toolbar.panels.headers.HeadersPanel',
-    'debug_toolbar.panels.request.RequestPanel',
-    'debug_toolbar.panels.sql.SQLPanel',
-
-    'misago.acl.panels.MisagoACLPanel',
-
-    'debug_toolbar.panels.staticfiles.StaticFilesPanel',
-    'debug_toolbar.panels.templates.TemplatesPanel',
-    'debug_toolbar.panels.cache.CachePanel',
-    'debug_toolbar.panels.signals.SignalsPanel',
-    'debug_toolbar.panels.logging.LoggingPanel',
+    "debug_toolbar.panels.versions.VersionsPanel",
+    "debug_toolbar.panels.timer.TimerPanel",
+    "debug_toolbar.panels.settings.SettingsPanel",
+    "debug_toolbar.panels.headers.HeadersPanel",
+    "debug_toolbar.panels.request.RequestPanel",
+    "debug_toolbar.panels.sql.SQLPanel",
+    "misago.acl.panels.MisagoACLPanel",
+    "debug_toolbar.panels.staticfiles.StaticFilesPanel",
+    "debug_toolbar.panels.templates.TemplatesPanel",
+    "debug_toolbar.panels.cache.CachePanel",
+    "debug_toolbar.panels.signals.SignalsPanel",
+    "debug_toolbar.panels.logging.LoggingPanel",
 ]
 
 
@@ -336,15 +307,13 @@ DEBUG_TOOLBAR_PANELS = [
 # http://www.django-rest-framework.org/api-guide/settings/
 
 REST_FRAMEWORK = {
-    'DEFAULT_PERMISSION_CLASSES': [
-        'misago.core.rest_permissions.IsAuthenticatedOrReadOnly',
+    "DEFAULT_PERMISSION_CLASSES": [
+        "misago.core.rest_permissions.IsAuthenticatedOrReadOnly"
     ],
-    'DEFAULT_RENDERER_CLASSES': [
-        'rest_framework.renderers.JSONRenderer',
-    ],
-    'EXCEPTION_HANDLER': 'misago.core.exceptionhandler.handle_api_exception',
-    'UNAUTHENTICATED_USER': 'misago.users.models.AnonymousUser',
-    'URL_FORMAT_OVERRIDE': None,
+    "DEFAULT_RENDERER_CLASSES": ["rest_framework.renderers.JSONRenderer"],
+    "EXCEPTION_HANDLER": "misago.core.exceptionhandler.handle_api_exception",
+    "UNAUTHENTICATED_USER": "misago.users.models.AnonymousUser",
+    "URL_FORMAT_OVERRIDE": None,
 }
 
 
@@ -356,7 +325,7 @@ REST_FRAMEWORK = {
 # On Misago admin panel home page you will find a message telling you if you have entered the
 # correct value, or what value is correct in case you've didn't.
 
-MISAGO_ADDRESS = 'http://my-misago-site.com/'
+MISAGO_ADDRESS = "http://my-misago-site.com/"
 
 
 # PostgreSQL text search configuration to use in searches
@@ -366,7 +335,7 @@ MISAGO_ADDRESS = 'http://my-misago-site.com/'
 # spanish, swedish and turkish
 # Example on adding custom language can be found here: https://github.com/lemonskyjwt/plpstgrssearch
 
-MISAGO_SEARCH_CONFIG = 'simple'
+MISAGO_SEARCH_CONFIG = "simple"
 
 
 # Allow users to download their personal data
@@ -378,7 +347,7 @@ MISAGO_ENABLE_DOWNLOAD_OWN_DATA = True
 # Path to the directory that Misago should use to prepare user data downloads.
 # Should not be accessible from internet.
 
-MISAGO_USER_DATA_DOWNLOADS_WORKING_DIR = os.path.join(BASE_DIR, 'userdata')
+MISAGO_USER_DATA_DOWNLOADS_WORKING_DIR = os.path.join(BASE_DIR, "userdata")
 
 
 # Allow users to delete their accounts
@@ -400,7 +369,7 @@ MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS = 2
 # Path to directory containing avatar galleries
 # Those galleries can be loaded by running loadavatargallery command
 
-MISAGO_AVATAR_GALLERY = os.path.join(BASE_DIR, 'avatargallery')
+MISAGO_AVATAR_GALLERY = os.path.join(BASE_DIR, "avatargallery")
 
 
 # Specifies the number of days that IP addresses are stored in the database before removing.
@@ -413,38 +382,36 @@ MISAGO_IP_STORE_TIME = 50
 
 MISAGO_PROFILE_FIELDS = [
     {
-        'name': _("Personal"),
-        'fields': [
-            'misago.users.profilefields.default.RealNameField',
-            'misago.users.profilefields.default.GenderField',
-            'misago.users.profilefields.default.BioField',
-            'misago.users.profilefields.default.LocationField',
+        "name": _("Personal"),
+        "fields": [
+            "misago.users.profilefields.default.RealNameField",
+            "misago.users.profilefields.default.GenderField",
+            "misago.users.profilefields.default.BioField",
+            "misago.users.profilefields.default.LocationField",
         ],
     },
     {
-        'name': _("Contact"),
-        'fields': [
-            'misago.users.profilefields.default.TwitterHandleField',
-            'misago.users.profilefields.default.SkypeIdField',
-            'misago.users.profilefields.default.WebsiteField',
+        "name": _("Contact"),
+        "fields": [
+            "misago.users.profilefields.default.TwitterHandleField",
+            "misago.users.profilefields.default.SkypeIdField",
+            "misago.users.profilefields.default.WebsiteField",
         ],
     },
     {
-        'name': _("IP address"),
-        'fields': [
-            'misago.users.profilefields.default.JoinIpField',
-        ],
+        "name": _("IP address"),
+        "fields": ["misago.users.profilefields.default.JoinIpField"],
     },
 ]
 
 
 # Set dev instance to send e-mails to console
 
-EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
+EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
 
 
 # Display debug toolbar if IN_MISAGO_DOCKER enviroment var is set to "1"
 
 DEBUG_TOOLBAR_CONFIG = {
-    'SHOW_TOOLBAR_CALLBACK': 'misago.conf.debugtoolbar.enable_debug_toolbar'
+    "SHOW_TOOLBAR_CALLBACK": "misago.conf.debugtoolbar.enable_debug_toolbar"
 }

+ 24 - 36
devproject/test_settings.py

@@ -4,67 +4,55 @@ from .settings import *  # pylint: disable-all
 
 # Use test DB
 DATABASES = {
-    'default': {
-        'ENGINE': 'django.db.backends.postgresql_psycopg2',
-        'NAME': os.environ.get('POSTGRES_TEST_DB'),
-        'USER': os.environ.get('POSTGRES_USER'),
-        'PASSWORD': os.environ.get('POSTGRES_PASSWORD'),
-        'HOST': os.environ.get('POSTGRES_HOST'),
-        'PORT': 5432,
+    "default": {
+        "ENGINE": "django.db.backends.postgresql_psycopg2",
+        "NAME": os.environ.get("POSTGRES_TEST_DB"),
+        "USER": os.environ.get("POSTGRES_USER"),
+        "PASSWORD": os.environ.get("POSTGRES_PASSWORD"),
+        "HOST": os.environ.get("POSTGRES_HOST"),
+        "PORT": 5432,
     }
 }
 
 # Use in-memory cache
-CACHES = {
-    'default': {
-        'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
-    }
-}
+CACHES = {"default": {"BACKEND": "django.core.cache.backends.dummy.DummyCache"}}
 
 # Disable Debug Toolbar
 DEBUG_TOOLBAR_CONFIG = {}
 INTERNAL_IPS = []
 
 # Disable account validation via Stop Forum Spam
-MISAGO_NEW_REGISTRATIONS_VALIDATORS = (
-    'misago.users.validators.validate_gmail_email',
-)
+MISAGO_NEW_REGISTRATIONS_VALIDATORS = ("misago.users.validators.validate_gmail_email",)
 
 # Store mails in memory
-EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
+EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
 
 # Use MD5 password hashing to speed up test suite
-PASSWORD_HASHERS = (
-    'django.contrib.auth.hashers.MD5PasswordHasher',
-)
+PASSWORD_HASHERS = ("django.contrib.auth.hashers.MD5PasswordHasher",)
 
 # Default misago address to test address
-MISAGO_ADDRESS = 'http://testserver/'
+MISAGO_ADDRESS = "http://testserver/"
 
 # Use english search config
-MISAGO_SEARCH_CONFIG = 'english'
+MISAGO_SEARCH_CONFIG = "english"
 
 # Register test post validator
-MISAGO_POST_VALIDATORS = [
-    'misago.core.testproject.validators.test_post_validator',
-]
+MISAGO_POST_VALIDATORS = ["misago.core.testproject.validators.test_post_validator"]
 
 # Register test post search filter
-MISAGO_POST_SEARCH_FILTERS = [
-    'misago.core.testproject.searchfilters.test_filter',
-]
+MISAGO_POST_SEARCH_FILTERS = ["misago.core.testproject.searchfilters.test_filter"]
 
 # Additional overrides for Travis-CI
-if os.environ.get('TRAVIS'):
+if os.environ.get("TRAVIS"):
     DATABASES = {
-        'default': {
-            'ENGINE': 'django.db.backends.postgresql_psycopg2',
-            'NAME': 'travis_ci_test',
-            'USER': 'postgres',
-            'PASSWORD': '',
-            'HOST': '127.0.0.1',
-            'PORT': '',
+        "default": {
+            "ENGINE": "django.db.backends.postgresql_psycopg2",
+            "NAME": "travis_ci_test",
+            "USER": "postgres",
+            "PASSWORD": "",
+            "HOST": "127.0.0.1",
+            "PORT": "",
         }
     }
 
-    TEST_NAME = 'travis_ci_test'
+    TEST_NAME = "travis_ci_test"

+ 12 - 17
devproject/urls.py

@@ -30,33 +30,28 @@ admin.site.login_form = AdminAuthenticationForm
 
 
 urlpatterns = [
-    url(r'^', include('misago.urls', namespace='misago')),
-    url(r'^', include('social_django.urls', namespace='social')),
-
+    url(r"^", include("misago.urls", namespace="misago")),
+    url(r"^", include("social_django.urls", namespace="social")),
     # Javascript translations
     url(
-        r'^django-i18n.js$',
+        r"^django-i18n.js$",
         last_modified(lambda req, **kw: timezone.now())(
-            cache_page(86400 * 2, key_prefix='misagojsi18n')(
-                JavaScriptCatalog.as_view(
-                    packages=['misago'],
-                ),
-            ),
+            cache_page(86400 * 2, key_prefix="misagojsi18n")(
+                JavaScriptCatalog.as_view(packages=["misago"])
+            )
         ),
-        name='django-i18n',
+        name="django-i18n",
     ),
-
     # Uncomment next line if you plan to use Django admin for 3rd party apps
-    #url(r'^django-admin/', admin.site.urls),
+    # url(r'^django-admin/', admin.site.urls),
 ]
 
 
 # If debug mode is enabled, include debug toolbar
 if settings.DEBUG:
     import debug_toolbar
-    urlpatterns += [
-        url(r'^__debug__/', include(debug_toolbar.urls)),
-    ]
+
+    urlpatterns += [url(r"^__debug__/", include(debug_toolbar.urls))]
 
 
 # Use static file server for static and media files (debug only)
@@ -69,5 +64,5 @@ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
 # If you replace those handlers with custom ones, make sure you decorate them
 # with shared_403_exception_handler or shared_404_exception_handler
 # decorators that are defined in misago.views.errorpages module!
-handler403 = 'misago.core.errorpages.permission_denied'
-handler404 = 'misago.core.errorpages.page_not_found'
+handler403 = "misago.core.errorpages.permission_denied"
+handler404 = "misago.core.errorpages.page_not_found"

+ 1 - 1
misago/__init__.py

@@ -1 +1 @@
-__version__ = '0.19.4'
+__version__ = "0.19.4"

+ 1 - 1
misago/acl/__init__.py

@@ -1,3 +1,3 @@
-default_app_config = 'misago.acl.apps.MisagoACLsConfig'
+default_app_config = "misago.acl.apps.MisagoACLsConfig"
 
 ACL_CACHE = "acl"

+ 17 - 17
misago/acl/admin.py

@@ -7,32 +7,32 @@ from .views import DeleteRole, EditRole, NewRole, RolesList, RoleUsers
 class MisagoAdminExtension(object):
     def register_urlpatterns(self, urlpatterns):
         # Permissions section
-        urlpatterns.namespace(r'^permissions/', 'permissions')
+        urlpatterns.namespace(r"^permissions/", "permissions")
 
         # Roles
-        urlpatterns.namespace(r'^users/', 'users', 'permissions')
+        urlpatterns.namespace(r"^users/", "users", "permissions")
         urlpatterns.patterns(
-            'permissions:users',
-            url(r'^$', RolesList.as_view(), name='index'),
-            url(r'^new/$', NewRole.as_view(), name='new'),
-            url(r'^edit/(?P<pk>\d+)/$', EditRole.as_view(), name='edit'),
-            url(r'^users/(?P<pk>\d+)/$', RoleUsers.as_view(), name='users'),
-            url(r'^delete/(?P<pk>\d+)/$', DeleteRole.as_view(), name='delete'),
+            "permissions:users",
+            url(r"^$", RolesList.as_view(), name="index"),
+            url(r"^new/$", NewRole.as_view(), name="new"),
+            url(r"^edit/(?P<pk>\d+)/$", EditRole.as_view(), name="edit"),
+            url(r"^users/(?P<pk>\d+)/$", RoleUsers.as_view(), name="users"),
+            url(r"^delete/(?P<pk>\d+)/$", DeleteRole.as_view(), name="delete"),
         )
 
     def register_navigation_nodes(self, site):
         site.add_node(
             name=_("Permissions"),
-            icon='fa fa-adjust',
-            parent='misago:admin',
-            after='misago:admin:users:accounts:index',
-            namespace='misago:admin:permissions',
-            link='misago:admin:permissions:users:index',
+            icon="fa fa-adjust",
+            parent="misago:admin",
+            after="misago:admin:users:accounts:index",
+            namespace="misago:admin:permissions",
+            link="misago:admin:permissions:users:index",
         )
         site.add_node(
             name=_("User roles"),
-            icon='fa fa-th-large',
-            parent='misago:admin:permissions',
-            namespace='misago:admin:permissions:users',
-            link='misago:admin:permissions:users:index',
+            icon="fa fa-th-large",
+            parent="misago:admin:permissions",
+            namespace="misago:admin:permissions:users",
+            link="misago:admin:permissions:users:index",
         )

+ 2 - 2
misago/acl/apps.py

@@ -3,8 +3,8 @@ from .providers import providers
 
 
 class MisagoACLsConfig(AppConfig):
-    name = 'misago.acl'
-    label = 'misago_acl'
+    name = "misago.acl"
+    label = "misago_acl"
     verbose_name = "Misago ACL framework"
 
     def ready(self):

+ 1 - 1
misago/acl/buildacl.py

@@ -9,7 +9,7 @@ def build_acl(roles):
         try:
             acl = module.build_acl(acl, roles, extension)
         except AttributeError:
-            message = '%s has to define build_acl function' % extension
+            message = "%s has to define build_acl function" % extension
             raise AttributeError(message)
 
     return acl

+ 1 - 1
misago/acl/cache.py

@@ -16,7 +16,7 @@ def set_acl_cache(user, cache_versions, user_acl):
 
 
 def get_cache_key(user, cache_versions):
-    return 'acl_%s_%s' % (user.acl_key, cache_versions[ACL_CACHE])
+    return "acl_%s_%s" % (user.acl_key, cache_versions[ACL_CACHE])
 
 
 def clear_acl_cache():

+ 6 - 11
misago/acl/forms.py

@@ -10,7 +10,7 @@ class RoleForm(forms.ModelForm):
 
     class Meta:
         model = Role
-        fields = ['name']
+        fields = ["name"]
 
 
 def get_permissions_forms(role, data=None):
@@ -23,22 +23,17 @@ def get_permissions_forms(role, data=None):
             module.change_permissions_form
         except AttributeError:
             message = "'%s' object has no attribute '%s'"
-            raise AttributeError(message % (extension, 'change_permissions_form'))
+            raise AttributeError(message % (extension, "change_permissions_form"))
 
         FormType = module.change_permissions_form(role)
 
         if FormType:
             if data:
-                perms_forms.append(FormType(
-                    data,
-                    prefix=extension,
-                ))
+                form = FormType(data, prefix=extension)
             else:
-                perms_forms.append(
-                    FormType(
-                        initial=role_permissions.get(extension),
-                        prefix=extension,
-                    )
+                form = FormType(
+                    initial=role_permissions.get(extension), prefix=extension
                 )
+            perms_forms.append(form)
 
     return perms_forms

+ 1 - 0
misago/acl/middleware.py

@@ -5,6 +5,7 @@ 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)

+ 17 - 12
misago/acl/migrations/0001_initial.py

@@ -12,20 +12,25 @@ class Migration(migrations.Migration):
 
     operations = [
         migrations.CreateModel(
-            name='Role',
+            name="Role",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
                 ),
-                ('name', models.CharField(max_length=255)),
-                ('special_role', models.CharField(max_length=255, null=True, blank=True)),
-                ('permissions', JSONField(default=permissions_default)),
+                ("name", models.CharField(max_length=255)),
+                (
+                    "special_role",
+                    models.CharField(max_length=255, null=True, blank=True),
+                ),
+                ("permissions", JSONField(default=permissions_default)),
             ],
-            options={
-                'abstract': False,
-            },
-            bases=(models.Model, ),
-        ),
+            options={"abstract": False},
+            bases=(models.Model,),
+        )
     ]

+ 1 - 4
misago/acl/migrations/0002_acl_version_tracker.py

@@ -4,9 +4,6 @@ from django.db import migrations
 class Migration(migrations.Migration):
     """Superseded by 0004"""
 
-    dependencies = [
-        ('misago_acl', '0001_initial'),
-        ('misago_core', '0001_initial'),
-    ]
+    dependencies = [("misago_acl", "0001_initial"), ("misago_core", "0001_initial")]
 
     operations = []

+ 117 - 143
misago/acl/migrations/0003_default_roles.py

@@ -5,231 +5,205 @@ _ = lambda s: s
 
 
 def create_default_roles(apps, schema_editor):
-    Role = apps.get_model('misago_acl', 'Role')
+    Role = apps.get_model("misago_acl", "Role")
 
     Role.objects.create(
         name=_("Member"),
-        special_role='authenticated',
+        special_role="authenticated",
         permissions={
             # account
-            'misago.users.permissions.account': {
-                'name_changes_allowed': 2,
-                'name_changes_expire': 180,
-                'can_have_signature': 0,
-                'allow_signature_links': 0,
-                'allow_signature_images': 0,
+            "misago.users.permissions.account": {
+                "name_changes_allowed": 2,
+                "name_changes_expire": 180,
+                "can_have_signature": 0,
+                "allow_signature_links": 0,
+                "allow_signature_images": 0,
             },
-
             # profiles
-            'misago.users.permissions.profiles': {
-                'can_browse_users_list': 1,
-                'can_search_users': 1,
-                'can_follow_users': 1,
-                'can_be_blocked': 1,
-                'can_see_users_name_history': 0,
-                'can_see_users_emails': 0,
-                'can_see_users_ips': 0,
-                'can_see_hidden_users': 0,
+            "misago.users.permissions.profiles": {
+                "can_browse_users_list": 1,
+                "can_search_users": 1,
+                "can_follow_users": 1,
+                "can_be_blocked": 1,
+                "can_see_users_name_history": 0,
+                "can_see_users_emails": 0,
+                "can_see_users_ips": 0,
+                "can_see_hidden_users": 0,
             },
-
             # attachments
-            'misago.threads.permissions.attachments': {
-                'max_attachment_size': 4 * 1024,
-                'can_download_other_users_attachments': True,
+            "misago.threads.permissions.attachments": {
+                "max_attachment_size": 4 * 1024,
+                "can_download_other_users_attachments": True,
             },
-
             # polls
-            'misago.threads.permissions.polls': {
-                'can_start_polls': 1,
-                'can_edit_polls': 1
+            "misago.threads.permissions.polls": {
+                "can_start_polls": 1,
+                "can_edit_polls": 1,
             },
-
             # search
-            'misago.search.permissions': {
-                'can_search': 1,
-            },
-        }
+            "misago.search.permissions": {"can_search": 1},
+        },
     )
 
     Role.objects.create(
         name=_("Guest"),
-        special_role='anonymous',
+        special_role="anonymous",
         permissions={
             # account
-            'misago.users.permissions.account': {
-                'name_changes_allowed': 0,
-                'name_changes_expire': 0,
-                'can_have_signature': 0,
-                'allow_signature_links': 0,
-                'allow_signature_images': 0,
+            "misago.users.permissions.account": {
+                "name_changes_allowed": 0,
+                "name_changes_expire": 0,
+                "can_have_signature": 0,
+                "allow_signature_links": 0,
+                "allow_signature_images": 0,
             },
-
             # profiles
-            'misago.users.permissions.profiles': {
-                'can_browse_users_list': 1,
-                'can_search_users': 1,
-                'can_see_users_name_history': 0,
-                'can_see_users_emails': 0,
-                'can_see_users_ips': 0,
-                'can_see_hidden_users': 0,
+            "misago.users.permissions.profiles": {
+                "can_browse_users_list": 1,
+                "can_search_users": 1,
+                "can_see_users_name_history": 0,
+                "can_see_users_emails": 0,
+                "can_see_users_ips": 0,
+                "can_see_hidden_users": 0,
             },
-
             # attachments
-            'misago.threads.permissions.attachments': {
-                'can_download_other_users_attachments': True,
+            "misago.threads.permissions.attachments": {
+                "can_download_other_users_attachments": True
             },
-
             # search
-            'misago.search.permissions': {
-                'can_search': 1,
-            },
-        }
+            "misago.search.permissions": {"can_search": 1},
+        },
     )
 
     Role.objects.create(
         name=_("Moderator"),
         permissions={
             # account
-            'misago.users.permissions.account': {
-                'name_changes_allowed': 5,
-                'name_changes_expire': 14,
-                'can_have_signature': 1,
-                'allow_signature_links': 1,
-                'allow_signature_images': 0,
+            "misago.users.permissions.account": {
+                "name_changes_allowed": 5,
+                "name_changes_expire": 14,
+                "can_have_signature": 1,
+                "allow_signature_links": 1,
+                "allow_signature_images": 0,
             },
-
             # profiles
-            'misago.users.permissions.profiles': {
-                'can_browse_users_list': 1,
-                'can_search_users': 1,
-                'can_be_blocked': 0,
-                'can_see_users_name_history': 1,
-                'can_see_ban_details': 1,
-                'can_see_users_emails': 1,
-                'can_see_users_ips': 1,
-                'can_see_hidden_users': 1,
+            "misago.users.permissions.profiles": {
+                "can_browse_users_list": 1,
+                "can_search_users": 1,
+                "can_be_blocked": 0,
+                "can_see_users_name_history": 1,
+                "can_see_ban_details": 1,
+                "can_see_users_emails": 1,
+                "can_see_users_ips": 1,
+                "can_see_hidden_users": 1,
             },
-
             # attachments
-            'misago.threads.permissions.attachments': {
-                'max_attachment_size': 8 * 1024,
-                'can_download_other_users_attachments': True,
-                'can_delete_other_users_attachments': True,
+            "misago.threads.permissions.attachments": {
+                "max_attachment_size": 8 * 1024,
+                "can_download_other_users_attachments": True,
+                "can_delete_other_users_attachments": True,
             },
-
             # polls
-            'misago.threads.permissions.polls': {
-                'can_start_polls': 2,
-                'can_edit_polls': 2,
-                'can_delete_polls': 2,
-                'can_always_see_poll_voters': 1
+            "misago.threads.permissions.polls": {
+                "can_start_polls": 2,
+                "can_edit_polls": 2,
+                "can_delete_polls": 2,
+                "can_always_see_poll_voters": 1,
             },
-
             # moderation
-            'misago.threads.permissions.threads': {
-                'can_see_unapproved_content_lists': True,
-                'can_see_reported_content_lists': True,
-                'can_omit_flood_protection': True,
+            "misago.threads.permissions.threads": {
+                "can_see_unapproved_content_lists": True,
+                "can_see_reported_content_lists": True,
+                "can_omit_flood_protection": True,
             },
-            'misago.users.permissions.moderation': {
-                'can_warn_users': 1,
-                'can_moderate_avatars': 1,
-                'can_moderate_signatures': 1,
-                'can_moderate_profile_details': 1,
+            "misago.users.permissions.moderation": {
+                "can_warn_users": 1,
+                "can_moderate_avatars": 1,
+                "can_moderate_signatures": 1,
+                "can_moderate_profile_details": 1,
             },
-
             # delete users
-            'misago.users.permissions.delete': {
-                'can_delete_users_newer_than': 0,
-                'can_delete_users_with_less_posts_than': 0,
+            "misago.users.permissions.delete": {
+                "can_delete_users_newer_than": 0,
+                "can_delete_users_with_less_posts_than": 0,
             },
-        }
+        },
     )
 
     Role.objects.create(
         name=_("Renaming users"),
         permissions={
             # rename users
-            'misago.users.permissions.moderation': {
-                'can_rename_users': 1,
-            },
-        }
+            "misago.users.permissions.moderation": {"can_rename_users": 1}
+        },
     )
 
     Role.objects.create(
         name=_("Banning users"),
         permissions={
             # ban users
-            'misago.users.permissions.profiles': {
-                'can_see_ban_details': 1,
-            },
-            'misago.users.permissions.moderation': {
-                'can_ban_users': 1,
-                'max_ban_length': 14,
-                'can_lift_bans': 1,
-                'max_lifted_ban_length': 14,
-            },
-        }
+            "misago.users.permissions.profiles": {"can_see_ban_details": 1},
+            "misago.users.permissions.moderation": {
+                "can_ban_users": 1,
+                "max_ban_length": 14,
+                "can_lift_bans": 1,
+                "max_lifted_ban_length": 14,
+            },
+        },
     )
 
     Role.objects.create(
         name=_("Deleting users"),
         permissions={
             # delete users
-            'misago.users.permissions.delete': {
-                'can_delete_users_newer_than': 3,
-                'can_delete_users_with_less_posts_than': 7,
-            },
-        }
+            "misago.users.permissions.delete": {
+                "can_delete_users_newer_than": 3,
+                "can_delete_users_with_less_posts_than": 7,
+            }
+        },
     )
 
     Role.objects.create(
         name=_("Can't be blocked"),
         permissions={
             # profiles
-            'misago.users.permissions.profiles': {
-                'can_be_blocked': 0,
-            },
-        }
+            "misago.users.permissions.profiles": {"can_be_blocked": 0}
+        },
     )
 
     Role.objects.create(
         name=_("Private threads"),
         permissions={
             # private threads
-            'misago.threads.permissions.privatethreads': {
-                'can_use_private_threads': 1,
-                'can_start_private_threads': 1,
-                'max_private_thread_participants': 3,
-                'can_add_everyone_to_private_threads': 0,
-                'can_report_private_threads': 1,
-                'can_moderate_private_threads': 0,
-            },
-        }
+            "misago.threads.permissions.privatethreads": {
+                "can_use_private_threads": 1,
+                "can_start_private_threads": 1,
+                "max_private_thread_participants": 3,
+                "can_add_everyone_to_private_threads": 0,
+                "can_report_private_threads": 1,
+                "can_moderate_private_threads": 0,
+            }
+        },
     )
 
     Role.objects.create(
         name=_("Private threads moderator"),
         permissions={
             # private threads
-            'misago.threads.permissions.privatethreads': {
-                'can_use_private_threads': 1,
-                'can_start_private_threads': 1,
-                'max_private_thread_participants': 15,
-                'can_add_everyone_to_private_threads': 1,
-                'can_report_private_threads': 1,
-                'can_moderate_private_threads': 1,
-            },
-        }
+            "misago.threads.permissions.privatethreads": {
+                "can_use_private_threads": 1,
+                "can_start_private_threads": 1,
+                "max_private_thread_participants": 15,
+                "can_add_everyone_to_private_threads": 1,
+                "can_report_private_threads": 1,
+                "can_moderate_private_threads": 1,
+            }
+        },
     )
 
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_acl', '0002_acl_version_tracker'),
-    ]
+    dependencies = [("misago_acl", "0002_acl_version_tracker")]
 
-    operations = [
-        migrations.RunPython(create_default_roles),
-    ]
+    operations = [migrations.RunPython(create_default_roles)]

+ 3 - 5
misago/acl/migrations/0004_cache_version.py

@@ -8,10 +8,8 @@ from misago.cache.operations import StartCacheVersioning
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('misago_acl', '0003_default_roles'),
-        ('misago_cache', '0001_initial'),
+        ("misago_acl", "0003_default_roles"),
+        ("misago_cache", "0001_initial"),
     ]
 
-    operations = [
-        StartCacheVersioning(ACL_CACHE)
-    ]
+    operations = [StartCacheVersioning(ACL_CACHE)]

+ 1 - 1
misago/acl/objectacl.py

@@ -3,7 +3,7 @@ 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__'):
+    if hasattr(obj, "__iter__"):
         for item in obj:
             _add_acl_to_obj(user_acl, item)
     else:

+ 4 - 6
misago/acl/panels.py

@@ -5,12 +5,13 @@ from django.utils.translation import gettext_lazy as _
 
 class MisagoACLPanel(Panel):
     """panel that displays current user's ACL"""
+
     title = _("Misago User ACL")
-    template = 'misago/acl_debug.html'
+    template = "misago/acl_debug.html"
 
     @property
     def nav_subtitle(self):
-        misago_user = self.get_stats().get('misago_user')
+        misago_user = self.get_stats().get("misago_user")
 
         if misago_user and misago_user.is_authenticated:
             return misago_user.username
@@ -28,7 +29,4 @@ class MisagoACLPanel(Panel):
         except AttributeError:
             misago_acl = {}
 
-        self.record_stats({
-            'misago_user': misago_user,
-            'misago_acl': misago_acl,
-        })
+        self.record_stats({"misago_user": misago_user, "misago_acl": misago_acl})

+ 1 - 1
misago/acl/providers.py

@@ -40,7 +40,7 @@ class PermissionProviders(object):
             self._providers.append((namespace, import_module(namespace)))
             self._providers_dict[namespace] = import_module(namespace)
 
-            if hasattr(self._providers_dict[namespace], 'register_with'):
+            if hasattr(self._providers_dict[namespace], "register_with"):
                 self._providers_dict[namespace].register_with(self)
 
     def _coerce_dict_values_to_tuples(self, types_dict):

+ 1 - 2
misago/acl/test.py

@@ -52,8 +52,7 @@ class patch_user_acl(ContextDecorator, ExitStack):
 
     def patch_user_acl(self):
         return patch(
-            "misago.acl.useracl.get_user_acl",
-            side_effect=self.patched_get_user_acl,
+            "misago.acl.useracl.get_user_acl", side_effect=self.patched_get_user_acl
         )
 
 

+ 21 - 27
misago/acl/tests/test_acl_algebra.py

@@ -35,34 +35,28 @@ def test_lowest_non_zero_value_is_returned():
 def test_acls_are_be_added_together():
     test_acls = [
         {
-            'can_see': 0,
-            'can_hear': 0,
-            'max_speed': 10,
-            'min_age': 16,
-            'speed_limit': 50,
+            "can_see": 0,
+            "can_hear": 0,
+            "max_speed": 10,
+            "min_age": 16,
+            "speed_limit": 50,
         },
+        {"can_see": 1, "can_hear": 0, "max_speed": 40, "min_age": 20, "speed_limit": 0},
         {
-            'can_see': 1,
-            'can_hear': 0,
-            'max_speed': 40,
-            'min_age': 20,
-            'speed_limit': 0,
-        },
-        {
-            'can_see': 0,
-            'can_hear': 1,
-            'max_speed': 80,
-            'min_age': 18,
-            'speed_limit': 40,
+            "can_see": 0,
+            "can_hear": 1,
+            "max_speed": 80,
+            "min_age": 18,
+            "speed_limit": 40,
         },
     ]
 
     defaults = {
-        'can_see': 0,
-        'can_hear': 0,
-        'max_speed': 30,
-        'min_age': 18,
-        'speed_limit': 60,
+        "can_see": 0,
+        "can_hear": 0,
+        "max_speed": 30,
+        "min_age": 18,
+        "speed_limit": 60,
     }
 
     acl = algebra.sum_acls(
@@ -75,8 +69,8 @@ def test_acls_are_be_added_together():
         speed_limit=algebra.greater_or_zero,
     )
 
-    assert acl['can_see'] == 1
-    assert acl['can_hear'] == 1
-    assert acl['max_speed'] == 80
-    assert acl['min_age'] == 16
-    assert acl['speed_limit'] == 0
+    assert acl["can_see"] == 1
+    assert acl["can_hear"] == 1
+    assert acl["max_speed"] == 80
+    assert acl["min_age"] == 16
+    assert acl["speed_limit"] == 0

+ 16 - 14
misago/acl/tests/test_getting_user_acl.py

@@ -55,15 +55,17 @@ def test_staffuser_acl_includes_staff_and_superuser_true_status(
 
 
 def test_getter_returns_acl_from_cache(mocker, db, cache_versions, anonymous_user):
-    cache_get = mocker.patch('django.core.cache.cache.get', return_value=dict())
+    cache_get = mocker.patch("django.core.cache.cache.get", return_value=dict())
     get_user_acl(anonymous_user, cache_versions)
     cache_get.assert_called_once()
 
 
-def test_getter_builds_new_acl_when_cache_is_not_available(mocker, cache_versions, user):
-    mocker.patch('django.core.cache.cache.set')
-    mocker.patch('misago.acl.buildacl.build_acl', return_value=dict())
-    cache_get = mocker.patch('django.core.cache.cache.get', return_value=None)
+def test_getter_builds_new_acl_when_cache_is_not_available(
+    mocker, cache_versions, user
+):
+    mocker.patch("django.core.cache.cache.set")
+    mocker.patch("misago.acl.buildacl.build_acl", return_value=dict())
+    cache_get = mocker.patch("django.core.cache.cache.get", return_value=None)
 
     get_user_acl(user, cache_versions)
     cache_get.assert_called_once()
@@ -72,9 +74,9 @@ def test_getter_builds_new_acl_when_cache_is_not_available(mocker, cache_version
 def test_getter_sets_new_cache_if_no_cache_is_set(
     mocker, db, cache_versions, anonymous_user
 ):
-    cache_set = mocker.patch('django.core.cache.cache.set')
-    mocker.patch('misago.acl.buildacl.build_acl', return_value=dict())
-    mocker.patch('django.core.cache.cache.get', return_value=None)
+    cache_set = mocker.patch("django.core.cache.cache.set")
+    mocker.patch("misago.acl.buildacl.build_acl", return_value=dict())
+    mocker.patch("django.core.cache.cache.get", return_value=None)
 
     get_user_acl(anonymous_user, cache_versions)
     cache_set.assert_called_once()
@@ -83,9 +85,9 @@ def test_getter_sets_new_cache_if_no_cache_is_set(
 def test_acl_cache_name_includes_cache_version(
     mocker, db, cache_versions, anonymous_user
 ):
-    cache_set = mocker.patch('django.core.cache.cache.set')
-    mocker.patch('misago.acl.buildacl.build_acl', return_value=dict())
-    mocker.patch('django.core.cache.cache.get', return_value=None)
+    cache_set = mocker.patch("django.core.cache.cache.set")
+    mocker.patch("misago.acl.buildacl.build_acl", return_value=dict())
+    mocker.patch("django.core.cache.cache.get", return_value=None)
 
     get_user_acl(anonymous_user, cache_versions)
     cache_key = cache_set.call_args[0][0]
@@ -95,8 +97,8 @@ def test_acl_cache_name_includes_cache_version(
 def test_getter_is_not_setting_new_cache_if_cache_is_set(
     mocker, cache_versions, anonymous_user
 ):
-    cache_set = mocker.patch('django.core.cache.cache.set')
-    mocker.patch('django.core.cache.cache.get', return_value=dict())
+    cache_set = mocker.patch("django.core.cache.cache.set")
+    mocker.patch("django.core.cache.cache.get", return_value=dict())
 
     get_user_acl(anonymous_user, cache_versions)
-    cache_set.assert_not_called()
+    cache_set.assert_not_called()

+ 1 - 1
misago/acl/tests/test_mock_role_admin_form_data.py

@@ -3,5 +3,5 @@ from misago.acl.test import mock_role_form_data
 
 
 def test_factory_for_change_role_permissions_form_data():
-    test_data = mock_role_form_data(Role(), {'can_fly': 1})
+    test_data = mock_role_form_data(Role(), {"can_fly": 1})
     assert "can_fly" in test_data

+ 4 - 2
misago/acl/tests/test_patching_user_acl.py

@@ -43,7 +43,9 @@ def test_callable_patch_is_called_with_user_and_acl_by_decorator(cache_versions,
     assert user_acl["patched_for_user_id"] == user.id
 
 
-def test_callable_patch_is_called_with_user_and_acl_by_context_manager(cache_versions, user):
+def test_callable_patch_is_called_with_user_and_acl_by_context_manager(
+    cache_versions, user
+):
     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
@@ -64,4 +66,4 @@ def test_multiple_acl_patches_applied_by_context_manager_stack(cache_versions, u
         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
+    assert "acl_patch" not in user_acl

+ 2 - 5
misago/acl/tests/test_providers.py

@@ -33,7 +33,7 @@ def test_loading_providers_second_time_raises_runtime_error():
 def test_container_returns_list_of_providers():
     providers = PermissionProviders()
     providers.load()
-    
+
     providers_setting = settings.MISAGO_ACL_EXTENSIONS
     assert len(providers.list()) == len(providers_setting)
 
@@ -41,7 +41,7 @@ def test_container_returns_list_of_providers():
 def test_container_returns_dict_of_providers():
     providers = PermissionProviders()
     providers.load()
-    
+
     providers_setting = settings.MISAGO_ACL_EXTENSIONS
     assert len(providers.dict()) == len(providers_setting)
 
@@ -62,10 +62,8 @@ def test_getter_returns_registered_type_annotator():
     class TestType(object):
         pass
 
-
     def test_annotator():
         pass
-    
 
     providers = PermissionProviders()
     providers.acl_annotator(TestType, test_annotator)
@@ -85,7 +83,6 @@ def test_getter_returns_registered_user_acl_serializer():
     def test_user_acl_serializer():
         pass
 
-
     providers = PermissionProviders()
     providers.user_acl_serializer(test_user_acl_serializer)
     providers.load()

+ 43 - 59
misago/acl/tests/test_roleadmin_views.py

@@ -14,132 +14,116 @@ def create_data(data_dict):
 class RoleAdminViewsTests(AdminTestCase):
     def test_link_registered(self):
         """admin nav contains user roles link"""
-        response = self.client.get(reverse('misago:admin:permissions:users:index'))
-        self.assertContains(response, reverse('misago:admin:permissions:users:index'))
+        response = self.client.get(reverse("misago:admin:permissions:users:index"))
+        self.assertContains(response, reverse("misago:admin:permissions:users:index"))
 
     def test_list_view(self):
         """roles list view returns 200"""
-        response = self.client.get(reverse('misago:admin:permissions:users:index'))
+        response = self.client.get(reverse("misago:admin:permissions:users:index"))
         self.assertEqual(response.status_code, 200)
 
     def test_new_view(self):
         """new role view has no showstoppers"""
-        response = self.client.get(reverse('misago:admin:permissions:users:new'))
+        response = self.client.get(reverse("misago:admin:permissions:users:new"))
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
-            reverse('misago:admin:permissions:users:new'), data=create_data({
-                'name': 'Test Role',
-            })
+            reverse("misago:admin:permissions:users:new"),
+            data=create_data({"name": "Test Role"}),
         )
         self.assertEqual(response.status_code, 302)
 
-        test_role = Role.objects.get(name='Test Role')
-        response = self.client.get(reverse('misago:admin:permissions:users:index'))
+        test_role = Role.objects.get(name="Test Role")
+        response = self.client.get(reverse("misago:admin:permissions:users:index"))
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, test_role.name)
 
     def test_edit_view(self):
         """edit role view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:permissions:users:new'), data=create_data({
-                'name': 'Test Role',
-            })
+            reverse("misago:admin:permissions:users:new"),
+            data=create_data({"name": "Test Role"}),
         )
 
-        test_role = Role.objects.get(name='Test Role')
+        test_role = Role.objects.get(name="Test Role")
 
         response = self.client.get(
-            reverse('misago:admin:permissions:users:edit', kwargs={
-                'pk': test_role.pk,
-            })
+            reverse("misago:admin:permissions:users:edit", kwargs={"pk": test_role.pk})
         )
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'Test Role')
+        self.assertContains(response, "Test Role")
 
         response = self.client.post(
-            reverse('misago:admin:permissions:users:edit', kwargs={
-                'pk': test_role.pk,
-            }),
-            data=create_data({
-                'name': 'Top Lel',
-            })
+            reverse("misago:admin:permissions:users:edit", kwargs={"pk": test_role.pk}),
+            data=create_data({"name": "Top Lel"}),
         )
         self.assertEqual(response.status_code, 302)
 
-        test_role = Role.objects.get(name='Top Lel')
-        response = self.client.get(reverse('misago:admin:permissions:users:index'))
+        test_role = Role.objects.get(name="Top Lel")
+        response = self.client.get(reverse("misago:admin:permissions:users:index"))
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, test_role.name)
 
     def test_editing_role_invalidates_acl_cache(self):
         self.client.post(
-            reverse('misago:admin:permissions:users:new'), data=create_data({
-                'name': 'Test Role',
-            })
+            reverse("misago:admin:permissions:users:new"),
+            data=create_data({"name": "Test Role"}),
         )
 
-        test_role = Role.objects.get(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=create_data({
-                    'name': 'Top Lel',
-                })
+                reverse(
+                    "misago:admin:permissions:users:edit", kwargs={"pk": test_role.pk}
+                ),
+                data=create_data({"name": "Top Lel"}),
             )
 
     def test_users_view(self):
         """users with this role view has no showstoppers"""
         response = self.client.post(
-            reverse('misago:admin:permissions:users:new'), data=create_data({
-                'name': 'Test Role',
-            })
+            reverse("misago:admin:permissions:users:new"),
+            data=create_data({"name": "Test Role"}),
         )
-        test_role = Role.objects.get(name='Test Role')
+        test_role = Role.objects.get(name="Test Role")
 
         response = self.client.get(
-            reverse('misago:admin:permissions:users:users', kwargs={
-                'pk': test_role.pk,
-            })
+            reverse("misago:admin:permissions:users:users", kwargs={"pk": test_role.pk})
         )
         self.assertEqual(response.status_code, 302)
 
     def test_delete_view(self):
         """delete role view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:permissions:users:new'), data=create_data({
-                'name': 'Test Role',
-            })
+            reverse("misago:admin:permissions:users:new"),
+            data=create_data({"name": "Test Role"}),
         )
 
-        test_role = Role.objects.get(name='Test Role')
+        test_role = Role.objects.get(name="Test Role")
         response = self.client.post(
-            reverse('misago:admin:permissions:users:delete', kwargs={
-                'pk': test_role.pk,
-            })
+            reverse(
+                "misago:admin:permissions:users:delete", kwargs={"pk": test_role.pk}
+            )
         )
         self.assertEqual(response.status_code, 302)
 
         # Get the page twice so no alert is renderered on second request
-        self.client.get(reverse('misago:admin:permissions:users:index'))
-        response = 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"))
         self.assertNotContains(response, test_role.name)
 
     def test_deleting_role_invalidates_acl_cache(self):
         self.client.post(
-            reverse('misago:admin:permissions:users:new'), data=create_data({
-                'name': 'Test Role',
-            })
+            reverse("misago:admin:permissions:users:new"),
+            data=create_data({"name": "Test Role"}),
         )
 
-        test_role = Role.objects.get(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,
-                })
-            )
+                reverse(
+                    "misago:admin:permissions:users:delete", kwargs={"pk": test_role.pk}
+                )
+            )

+ 1 - 1
misago/acl/tests/test_user_acl_context_processor.py

@@ -6,4 +6,4 @@ from misago.acl.context_processors import user_acl
 def test_context_processor_adds_request_user_acl_to_context():
     test_acl = {"test": True}
     context = user_acl(Mock(user_acl=test_acl))
-    assert context == {"user_acl": test_acl}
+    assert context == {"user_acl": test_acl}

+ 11 - 15
misago/acl/views.py

@@ -10,14 +10,14 @@ from .models import Role
 
 
 class RoleAdmin(generic.AdminBaseMixin):
-    root_link = 'misago:admin:permissions:users:index'
+    root_link = "misago:admin:permissions:users:index"
     model = Role
-    templates_dir = 'misago/admin/roles'
+    templates_dir = "misago/admin/roles"
     message_404 = _("Requested role does not exist.")
 
 
 class RolesList(RoleAdmin, generic.ListView):
-    ordering = (('name', None), )
+    ordering = (("name", None),)
 
 
 class RoleFormMixin(object):
@@ -26,7 +26,7 @@ class RoleFormMixin(object):
 
         perms_forms = get_permissions_forms(target)
 
-        if request.method == 'POST':
+        if request.method == "POST":
             perms_forms = get_permissions_forms(target, request.POST)
             valid_forms = 0
             for permissions_form in perms_forms:
@@ -43,9 +43,9 @@ class RoleFormMixin(object):
                 form.instance.permissions = new_permissions
                 form.instance.save()
 
-                messages.success(request, self.message_submit % {'name': target.name})
+                messages.success(request, self.message_submit % {"name": target.name})
 
-                if 'stay' in request.POST:
+                if "stay" in request.POST:
                     return redirect(request.path)
                 else:
                     return redirect(self.root_link)
@@ -53,11 +53,7 @@ class RoleFormMixin(object):
                 form.add_error(None, _("Form contains errors."))
 
         return self.render(
-            request, {
-                'form': form,
-                'target': target,
-                'perms_forms': perms_forms,
-            }
+            request, {"form": form, "target": target, "perms_forms": perms_forms}
         )
 
 
@@ -73,15 +69,15 @@ class DeleteRole(RoleAdmin, generic.ButtonView):
     def check_permissions(self, request, target):
         if target.special_role:
             message = _('Role "%(name)s" is special role and can\'t be deleted.')
-            return message % {'name': target.name}
+            return message % {"name": target.name}
 
     def button_action(self, request, target):
         target.delete()
         message = _('Role "%(name)s" has been deleted.')
-        messages.success(request, message % {'name': target.name})
+        messages.success(request, message % {"name": target.name})
 
 
 class RoleUsers(RoleAdmin, generic.TargetedView):
     def real_dispatch(self, request, target):
-        redirect_url = reverse('misago:admin:users:accounts:index')
-        return redirect('%s?role=%s' % (redirect_url, target.pk))
+        redirect_url = reverse("misago:admin:users:accounts:index")
+        return redirect("%s?role=%s" % (redirect_url, target.pk))

+ 4 - 4
misago/admin/__init__.py

@@ -1,5 +1,5 @@
-from .hierarchy import site  # noqa
-from .urlpatterns import urlpatterns  # noqa
-from .discoverer import discover_misago_admin  # noqa
+from .hierarchy import site
+from .urlpatterns import urlpatterns
+from .discoverer import discover_misago_admin
 
-default_app_config = 'misago.admin.apps.MisagoAdminConfig'
+default_app_config = "misago.admin.apps.MisagoAdminConfig"

+ 3 - 3
misago/admin/admin.py

@@ -9,7 +9,7 @@ class MisagoAdminExtension(MiddlewareMixin):
     def register_navigation_nodes(self, site):
         site.add_node(
             name=_("Home"),
-            icon='fa fa-home',
-            parent='misago:admin',
-            link='misago:admin:index',
+            icon="fa fa-home",
+            parent="misago:admin",
+            link="misago:admin:index",
         )

+ 2 - 2
misago/admin/apps.py

@@ -2,6 +2,6 @@ from django.apps import AppConfig
 
 
 class MisagoAdminConfig(AppConfig):
-    name = 'misago.admin'
-    label = 'misago_admin'
+    name = "misago.admin"
+    label = "misago_admin"
     verbose_name = "Misago Admin"

+ 5 - 5
misago/admin/auth.py

@@ -8,13 +8,13 @@ from django.utils.translation import gettext as _
 from misago.conf import settings
 
 
-KEY_TOKEN = 'misago_admin_session_token'
-KEY_UPDATED = 'misago_admin_session_updated'
+KEY_TOKEN = "misago_admin_session_token"
+KEY_UPDATED = "misago_admin_session_updated"
 
 
 def make_user_admin_token(user):
     formula = (str(user.pk), user.email, user.password, settings.SECRET_KEY)
-    return md5(':'.join(formula).encode()).hexdigest()
+    return md5(":".join(formula).encode()).hexdigest()
 
 
 # Admin session state controls
@@ -60,7 +60,7 @@ logout = dj_auth.logout
 
 # Register signal for logout to make sure eventual admin session is closed
 def django_login_handler(sender, **kwargs):
-    request, user = kwargs['request'], kwargs['user']
+    request, user = kwargs["request"], kwargs["user"]
     try:
         admin_namespace = request.admin_namespace
     except AttributeError:
@@ -73,7 +73,7 @@ dj_auth.signals.user_logged_in.connect(django_login_handler)
 
 
 def django_logout_handler(sender, **kwargs):
-    close_admin_session(kwargs['request'])
+    close_admin_session(kwargs["request"])
 
 
 dj_auth.signals.user_logged_out.connect(django_logout_handler)

+ 4 - 4
misago/admin/discoverer.py

@@ -9,11 +9,11 @@ from .urlpatterns import urlpatterns
 def discover_misago_admin():
     for app in apps.get_app_configs():
         module = import_module(app.name)
-        if not hasattr(module, 'admin'):
+        if not hasattr(module, "admin"):
             continue
 
-        admin_module = import_module('%s.admin' % app.name)
-        if hasattr(admin_module, 'MisagoAdminExtension'):
-            extension = getattr(admin_module, 'MisagoAdminExtension')()
+        admin_module = import_module("%s.admin" % app.name)
+        if hasattr(admin_module, "MisagoAdminExtension"):
+            extension = getattr(admin_module, "MisagoAdminExtension")()
             extension.register_navigation_nodes(site)
             extension.register_urlpatterns(urlpatterns)

+ 7 - 10
misago/admin/forms.py

@@ -5,7 +5,7 @@ from misago.core.utils import parse_iso8601_string
 
 
 class IsoDateTimeField(DateTimeField):
-    input_formats = ['iso8601']
+    input_formats = ["iso8601"]
 
     def prepare_value(self, value):
         try:
@@ -24,13 +24,13 @@ class IsoDateTimeField(DateTimeField):
         try:
             return parse_iso8601_string(value)
         except ValueError:
-            raise ValidationError(self.error_messages['invalid'], code='invalid')
+            raise ValidationError(self.error_messages["invalid"], code="invalid")
 
 
 class YesNoSwitchBase(TypedChoiceField):
     def prepare_value(self, value):
         """normalize bools to binary 1/0 so field works on them too"""
-        if value in (True, 'True', 'true', 1, '1'):
+        if value in (True, "True", "true", 1, "1"):
             return 1
         else:
             return 0
@@ -40,15 +40,12 @@ class YesNoSwitchBase(TypedChoiceField):
 
 
 def YesNoSwitch(**kwargs):
-    yes_label = kwargs.pop('yes_label', _("Yes"))
-    no_label = kwargs.pop('no_label', _("No"))
+    yes_label = kwargs.pop("yes_label", _("Yes"))
+    no_label = kwargs.pop("no_label", _("No"))
 
     return YesNoSwitchBase(
         coerce=int,
-        choices=[
-            (1, yes_label),
-            (0, no_label),
-        ],
-        widget=RadioSelect(attrs={'class': 'yesno-switch'}),
+        choices=[(1, yes_label), (0, no_label)],
+        widget=RadioSelect(attrs={"class": "yesno-switch"}),
         **kwargs
     )

+ 51 - 45
misago/admin/hierarchy.py

@@ -15,8 +15,8 @@ class Node(object):
         try:
             return self._resolved_namespace
         except AttributeError:
-            bits = self.link.split(':')
-            self._resolved_namespace = ':'.join(bits[:-1])
+            bits = self.link.split(":")
+            self._resolved_namespace = ":".join(bits[:-1])
 
         return self._resolved_namespace
 
@@ -26,12 +26,14 @@ class Node(object):
     def children_as_dicts(self):
         childrens = []
         for children in self._children:
-            childrens.append({
-                'name': children.name,
-                'icon': children.icon,
-                'link': reverse(children.link),
-                'namespace': children.namespace,
-            })
+            childrens.append(
+                {
+                    "name": children.name,
+                    "icon": children.icon,
+                    "link": reverse(children.link),
+                    "namespace": children.namespace,
+                }
+            )
         return childrens
 
     def add_node(self, node, after=None, before=None):
@@ -81,7 +83,9 @@ class Node(object):
         try:
             return self._children_dict[namespace]
         except KeyError:
-            raise ValueError("Node %s is not a child of node %s" % (namespace, self.name))
+            raise ValueError(
+                "Node %s is not a child of node %s" % (namespace, self.name)
+            )
 
     def is_root(self):
         return False
@@ -93,31 +97,31 @@ class AdminHierarchyBuilder(object):
         self.nodes_dict = {}
 
     def build_nodes_dict(self):
-        nodes_dict = {'misago:admin': Node(link='misago:admin:index')}
+        nodes_dict = {"misago:admin": Node(link="misago:admin:index")}
 
         iterations = 0
         while self.nodes_record:
             iterations += 1
             if iterations > 512:
-                message = (
-                    "Misago Admin hierarchy is invalid or too complex to resolve. Nodes left: %s"
-                )
+                message = "Misago Admin hierarchy is invalid or too complex to resolve. Nodes left: %s"
                 raise ValueError(message % self.nodes_record)
 
             for index, node in enumerate(self.nodes_record):
-                if node['parent'] in nodes_dict:
-                    node_obj = Node(name=node['name'], icon=node['icon'], link=node['link'])
-
-                    parent = nodes_dict[node['parent']]
-                    if node['after']:
-                        node_added = parent.add_node(node_obj, after=node['after'])
-                    elif node['before']:
-                        node_added = parent.add_node(node_obj, before=node['before'])
+                if node["parent"] in nodes_dict:
+                    node_obj = Node(
+                        name=node["name"], icon=node["icon"], link=node["link"]
+                    )
+
+                    parent = nodes_dict[node["parent"]]
+                    if node["after"]:
+                        node_added = parent.add_node(node_obj, after=node["after"])
+                    elif node["before"]:
+                        node_added = parent.add_node(node_obj, before=node["before"])
                     else:
                         node_added = parent.add_node(node_obj)
 
                     if node_added:
-                        namespace = node.get('namespace') or node_obj.namespace
+                        namespace = node.get("namespace") or node_obj.namespace
 
                         if namespace not in nodes_dict:
                             nodes_dict[namespace] = node_obj
@@ -128,14 +132,14 @@ class AdminHierarchyBuilder(object):
         return nodes_dict
 
     def add_node(
-            self,
-            name=None,
-            icon=None,
-            parent='misago:admin',
-            after=None,
-            before=None,
-            namespace=None,
-            link=None
+        self,
+        name=None,
+        icon=None,
+        parent="misago:admin",
+        after=None,
+        before=None,
+        namespace=None,
+        link=None,
     ):
         if self.nodes_dict:
             raise RuntimeError(
@@ -145,15 +149,17 @@ class AdminHierarchyBuilder(object):
         if after and before:
             raise ValueError("after and before arguments are exclusive")
 
-        self.nodes_record.append({
-            'name': name,
-            'icon': icon,
-            'parent': parent,
-            'namespace': namespace,
-            'after': after,
-            'before': before,
-            'link': link,
-        })
+        self.nodes_record.append(
+            {
+                "name": name,
+                "icon": icon,
+                "parent": parent,
+                "namespace": namespace,
+                "after": after,
+                "before": before,
+                "link": link,
+            }
+        )
 
     def visible_branches(self, request):
         if not self.nodes_dict:
@@ -164,7 +170,7 @@ class AdminHierarchyBuilder(object):
         try:
             namespace = request.resolver_match.namespace
         except AttributeError:
-            namespace = 'misago:admin'
+            namespace = "misago:admin"
 
         if namespace in self.nodes_dict:
             node = self.nodes_dict[namespace]
@@ -177,17 +183,17 @@ class AdminHierarchyBuilder(object):
         try:
             namespaces = request.resolver_match.namespaces
         except AttributeError:
-            namespaces = ['misago', 'admin']
+            namespaces = ["misago", "admin"]
 
         branches.reverse()
         for depth, branch in enumerate(branches):
-            depth_namespace = namespaces[2:3 + depth]
+            depth_namespace = namespaces[2 : 3 + depth]
             for node in branch:
-                node_namespace = node['namespace'].split(':')[2:3 + depth]
+                node_namespace = node["namespace"].split(":")[2 : 3 + depth]
                 if request.resolver_match:
-                    node['is_active'] = depth_namespace == node_namespace
+                    node["is_active"] = depth_namespace == node_namespace
                 else:
-                    node['is_active'] = False
+                    node["is_active"] = False
 
         return branches
 

+ 2 - 2
misago/admin/middleware.py

@@ -13,9 +13,9 @@ class AdminAuthMiddleware(MiddlewareMixin):
         if request.admin_namespace:
             if not auth.is_admin_session(request):
                 auth.close_admin_session(request)
-                if request.resolver_match.url_name == 'index':
+                if request.resolver_match.url_name == "index":
                     return login(request)
                 else:
-                    return redirect('%s:index' % request.admin_namespace)
+                    return redirect("%s:index" % request.admin_namespace)
             else:
                 auth.update_admin_session(request)

+ 2 - 6
misago/admin/templatetags/misago_admin_form.py

@@ -6,11 +6,7 @@ register = template.Library()
 
 @register.inclusion_tag("misago/admin/form/row.html")
 def form_row(field, label_class=None, field_class=None):
-    return {
-        "field": field,
-        "label_class": label_class,
-        "field_class": field_class,
-    }
+    return {"field": field, "label_class": label_class, "field_class": field_class}
 
 
 @register.inclusion_tag("misago/admin/form/input.html")
@@ -25,7 +21,7 @@ def form_input(field):
 @register.simple_tag
 def render_attrs(widget, class_name=None):
     rendered_attrs = []
-    for attr, value in widget['attrs'].items():
+    for attr, value in widget["attrs"].items():
         if value not in (True, False, None):
             rendered_attrs.append((attr, value))
     if not widget["attrs"].get("class") and class_name:

+ 38 - 29
misago/admin/tests/test_admin_form_templatetags.py

@@ -3,31 +3,40 @@ from django.template import Context, Template, TemplateSyntaxError
 from django.test import TestCase
 
 from misago.admin.templatetags.misago_admin_form import (
-    is_radio_select_field, is_select_field, is_multiple_choice_field, is_textarea_field,
-    render_attrs, render_bool_attrs
+    is_radio_select_field,
+    is_select_field,
+    is_multiple_choice_field,
+    is_textarea_field,
+    render_attrs,
+    render_bool_attrs,
 )
 from misago.admin.forms import YesNoSwitch
 
 
 class TestForm(forms.Form):
-    text_field = forms.CharField(label="Hello!", max_length=255, help_text="I am a help text.")
-    textarea_field = forms.CharField(label="Message", max_length=255, widget=forms.Textarea())
-    select_field = forms.ChoiceField(label="Choice", choices=(("y", "Yes"), ("n", "No")))
+    text_field = forms.CharField(
+        label="Hello!", max_length=255, help_text="I am a help text."
+    )
+    textarea_field = forms.CharField(
+        label="Message", max_length=255, widget=forms.Textarea()
+    )
+    select_field = forms.ChoiceField(
+        label="Choice", choices=(("y", "Yes"), ("n", "No"))
+    )
     checkbox_select_field = forms.MultipleChoiceField(
         label="Color",
         choices=(("r", "Red"), ("g", "Green"), ("b", "Blue")),
         widget=forms.CheckboxSelectMultiple,
     )
     multiple_select_field = forms.MultipleChoiceField(
-        label="Rank",
-        choices=(("r", "Red"), ("g", "Green"), ("b", "Blue")),
+        label="Rank", choices=(("r", "Red"), ("g", "Green"), ("b", "Blue"))
     )
     yesno_field = YesNoSwitch(label="Switch")
 
 
 def render(template_str):
     base_template = "{%% load misago_admin_form %%} %s"
-    context = Context({'form': TestForm()})
+    context = Context({"form": TestForm()})
     template = Template(base_template % template_str)
     return template.render(context).strip()
 
@@ -35,27 +44,27 @@ def render(template_str):
 class FormRowTagTests(TestCase):
     def test_row_with_field_input_is_rendered(self):
         html = render("{% form_row form.text_field %}")
-        self.assertIn('id_text_field', html)
+        self.assertIn("id_text_field", html)
 
     def test_row_with_field_input_and_label_css_class_is_rendered(self):
         html = render('{% form_row form.text_field label_class="col-md-3" %}')
-        self.assertIn('id_text_field', html)
-        self.assertIn('col-md-3', html)
+        self.assertIn("id_text_field", html)
+        self.assertIn("col-md-3", html)
 
     def test_row_with_field_input_and_field_css_class_is_rendered(self):
         html = render('{% form_row form.text_field field_class="col-md-9" %}')
-        self.assertIn('id_text_field', html)
-        self.assertIn('col-md-9', html)
+        self.assertIn("id_text_field", html)
+        self.assertIn("col-md-9", html)
 
     def test_row_with_field_input_and_label_andfield_css_classes_is_rendered(self):
         html = render('{% form_row form.text_field "col-md-3" "col-md-9" %}')
-        self.assertIn('id_text_field', html)
-        self.assertIn('col-md-3', html)
-        self.assertIn('col-md-9', html)
+        self.assertIn("id_text_field", html)
+        self.assertIn("col-md-3", html)
+        self.assertIn("col-md-9", html)
 
     def test_tag_without_field_raises_exception(self):
         with self.assertRaises(TemplateSyntaxError):
-            render('{% form_row %}')
+            render("{% form_row %}")
 
     def test_field_label_is_rendered(self):
         html = render("{% form_row form.text_field %}")
@@ -69,49 +78,49 @@ class FormRowTagTests(TestCase):
 class IsRadioSelectFieldFilterTests(TestCase):
     def test_for_field_with_radio_select_widget_filter_returns_true(self):
         form = TestForm()
-        self.assertTrue(is_radio_select_field(form['yesno_field']))
+        self.assertTrue(is_radio_select_field(form["yesno_field"]))
 
     def test_for_field_without_radio_select_widget_filter_returns_false(self):
         form = TestForm()
-        self.assertFalse(is_radio_select_field(form['text_field']))
+        self.assertFalse(is_radio_select_field(form["text_field"]))
 
 
 class IsSelectFieldFilerTests(TestCase):
     def test_for_field_with_select_widget_filter_returns_true(self):
         form = TestForm()
-        self.assertTrue(is_select_field(form['select_field']))
+        self.assertTrue(is_select_field(form["select_field"]))
 
     def teste_for_field_without_select_widget_filter_returns_false(self):
         form = TestForm()
-        self.assertFalse(is_select_field(form['text_field']))
+        self.assertFalse(is_select_field(form["text_field"]))
 
 
 class IsMultipleChoiceFieldFilerTests(TestCase):
     def test_for_field_with_checkbox_select_widget_filter_returns_true(self):
         form = TestForm()
-        self.assertTrue(is_multiple_choice_field(form['checkbox_select_field']))
+        self.assertTrue(is_multiple_choice_field(form["checkbox_select_field"]))
 
     def test_for_field_without_checkbox_select_widget_filter_returns_false(self):
         form = TestForm()
-        self.assertFalse(is_multiple_choice_field(form['text_field']))
+        self.assertFalse(is_multiple_choice_field(form["text_field"]))
 
     def test_for_field_with_multiple_select_widget_filter_returns_true(self):
         form = TestForm()
-        self.assertTrue(is_multiple_choice_field(form['multiple_select_field']))
+        self.assertTrue(is_multiple_choice_field(form["multiple_select_field"]))
 
     def test_for_field_without_multiple_select_widget_filter_returns_false(self):
         form = TestForm()
-        self.assertFalse(is_multiple_choice_field(form['text_field']))
+        self.assertFalse(is_multiple_choice_field(form["text_field"]))
 
 
 class IsTextareaFieldFilterTests(TestCase):
     def test_for_field_with_textarea_widget_filter_returns_true(self):
         form = TestForm()
-        self.assertTrue(is_textarea_field(form['textarea_field']))
+        self.assertTrue(is_textarea_field(form["textarea_field"]))
 
     def test_for_field_without_textarea_widget_filter_returns_false(self):
         form = TestForm()
-        self.assertFalse(is_textarea_field(form['text_field']))
+        self.assertFalse(is_textarea_field(form["text_field"]))
 
 
 class RenderAttrsTagTests(TestCase):
@@ -144,7 +153,7 @@ class RenderAttrsTagTests(TestCase):
         self.assertEqual(result, "")
 
     def test_attr_name_is_escaped(self):
-        result = render_attrs({"attrs": {'"': 'test'}})
+        result = render_attrs({"attrs": {'"': "test"}})
         self.assertEqual(result, '&quot;="test"')
 
     def test_attr_value_is_escaped(self):
@@ -195,4 +204,4 @@ class RenderBoolAttrsTagTests(TestCase):
 
     def test_empty_attr_dict_is_not_rendered(self):
         result = render_bool_attrs({})
-        self.assertEqual(result, "")
+        self.assertEqual(result, "")

+ 10 - 10
misago/admin/tests/test_admin_hierarchy.py

@@ -6,22 +6,22 @@ from misago.admin.hierarchy import Node
 class NodeTests(TestCase):
     def test_add_node(self):
         """add_node added node"""
-        master = Node(name='Apples', link='misago:index')
+        master = Node(name="Apples", link="misago:index")
 
-        child = Node(name='Oranges', link='misago:index')
+        child = Node(name="Oranges", link="misago:index")
         master.add_node(child)
 
         self.assertTrue(child in master.children())
 
     def test_add_node_after(self):
         """add_node added node after specific node"""
-        master = Node(name='Apples', link='misago:index')
+        master = Node(name="Apples", link="misago:index")
 
-        child = Node(name='Oranges', link='misago:index')
+        child = Node(name="Oranges", link="misago:index")
         master.add_node(child)
 
-        test = Node(name='Potatoes', link='misago:index')
-        master.add_node(test, after='misago:index')
+        test = Node(name="Potatoes", link="misago:index")
+        master.add_node(test, after="misago:index")
 
         all_nodes = master.children()
         for i, node in enumerate(all_nodes):
@@ -30,13 +30,13 @@ class NodeTests(TestCase):
 
     def test_add_node_before(self):
         """add_node added node  before specific node"""
-        master = Node(name='Apples', link='misago:index')
+        master = Node(name="Apples", link="misago:index")
 
-        child = Node(name='Oranges', link='misago:index')
+        child = Node(name="Oranges", link="misago:index")
         master.add_node(child)
 
-        test = Node(name='Potatoes', link='misago:index')
-        master.add_node(test, before='misago:index')
+        test = Node(name="Potatoes", link="misago:index")
+        master.add_node(test, before="misago:index")
 
         all_nodes = master.children()
         for i, node in enumerate(all_nodes):

+ 29 - 20
misago/admin/tests/test_admin_index.py

@@ -8,27 +8,27 @@ from misago.admin.views.index import check_misago_address
 class AdminIndexViewTests(AdminTestCase):
     def test_view_returns_200(self):
         """admin index view returns 200"""
-        response = self.client.get(reverse('misago:admin:index'))
+        response = self.client.get(reverse("misago:admin:index"))
 
         self.assertContains(response, self.user.username)
 
     def test_view_contains_address_check(self):
         """admin index view contains address check"""
-        response = self.client.get(reverse('misago:admin:index'))
+        response = self.client.get(reverse("misago:admin:index"))
 
         self.assertContains(response, "MISAGO_ADDRESS")
 
 
 class RequestMock(object):
-    absolute_uri = 'https://misago-project.org/somewhere/'
+    absolute_uri = "https://misago-project.org/somewhere/"
 
     def build_absolute_uri(self, location):
-        assert location == '/'
+        assert location == "/"
         return self.absolute_uri
 
 
 request = RequestMock()
-incorrect_address = 'http://somewhere.com'
+incorrect_address = "http://somewhere.com"
 correct_address = request.absolute_uri
 
 
@@ -38,30 +38,39 @@ class AdminIndexAddressCheckTests(TestCase):
         """check handles address not set"""
         result = check_misago_address(request)
 
-        self.assertEqual(result, {
-            'is_correct': False,
-            'set_address': None,
-            'correct_address': request.absolute_uri,
-        })
+        self.assertEqual(
+            result,
+            {
+                "is_correct": False,
+                "set_address": None,
+                "correct_address": request.absolute_uri,
+            },
+        )
 
     @override_settings(MISAGO_ADDRESS=incorrect_address)
     def test_address_set_invalid(self):
         """check handles incorrect address"""
         result = check_misago_address(request)
 
-        self.assertEqual(result, {
-            'is_correct': False,
-            'set_address': incorrect_address,
-            'correct_address': request.absolute_uri,
-        })
+        self.assertEqual(
+            result,
+            {
+                "is_correct": False,
+                "set_address": incorrect_address,
+                "correct_address": request.absolute_uri,
+            },
+        )
 
     @override_settings(MISAGO_ADDRESS=correct_address)
     def test_address_set_valid(self):
         """check handles correct address"""
         result = check_misago_address(request)
 
-        self.assertEqual(result, {
-            'is_correct': True,
-            'set_address': correct_address,
-            'correct_address': request.absolute_uri,
-        })
+        self.assertEqual(
+            result,
+            {
+                "is_correct": True,
+                "set_address": correct_address,
+                "correct_address": request.absolute_uri,
+            },
+        )

+ 43 - 57
misago/admin/tests/test_admin_views.py

@@ -17,17 +17,17 @@ class MockRequest(object):
 class AdminProtectedNamespaceTests(TestCase):
     def test_valid_cases(self):
         """get_protected_namespace returns true for protected links"""
-        TEST_CASES = ('', 'somewhere/', 'ejksajdlksajldjskajdlksajlkdas', )
-        
-        links_prefix = reverse('misago:admin:index')
-        
+        TEST_CASES = ("", "somewhere/", "ejksajdlksajldjskajdlksajlkdas")
+
+        links_prefix = reverse("misago:admin:index")
+
         for case in TEST_CASES:
             request = MockRequest(links_prefix + case)
-            self.assertEqual(get_protected_namespace(request), 'misago:admin')
+            self.assertEqual(get_protected_namespace(request), "misago:admin")
 
     def test_invalid_cases(self):
         """get_protected_namespace returns none for other links"""
-        TEST_CASES = ('/', '/somewhere/', '/ejksajdlksajldjskajdlksajlkdas', )
+        TEST_CASES = ("/", "/somewhere/", "/ejksajdlksajldjskajdlksajlkdas")
 
         for case in TEST_CASES:
             request = MockRequest(case)
@@ -37,20 +37,16 @@ class AdminProtectedNamespaceTests(TestCase):
 class AdminLoginViewTests(TestCase):
     def test_login_returns_200_on_get(self):
         """unauthenticated request to admin index produces login form"""
-        response = self.client.get(reverse('misago:admin:index'))
+        response = self.client.get(reverse("misago:admin:index"))
 
-        self.assertContains(response, 'Sign in')
-        self.assertContains(response, 'Username or e-mail')
-        self.assertContains(response, 'Password')
+        self.assertContains(response, "Sign in")
+        self.assertContains(response, "Username or e-mail")
+        self.assertContains(response, "Password")
 
     def test_login_returns_200_on_invalid_post(self):
         """form handles invalid data gracefully"""
         response = self.client.post(
-            reverse('misago:admin:index'),
-            data={
-                'username': 'Nope',
-                'password': 'Nope',
-            },
+            reverse("misago:admin:index"), data={"username": "Nope", "password": "Nope"}
         )
 
         self.assertContains(response, "Login or password is incorrect.")
@@ -60,72 +56,60 @@ class AdminLoginViewTests(TestCase):
 
     def test_login_denies_non_staff_non_superuser(self):
         """login rejects user thats non staff and non superuser"""
-        user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
         user.is_staff = False
         user.is_superuser = False
         user.save()
 
         response = self.client.post(
-            reverse('misago:admin:index'),
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            reverse("misago:admin:index"),
+            data={"username": "Bob", "password": "Pass.123"},
         )
 
         self.assertContains(response, "Your account does not have admin privileges.")
 
     def test_login_denies_non_staff_superuser(self):
         """login rejects user thats non staff and superuser"""
-        user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
         user.is_staff = False
         user.is_superuser = True
         user.save()
 
         response = self.client.post(
-            reverse('misago:admin:index'),
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            reverse("misago:admin:index"),
+            data={"username": "Bob", "password": "Pass.123"},
         )
 
         self.assertContains(response, "Your account does not have admin privileges.")
 
     def test_login_signs_in_staff_non_superuser(self):
         """login passess user thats staff and non superuser"""
-        user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
         user.is_staff = True
         user.is_superuser = False
         user.save()
 
         response = self.client.post(
-            reverse('misago:admin:index'),
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            reverse("misago:admin:index"),
+            data={"username": "Bob", "password": "Pass.123"},
         )
 
         self.assertEqual(response.status_code, 302)
 
     def test_login_signs_in_staff_superuser(self):
         """login passess user thats staff and superuser"""
-        user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
         user.is_staff = True
         user.is_superuser = True
         user.save()
 
         response = self.client.post(
-            reverse('misago:admin:index'),
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            reverse("misago:admin:index"),
+            data={"username": "Bob", "password": "Pass.123"},
         )
 
         self.assertEqual(response.status_code, 302)
@@ -134,24 +118,24 @@ class AdminLoginViewTests(TestCase):
 class AdminLogoutTests(AdminTestCase):
     def test_admin_logout(self):
         """admin logout logged from admin only"""
-        response = self.client.post(reverse('misago:admin:logout'))
+        response = self.client.post(reverse("misago:admin:logout"))
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:index'))
+        response = self.client.get(reverse("misago:admin:index"))
         self.assertContains(response, "Your admin session has been closed.")
 
-        response = self.client.get(reverse('misago:index'))
+        response = self.client.get(reverse("misago:index"))
         self.assertContains(response, self.user.username)
 
     def test_complete_logout(self):
         """complete logout logged from both admin and site"""
-        response = self.client.post(reverse('misago:logout'))
+        response = self.client.post(reverse("misago:logout"))
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:index'))
+        response = self.client.get(reverse("misago:admin:index"))
         self.assertContains(response, "Sign in")
 
-        response = self.client.get(reverse('misago:index'))
+        response = self.client.get(reverse("misago:index"))
         self.assertContains(response, "Sign in")
 
 
@@ -162,7 +146,7 @@ class AdminViewAccessTests(AdminTestCase):
         self.user.is_superuser = False
         self.user.save()
 
-        response = self.client.get(reverse('misago:admin:index'))
+        response = self.client.get(reverse("misago:admin:index"))
         self.assertContains(response, "Sign in")
 
     def test_admin_denies_non_staff_superuser(self):
@@ -171,7 +155,7 @@ class AdminViewAccessTests(AdminTestCase):
         self.user.is_superuser = True
         self.user.save()
 
-        response = self.client.get(reverse('misago:admin:index'))
+        response = self.client.get(reverse("misago:admin:index"))
         self.assertContains(response, "Sign in")
 
     def test_admin_passess_in_staff_non_superuser(self):
@@ -180,7 +164,7 @@ class AdminViewAccessTests(AdminTestCase):
         self.user.is_superuser = False
         self.user.save()
 
-        response = self.client.get(reverse('misago:admin:index'))
+        response = self.client.get(reverse("misago:admin:index"))
         self.assertContains(response, self.user.username)
 
     def test_admin_passess_in_staff_superuser(self):
@@ -189,36 +173,38 @@ class AdminViewAccessTests(AdminTestCase):
         self.user.is_superuser = True
         self.user.save()
 
-        response = self.client.get(reverse('misago:admin:index'))
+        response = self.client.get(reverse("misago:admin:index"))
         self.assertContains(response, self.user.username)
 
 
 class Admin404ErrorTests(AdminTestCase):
     def test_list_search_unicode_handling(self):
         """querystring creation handles unicode strings"""
-        test_link = '%stotally-errored/' % reverse('misago:admin:index')
+        test_link = "%stotally-errored/" % reverse("misago:admin:index")
 
         response = self.client.get(test_link)
 
-        self.assertContains(response, "Requested page could not be found.", status_code=404)
+        self.assertContains(
+            response, "Requested page could not be found.", status_code=404
+        )
 
 
 class AdminGenericViewsTests(AdminTestCase):
     def test_view_redirected_queryvar(self):
         """querystring redirected value is handled"""
-        test_link = reverse('misago:admin:users:accounts:index')
+        test_link = reverse("misago:admin:users:accounts:index")
 
         # request resulted in redirect with redirected=1 bit
-        response = self.client.get('%s?username=lorem' % test_link)
+        response = self.client.get("%s?username=lorem" % test_link)
         self.assertEqual(response.status_code, 302)
-        self.assertIn('redirected=1', response['location'])
+        self.assertIn("redirected=1", response["location"])
 
         # request with flag muted redirect
-        response = self.client.get('%s?redirected=1&username=lorem' % test_link)
+        response = self.client.get("%s?redirected=1&username=lorem" % test_link)
         self.assertEqual(response.status_code, 200)
 
     def test_list_search_unicode_handling(self):
         """querystring creation handles unicode strings"""
-        test_link = reverse('misago:admin:users:accounts:index')
-        response = self.client.get('%s?redirected=1&username=%s' % (test_link, 'łut'))
+        test_link = reverse("misago:admin:users:accounts:index")
+        response = self.client.get("%s?redirected=1&username=%s" % (test_link, "łut"))
         self.assertEqual(response.status_code, 200)

+ 9 - 9
misago/admin/tests/test_forms.py

@@ -5,24 +5,24 @@ from misago.admin.forms import YesNoSwitch
 
 
 class YesNoForm(forms.Form):
-    test_field = YesNoSwitch(label='Hello!')
+    test_field = YesNoSwitch(label="Hello!")
 
 
 class YesNoSwitchTests(TestCase):
     def test_valid_inputs(self):
         """YesNoSwitch returns valid values for valid input"""
-        for true in ('1', 'True', 'true', 1, True):
-            form = YesNoForm({'test_field': true})
+        for true in ("1", "True", "true", 1, True):
+            form = YesNoForm({"test_field": true})
             form.full_clean()
-            self.assertEqual(form.cleaned_data['test_field'], 1)
+            self.assertEqual(form.cleaned_data["test_field"], 1)
 
-        for false in ('0', 'False', 'false', 'egebege', False, 0):
-            form = YesNoForm({'test_field': false})
+        for false in ("0", "False", "false", "egebege", False, 0):
+            form = YesNoForm({"test_field": false})
             form.full_clean()
-            self.assertEqual(form.cleaned_data['test_field'], 0)
+            self.assertEqual(form.cleaned_data["test_field"], 0)
 
     def test_dontstripme_input_is_ignored(self):
         """YesNoSwitch returns valid values for invalid input"""
-        form = YesNoForm({'test_field': '221'})
+        form = YesNoForm({"test_field": "221"})
         form.full_clean()
-        self.assertFalse(form.cleaned_data.get('test_field'))
+        self.assertFalse(form.cleaned_data.get("test_field"))

+ 3 - 6
misago/admin/testutils.py

@@ -10,10 +10,7 @@ class AdminTestCase(SuperUserTestCase):
 
     def login_admin(self, user):
         self.client.post(
-            reverse('misago:admin:index'),
-            data={
-                'username': user.email,
-                'password': self.USER_PASSWORD,
-            }
+            reverse("misago:admin:index"),
+            data={"username": user.email, "password": self.USER_PASSWORD},
         )
-        self.client.get(reverse('misago:admin:index'))
+        self.client.get(reverse("misago:admin:index"))

+ 17 - 22
misago/admin/urlpatterns.py

@@ -7,39 +7,34 @@ class URLPatterns(object):
         self._patterns = []
 
     def namespace(self, path, namespace, parent=None):
-        self._namespaces.append({
-            'path': path,
-            'parent': parent,
-            'namespace': namespace,
-        })
+        self._namespaces.append(
+            {"path": path, "parent": parent, "namespace": namespace}
+        )
 
     def patterns(self, namespace, *new_patterns):
-        self._patterns.append({
-            'namespace': namespace,
-            'urlpatterns': new_patterns,
-        })
+        self._patterns.append({"namespace": namespace, "urlpatterns": new_patterns})
 
     def get_child_patterns(self, parent):
-        prefix = '%s:' % parent if parent else ''
+        prefix = "%s:" % parent if parent else ""
 
         namespace_urlpatterns = self.namespace_patterns.get(parent, [])
         for namespace in self._namespaces:
-            if namespace['parent'] == parent:
-                prefixed_namespace = prefix + namespace['namespace']
+            if namespace["parent"] == parent:
+                prefixed_namespace = prefix + namespace["namespace"]
                 child_patterns = self.get_child_patterns(prefixed_namespace)
                 included_patterns = include(
-                    (child_patterns, namespace['namespace']),
-                    namespace=namespace['namespace'],
+                    (child_patterns, namespace["namespace"]),
+                    namespace=namespace["namespace"],
                 )
-                namespace_urlpatterns.append(url(namespace['path'], included_patterns))
+                namespace_urlpatterns.append(url(namespace["path"], included_patterns))
 
         return namespace_urlpatterns
 
     def sum_registered_patters(self):
         all_patterns = {}
         for urls in self._patterns:
-            namespace = urls['namespace']
-            added_patterns = urls['urlpatterns']
+            namespace = urls["namespace"]
+            added_patterns = urls["urlpatterns"]
             all_patterns.setdefault(namespace, []).extend(added_patterns)
 
         self.namespace_patterns = all_patterns
@@ -47,13 +42,13 @@ class URLPatterns(object):
     def build_root_urlpatterns(self):
         root_urlpatterns = []
         for namespace in self._namespaces:
-            if not namespace['parent']:
-                child_patterns = self.get_child_patterns(namespace['namespace'])
+            if not namespace["parent"]:
+                child_patterns = self.get_child_patterns(namespace["namespace"])
                 included_patterns = include(
-                    (child_patterns, namespace['namespace']),
-                    namespace=namespace['namespace'],
+                    (child_patterns, namespace["namespace"]),
+                    namespace=namespace["namespace"],
                 )
-                root_urlpatterns.append(url(namespace['path'], included_patterns))
+                root_urlpatterns.append(url(namespace["path"], included_patterns))
 
         return root_urlpatterns
 

+ 3 - 3
misago/admin/urls.py

@@ -9,9 +9,9 @@ urlpatterns = [
     # "misago:admin:index" link symbolises "root" of Misago admin links space
     # any request with path that falls below this one is assumed to be directed
     # at Misago Admin and will be checked by Misago Admin Middleware
-    url(r'^$', index.admin_index, name='index'),
-    url(r'^resolve-version/$', index.check_version, name='check-version'),
-    url(r'^logout/$', auth.logout, name='logout'),
+    url(r"^$", index.admin_index, name="index"),
+    url(r"^resolve-version/$", index.check_version, name="check-version"),
+    url(r"^logout/$", auth.logout, name="logout"),
 ]
 
 # Discover admin and register patterns

+ 8 - 8
misago/admin/views/__init__.py

@@ -11,7 +11,7 @@ from .auth import login
 def get_protected_namespace(request):
     for namespace in settings.MISAGO_ADMIN_NAMESPACES:
         try:
-            admin_path = reverse('%s:index' % namespace)
+            admin_path = reverse("%s:index" % namespace)
             if request.path.startswith(admin_path):
                 return namespace
         except NoReverseMatch:
@@ -36,19 +36,19 @@ def render(request, template, context=None, error_page=False):
     except IndexError:
         pages = []
 
-    context.update({'sections': sections, 'actions': actions, 'pages': pages})
+    context.update({"sections": sections, "actions": actions, "pages": pages})
 
     if error_page:
         # admittedly haxy solution for displaying navs on error pages
-        context['actions'] = []
-        context['pages'] = []
+        context["actions"] = []
+        context["pages"] = []
         for item in navigation[0]:
-            item['is_active'] = False
+            item["is_active"] = False
     else:
-        context['active_link'] = None
+        context["active_link"] = None
         for item in navigation[-1]:
-            if item['is_active']:
-                context['active_link'] = item
+            if item["is_active"]:
+                context["active_link"] = item
                 break
 
     return dj_render(request, template, context)

+ 11 - 11
misago/admin/views/auth.py

@@ -13,30 +13,30 @@ from misago.users.forms.auth import AdminAuthenticationForm
 @csrf_protect
 @never_cache
 def login(request):
-    if request.admin_namespace == 'misago:admin':
-        target = 'misago'
-    elif request.admin_namespace == 'admin':
-        target = 'django'
+    if request.admin_namespace == "misago:admin":
+        target = "misago"
+    elif request.admin_namespace == "admin":
+        target = "django"
     else:
-        target = 'unknown'
+        target = "unknown"
 
     form = AdminAuthenticationForm(request)
 
-    if request.method == 'POST':
+    if request.method == "POST":
         form = AdminAuthenticationForm(request, data=request.POST)
         if form.is_valid():
             auth.login(request, form.user_cache)
-            return redirect('%s:index' % request.admin_namespace)
+            return redirect("%s:index" % request.admin_namespace)
 
-    return render(request, 'misago/admin/login.html', {'form': form, 'target': target})
+    return render(request, "misago/admin/login.html", {"form": form, "target": target})
 
 
 @csrf_protect
 @never_cache
 def logout(request):
-    if request.method == 'POST':
+    if request.method == "POST":
         auth.close_admin_session(request)
         messages.info(request, _("Your admin session has been closed."))
-        return redirect('misago:index')
+        return redirect("misago:index")
     else:
-        return redirect('misago:admin:index')
+        return redirect("misago:admin:index")

+ 10 - 7
misago/admin/views/errorpages.py

@@ -10,15 +10,18 @@ from . import get_protected_namespace, protected_admin_view, render
 @protected_admin_view
 def _error_page(request, code, exception=None, default_message=None):
     if is_admin_session(request):
-        template_pattern = 'misago/admin/errorpages/%s.html' % code
+        template_pattern = "misago/admin/errorpages/%s.html" % code
 
-        response = render(request, template_pattern, {
-            'message': get_exception_message(exception, default_message),
-        }, error_page=True)
+        response = render(
+            request,
+            template_pattern,
+            {"message": get_exception_message(exception, default_message)},
+            error_page=True,
+        )
         response.status_code = code
         return response
     else:
-        return redirect('misago:admin:index')
+        return redirect("misago:admin:index")
 
 
 def admin_error_page(f):
@@ -38,11 +41,11 @@ def _csrf_failure(request, reason=""):
         update_admin_session(request)
         response = render(
             request,
-            'misago/admin/errorpages/csrf_failure_authenticated.html',
+            "misago/admin/errorpages/csrf_failure_authenticated.html",
             error_page=True,
         )
     else:
-        response = render(request, 'misago/admin/errorpages/csrf_failure.html')
+        response = render(request, "misago/admin/errorpages/csrf_failure.html")
 
     response.status_code = 403
     return response

+ 1 - 1
misago/admin/views/generic/__init__.py

@@ -1,4 +1,4 @@
 from .mixin import AdminBaseMixin
 from .base import AdminView
 from .list import ListView, MassActionError
-from .formsbuttons import (TargetedView, FormView, ModelFormView, ButtonView)
+from .formsbuttons import TargetedView, FormView, ModelFormView, ButtonView

+ 4 - 4
misago/admin/views/generic/base.py

@@ -5,11 +5,11 @@ from misago.admin.views import render
 
 class AdminView(View):
     def final_template(self):
-        return '%s/%s' % (self.templates_dir, self.template)
+        return "%s/%s" % (self.templates_dir, self.template)
 
     def current_link(self, request):
         matched_url = request.resolver_match.url_name
-        return '%s:%s' % (request.resolver_match.namespace, matched_url)
+        return "%s:%s" % (request.resolver_match.namespace, matched_url)
 
     def process_context(self, request, context):
         """simple hook for extending and manipulating template context."""
@@ -18,8 +18,8 @@ class AdminView(View):
     def render(self, request, context=None, template=None):
         context = context or {}
 
-        context['root_link'] = self.root_link
-        context['current_link'] = self.current_link(request)
+        context["root_link"] = self.root_link
+        context["current_link"] = self.current_link(request)
 
         context = self.process_context(request, context)
 

+ 12 - 12
misago/admin/views/generic/formsbuttons.py

@@ -18,7 +18,7 @@ class TargetedView(AdminView):
                 select_for_update = select_for_update.select_for_update()
             # Does not work on Python 3:
             # return select_for_update.get(pk=kwargs[kwargs.keys()[0]])
-            (pk, ) = kwargs.values()
+            (pk,) = kwargs.values()
             return select_for_update.get(pk=pk)
         else:
             return self.get_model()()
@@ -55,13 +55,13 @@ class TargetedView(AdminView):
 
 class FormView(TargetedView):
     form = None
-    template = 'form.html'
+    template = "form.html"
 
     def create_form_type(self, request):
         return self.form
 
     def initialize_form(self, form, request):
-        if request.method == 'POST':
+        if request.method == "POST":
             return form(request.POST, request.FILES)
         else:
             return form()
@@ -75,17 +75,17 @@ class FormView(TargetedView):
         FormType = self.create_form_type(request)
         form = self.initialize_form(FormType, request)
 
-        if request.method == 'POST' and form.is_valid():
+        if request.method == "POST" and form.is_valid():
             response = self.handle_form(form, request)
 
             if response:
                 return response
-            elif 'stay' in request.POST:
+            elif "stay" in request.POST:
                 return redirect(request.path)
             else:
                 return redirect(self.root_link)
 
-        return self.render(request, {'form': form})
+        return self.render(request, {"form": form})
 
 
 class ModelFormView(FormView):
@@ -95,7 +95,7 @@ class ModelFormView(FormView):
         return self.form
 
     def initialize_form(self, form, request, target):
-        if request.method == 'POST':
+        if request.method == "POST":
             return form(request.POST, request.FILES, instance=target)
         else:
             return form(instance=target)
@@ -103,28 +103,28 @@ class ModelFormView(FormView):
     def handle_form(self, form, request, target):
         form.instance.save()
         if self.message_submit:
-            messages.success(request, self.message_submit % {'name': target.name})
+            messages.success(request, self.message_submit % {"name": target.name})
 
     def real_dispatch(self, request, target):
         FormType = self.create_form_type(request, target)
         form = self.initialize_form(FormType, request, target)
 
-        if request.method == 'POST' and form.is_valid():
+        if request.method == "POST" and form.is_valid():
             response = self.handle_form(form, request, target)
 
             if response:
                 return response
-            elif 'stay' in request.POST:
+            elif "stay" in request.POST:
                 return redirect(request.path)
             else:
                 return redirect(self.root_link)
 
-        return self.render(request, {'form': form, 'target': target})
+        return self.render(request, {"form": form, "target": target})
 
 
 class ButtonView(TargetedView):
     def real_dispatch(self, request, target):
-        if request.method == 'POST':
+        if request.method == "POST":
             new_response = self.button_action(request, target)
             if new_response:
                 return new_response

+ 101 - 99
misago/admin/views/generic/list.py

@@ -28,7 +28,8 @@ class ListView(AdminView):
     ordering = tuple of tuples defining allowed orderings
                typles should follow this format: (name, order_by)
     """
-    template = 'list.html'
+
+    template = "list.html"
 
     items_per_page = 0
     ordering = None
@@ -36,32 +37,26 @@ class ListView(AdminView):
     extra_actions = None
     mass_actions = None
 
-    selection_label = _('Selected: 0')
-    empty_selection_label = _('Select items')
+    selection_label = _("Selected: 0")
+    empty_selection_label = _("Select items")
 
     @classmethod
     def add_mass_action(cls, action, name, icon, confirmation=None):
         if not cls.mass_actions:
             cls.mass_actions = []
 
-        cls.extra_actions.append({
-            'action': action,
-            'name': name,
-            'icon': icon,
-            'confirmation': confirmation
-        })
+        cls.extra_actions.append(
+            {"action": action, "name": name, "icon": icon, "confirmation": confirmation}
+        )
 
     @classmethod
     def add_item_action(cls, name, icon, link, style=None):
         if not cls.extra_actions:
             cls.extra_actions = []
 
-        cls.extra_actions.append({
-            'name': name,
-            'icon': icon,
-            'link': link,
-            'style': style,
-        })
+        cls.extra_actions.append(
+            {"name": name, "icon": icon, "link": link, "style": style}
+        )
 
     def get_queryset(self):
         return self.get_model().objects.all()
@@ -73,25 +68,25 @@ class ListView(AdminView):
         refresh_querystring = False
 
         context = {
-            'items': self.get_queryset(),
-            'paginator': None,
-            'page': None,
-            'order_by': [],
-            'order': None,
-            'search_form': None,
-            'active_filters': {},
-            'querystring': '',
-            'query_order': {},
-            'query_filters': {},
-            'selected_items': [],
-            'selection_label': self.selection_label,
-            'empty_selection_label': self.empty_selection_label,
-            'mass_actions': mass_actions_list,
-            'extra_actions': extra_actions_list,
-            'extra_actions_len': len(extra_actions_list),
+            "items": self.get_queryset(),
+            "paginator": None,
+            "page": None,
+            "order_by": [],
+            "order": None,
+            "search_form": None,
+            "active_filters": {},
+            "querystring": "",
+            "query_order": {},
+            "query_filters": {},
+            "selected_items": [],
+            "selection_label": self.selection_label,
+            "empty_selection_label": self.empty_selection_label,
+            "mass_actions": mass_actions_list,
+            "extra_actions": extra_actions_list,
+            "extra_actions_len": len(extra_actions_list),
         }
 
-        if request.method == 'POST' and mass_actions_list:
+        if request.method == "POST" and mass_actions_list:
             try:
                 response = self.handle_mass_action(request, context)
                 if response:
@@ -106,12 +101,15 @@ class ListView(AdminView):
             used_method = self.get_ordering_method_to_use(ordering_methods)
             self.set_ordering_in_context(context, used_method)
 
-            if (ordering_methods['GET'] and ordering_methods['GET'] != ordering_methods['session']):
+            if (
+                ordering_methods["GET"]
+                and ordering_methods["GET"] != ordering_methods["session"]
+            ):
                 # Store GET ordering in session for future requests
                 session_key = self.ordering_session_key
-                request.session[session_key] = ordering_methods['GET']
+                request.session[session_key] = ordering_methods["GET"]
 
-            if context['order_by'] and not ordering_methods['GET']:
+            if context["order_by"] and not ordering_methods["GET"]:
                 # Make view redirect to itself with querystring,
                 # So address ball contains copy-friendly link
                 refresh_querystring = True
@@ -120,24 +118,26 @@ class ListView(AdminView):
         if search_form:
             filtering_methods = self.get_filtering_methods(request, search_form)
             active_filters = self.get_filtering_method_to_use(filtering_methods)
-            if request.GET.get('clear_filters'):
+            if request.GET.get("clear_filters"):
                 # Clear filters from querystring
                 request.session.pop(self.filters_session_key, None)
                 active_filters = {}
             self.apply_filtering_on_context(context, active_filters, search_form)
 
-            if (filtering_methods['GET'] and
-                    filtering_methods['GET'] != filtering_methods['session']):
+            if (
+                filtering_methods["GET"]
+                and filtering_methods["GET"] != filtering_methods["session"]
+            ):
                 # Store GET filters in session for future requests
                 session_key = self.filters_session_key
-                request.session[session_key] = filtering_methods['GET']
-            if request.GET.get('set_filters'):
+                request.session[session_key] = filtering_methods["GET"]
+            if request.GET.get("set_filters"):
                 # Force store filters in session
                 session_key = self.filters_session_key
-                request.session[session_key] = context['active_filters']
+                request.session[session_key] = context["active_filters"]
                 refresh_querystring = True
 
-            if context['active_filters'] and not filtering_methods['GET']:
+            if context["active_filters"] and not filtering_methods["GET"]:
                 # Make view redirect to itself with querystring,
                 # so address bar contains copy-friendly link
                 refresh_querystring = True
@@ -146,12 +146,14 @@ class ListView(AdminView):
 
         if self.items_per_page:
             try:
-                self.paginate_items(context, kwargs.get('page', 0))
+                self.paginate_items(context, kwargs.get("page", 0))
             except EmptyPage:
-                return redirect('%s%s' % (reverse(self.root_link), context['querystring']))
+                return redirect(
+                    "%s%s" % (reverse(self.root_link), context["querystring"])
+                )
 
-        if refresh_querystring and not request.GET.get('redirected'):
-            return redirect('%s%s' % (request.path, context['querystring']))
+        if refresh_querystring and not request.GET.get("redirected"):
+            return redirect("%s%s" % (request.path, context["querystring"]))
 
         return self.render(request, context)
 
@@ -165,11 +167,11 @@ class ListView(AdminView):
         except ValueError:
             page = 1
 
-        context['paginator'] = Paginator(
-            context['items'], self.items_per_page, allow_empty_first_page=True
+        context["paginator"] = Paginator(
+            context["items"], self.items_per_page, allow_empty_first_page=True
         )
-        context['page'] = context['paginator'].page(page)
-        context['items'] = context['page'].object_list
+        context["page"] = context["paginator"].page(page)
+        context["items"] = context["page"].object_list
 
     # Filter list items
     search_form = None
@@ -179,16 +181,16 @@ class ListView(AdminView):
 
     @property
     def filters_session_key(self):
-        return 'misago_admin_%s_filters' % self.root_link
+        return "misago_admin_%s_filters" % self.root_link
 
     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),
+            "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'] = {}
+        if request.GET.get("set_filters"):
+            methods["session"] = {}
 
         return methods
 
@@ -210,35 +212,35 @@ class ListView(AdminView):
         return data
 
     def get_filtering_method_to_use(self, methods):
-        for method in ('GET', 'session'):
+        for method in ("GET", "session"):
             if methods.get(method):
                 return methods.get(method)
         else:
             return {}
 
     def apply_filtering_on_context(self, context, active_filters, search_form):
-        context['active_filters'] = active_filters
-        context['search_form'] = search_form(initial=context['active_filters'])
+        context["active_filters"] = active_filters
+        context["search_form"] = search_form(initial=context["active_filters"])
 
-        if context['active_filters']:
-            context['items'] = context['search_form'].filter_queryset(
-                active_filters, context['items']
+        if context["active_filters"]:
+            context["items"] = context["search_form"].filter_queryset(
+                active_filters, context["items"]
             )
 
     # Order list items
     @property
     def ordering_session_key(self):
-        return 'misago_admin_%s_order_by' % self.root_link
+        return "misago_admin_%s_order_by" % self.root_link
 
     def get_ordering_from_GET(self, request):
-        sort = request.GET.get('sort')
+        sort = request.GET.get("sort")
 
-        if request.GET.get('direction') == 'desc':
-            new_ordering = '-%s' % sort
-        elif request.GET.get('direction') == 'asc':
+        if request.GET.get("direction") == "desc":
+            new_ordering = "-%s" % sort
+        elif request.GET.get("direction") == "asc":
             new_ordering = sort
         else:
-            new_ordering = '?nope'
+            new_ordering = "?nope"
 
         return self.clean_ordering(new_ordering)
 
@@ -255,50 +257,50 @@ class ListView(AdminView):
 
     def get_ordering_methods(self, request):
         return {
-            'GET': self.get_ordering_from_GET(request),
-            'session': self.get_ordering_from_session(request),
-            'default': self.clean_ordering(self.ordering[0][0]),
+            "GET": self.get_ordering_from_GET(request),
+            "session": self.get_ordering_from_session(request),
+            "default": self.clean_ordering(self.ordering[0][0]),
         }
 
     def get_ordering_method_to_use(self, methods):
-        for method in ('GET', 'session', 'default'):
+        for method in ("GET", "session", "default"):
             if methods.get(method):
                 return methods.get(method)
 
     def set_ordering_in_context(self, context, method):
         for order_by, name in self.ordering:
             order_as_dict = {
-                'type': 'desc' if order_by[0] == '-' else 'asc',
-                'order_by': order_by,
-                'name': name,
+                "type": "desc" if order_by[0] == "-" else "asc",
+                "order_by": order_by,
+                "name": name,
             }
 
             if order_by == method:
-                context['order'] = order_as_dict
-                context['items'] = context['items'].order_by(order_as_dict['order_by'])
-            elif order_as_dict['name']:
-                if order_as_dict['type'] == 'desc':
-                    order_as_dict['order_by'] = order_as_dict['order_by'][1:]
-                context['order_by'].append(order_as_dict)
+                context["order"] = order_as_dict
+                context["items"] = context["items"].order_by(order_as_dict["order_by"])
+            elif order_as_dict["name"]:
+                if order_as_dict["type"] == "desc":
+                    order_as_dict["order_by"] = order_as_dict["order_by"][1:]
+                context["order_by"].append(order_as_dict)
 
     # Mass actions
     def handle_mass_action(self, request, context):
         limit = self.items_per_page or 64
-        action = self.select_mass_action(request.POST.get('action'))
-        items = [x for x in request.POST.getlist('selected_items')[:limit]]
+        action = self.select_mass_action(request.POST.get("action"))
+        items = [x for x in request.POST.getlist("selected_items")[:limit]]
 
-        context['selected_items'] = items
-        if not context['selected_items']:
+        context["selected_items"] = items
+        if not context["selected_items"]:
             raise MassActionError(_("You have to select one or more items."))
 
-        action_queryset = context['items'].filter(pk__in=items)
+        action_queryset = context["items"].filter(pk__in=items)
 
         if not action_queryset.exists():
             raise MassActionError(_("You have to select one or more items."))
 
-        action_callable = getattr(self, 'action_%s' % action['action'])
+        action_callable = getattr(self, "action_%s" % action["action"])
 
-        if action.get('is_atomic', True):
+        if action.get("is_atomic", True):
             with transaction.atomic():
                 return action_callable(request, action_queryset)
         else:
@@ -306,7 +308,7 @@ class ListView(AdminView):
 
     def select_mass_action(self, action):
         for definition in self.mass_actions:
-            if definition['action'] == action:
+            if definition["action"] == action:
                 return definition
         else:
             raise MassActionError(_("Action is not allowed."))
@@ -317,26 +319,26 @@ class ListView(AdminView):
         filter_values = {}
         order_values = {}
 
-        if context['active_filters']:
-            filter_values = context['active_filters']
+        if context["active_filters"]:
+            filter_values = context["active_filters"]
             values.update(filter_values)
 
-        if context['order_by']:
+        if context["order_by"]:
             order_values = {
-                'sort': context['order']['order_by'],
-                'direction': context['order']['type'],
+                "sort": context["order"]["order_by"],
+                "direction": context["order"]["type"],
             }
 
-            if order_values['sort'][0] == '-':
+            if order_values["sort"][0] == "-":
                 # We don't start sorting criteria with minus in querystring
-                order_values['sort'] = order_values['sort'][1:]
+                order_values["sort"] = order_values["sort"][1:]
 
             values.update(order_values)
 
         if values:
-            values['redirected'] = 1
-            context['querystring'] = '?%s' % urlencode(values, 'utf-8')
+            values["redirected"] = 1
+            context["querystring"] = "?%s" % urlencode(values, "utf-8")
         if order_values:
-            context['query_order'] = order_values
+            context["query_order"] = order_values
         if filter_values:
-            context['query_filters'] = filter_values
+            context["query_filters"] = filter_values

+ 1 - 0
misago/admin/views/generic/mixin.py

@@ -9,6 +9,7 @@ class AdminBaseMixin(object):
     templates_dir - directory with templates
     message_404 - string used in "requested item not found" messages
     """
+
     model = None
     root_link = None
     templates_dir = None

+ 28 - 29
misago/admin/views/index.py

@@ -20,33 +20,35 @@ UserModel = get_user_model()
 
 def admin_index(request):
     inactive_users_queryset = UserModel.objects.exclude(
-        requires_activation=UserModel.ACTIVATION_NONE,
+        requires_activation=UserModel.ACTIVATION_NONE
     )
 
     db_stats = {
-        'threads': Thread.objects.count(),
-        'posts': Post.objects.count(),
-        'users': UserModel.objects.count(),
-        'inactive_users': inactive_users_queryset.count()
+        "threads": Thread.objects.count(),
+        "posts": Post.objects.count(),
+        "users": UserModel.objects.count(),
+        "inactive_users": inactive_users_queryset.count(),
     }
 
     return render(
-        request, 'misago/admin/index.html', {
-            'db_stats': db_stats,
-            'address_check': check_misago_address(request),
-            'version_check': cache.get(VERSION_CHECK_CACHE_KEY),
-        }
+        request,
+        "misago/admin/index.html",
+        {
+            "db_stats": db_stats,
+            "address_check": check_misago_address(request),
+            "version_check": cache.get(VERSION_CHECK_CACHE_KEY),
+        },
     )
 
 
 def check_misago_address(request):
     set_address = settings.MISAGO_ADDRESS
-    correct_address = request.build_absolute_uri('/')
+    correct_address = request.build_absolute_uri("/")
 
     return {
-        'is_correct': set_address == correct_address,
-        'set_address': set_address,
-        'correct_address': correct_address,
+        "is_correct": set_address == correct_address,
+        "set_address": set_address,
+        "correct_address": correct_address,
     }
 
 
@@ -54,37 +56,34 @@ def check_version(request):
     if request.method != "POST":
         raise Http404()
 
-    version = cache.get(VERSION_CHECK_CACHE_KEY, 'nada')
+    version = cache.get(VERSION_CHECK_CACHE_KEY, "nada")
 
-    if version == 'nada':
+    if version == "nada":
         try:
-            api_url = 'https://pypi.org/pypi/Misago/json'
+            api_url = "https://pypi.org/pypi/Misago/json"
             r = requests.get(api_url)
             r.raise_for_status()
 
-            latest_version = r.json()['info']['version']
+            latest_version = r.json()["info"]["version"]
 
             if latest_version == __version__:
                 version = {
-                    'is_error': False,
-                    'message': _("Up to date! (%(current)s)") % {
-                        'current': __version__,
-                    },
+                    "is_error": False,
+                    "message": _("Up to date! (%(current)s)")
+                    % {"current": __version__},
                 }
             else:
                 version = {
-                    'is_error': True,
-                    'message': _("Outdated: %(current)s! (latest: %(latest)s)") % {
-                        'latest': latest_version,
-                        'current': __version__,
-                    },
+                    "is_error": True,
+                    "message": _("Outdated: %(current)s! (latest: %(latest)s)")
+                    % {"latest": latest_version, "current": __version__},
                 }
 
             cache.set(VERSION_CHECK_CACHE_KEY, version, 180)
         except (RequestException, IndexError, KeyError, ValueError) as e:
             version = {
-                'is_error': True,
-                'message': _("Failed to connect to pypi.org API. Try again later."),
+                "is_error": True,
+                "message": _("Failed to connect to pypi.org API. Try again later."),
             }
 
     return JsonResponse(version)

+ 1 - 1
misago/cache/__init__.py

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

+ 2 - 2
misago/cache/apps.py

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

+ 1 - 1
misago/cache/management/commands/invalidateversionedcaches.py

@@ -4,7 +4,7 @@ from misago.cache.versions import invalidate_all_caches
 
 
 class Command(BaseCommand):
-    help = 'Invalidates versioned caches'
+    help = "Invalidates versioned caches"
 
     def handle(self, *args, **options):
         invalidate_all_caches()

+ 1 - 0
misago/cache/middleware.py

@@ -3,6 +3,7 @@ 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)

+ 13 - 6
misago/cache/migrations/0001_initial.py

@@ -8,15 +8,22 @@ class Migration(migrations.Migration):
 
     initial = True
 
-    dependencies = [
-    ]
+    dependencies = []
 
     operations = [
         migrations.CreateModel(
-            name='CacheVersion',
+            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)),
+                (
+                    "cache",
+                    models.CharField(max_length=128, primary_key=True, serialize=False),
+                ),
+                (
+                    "version",
+                    models.CharField(
+                        default=misago.cache.utils.generate_version_string, max_length=8
+                    ),
+                ),
             ],
-        ),
+        )
     ]

+ 5 - 3
misago/cache/operations.py

@@ -17,13 +17,15 @@ class StopCacheVersioning(RunPython):
 
 def start_cache_versioning(cache):
     def migration_operation(apps, _):
-        CacheVersion = apps.get_model('misago_cache', 'CacheVersion')
+        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 = apps.get_model("misago_cache", "CacheVersion")
         CacheVersion.objects.filter(cache=cache).delete()
-    return migration_operation
+
+    return migration_operation

+ 0 - 1
misago/cache/test.py

@@ -18,4 +18,3 @@ class assert_invalidates_cache:
             if cache == self.cache:
                 message = "cache %s was not invalidated" % cache
                 assert self.versions[cache] != version, message
-        

+ 1 - 1
misago/cache/tests/conftest.py

@@ -5,4 +5,4 @@ from misago.cache.models import CacheVersion
 
 @pytest.fixture
 def cache_version(db):
-    return CacheVersion.objects.create(cache="test_cache")
+    return CacheVersion.objects.create(cache="test_cache")

+ 14 - 14
misago/cache/tests/test_getting_cache_versions.py

@@ -1,6 +1,8 @@
 from misago.cache.versions import (
-    CACHE_NAME, get_cache_versions, get_cache_versions_from_cache,
-    get_cache_versions_from_db
+    CACHE_NAME,
+    get_cache_versions,
+    get_cache_versions_from_cache,
+    get_cache_versions_from_db,
 )
 
 
@@ -10,23 +12,21 @@ def test_db_getter_returns_cache_versions_from_db(db, django_assert_num_queries)
 
 
 def test_cache_getter_returns_cache_versions_from_cache(mocker):
-    cache_get = mocker.patch('django.core.cache.cache.get', return_value=True)
+    cache_get = mocker.patch("django.core.cache.cache.get", return_value=True)
     assert get_cache_versions_from_cache() is True
     cache_get.assert_called_once_with(CACHE_NAME)
 
 
 def test_getter_reads_from_cache(mocker, django_assert_num_queries):
-    cache_get = mocker.patch('django.core.cache.cache.get', return_value=True)
+    cache_get = mocker.patch("django.core.cache.cache.get", return_value=True)
     with django_assert_num_queries(0):
         assert get_cache_versions() is True
     cache_get.assert_called_once_with(CACHE_NAME)
 
 
-def test_getter_reads_from_db_if_no_cache_is_set(
-    db, mocker, django_assert_num_queries
-):
-    mocker.patch('django.core.cache.cache.set')
-    cache_get = mocker.patch('django.core.cache.cache.get', return_value=None)
+def test_getter_reads_from_db_if_no_cache_is_set(db, mocker, django_assert_num_queries):
+    mocker.patch("django.core.cache.cache.set")
+    cache_get = mocker.patch("django.core.cache.cache.get", return_value=None)
 
     db_caches = get_cache_versions_from_db()
     with django_assert_num_queries(1):
@@ -35,8 +35,8 @@ def test_getter_reads_from_db_if_no_cache_is_set(
 
 
 def test_getter_sets_new_cache_if_no_cache_is_set(db, mocker):
-    cache_set = mocker.patch('django.core.cache.cache.set')
-    mocker.patch('django.core.cache.cache.get', return_value=None)
+    cache_set = mocker.patch("django.core.cache.cache.set")
+    mocker.patch("django.core.cache.cache.get", return_value=None)
 
     get_cache_versions()
     db_caches = get_cache_versions_from_db()
@@ -44,7 +44,7 @@ def test_getter_sets_new_cache_if_no_cache_is_set(db, mocker):
 
 
 def test_getter_is_not_setting_new_cache_if_cache_is_set(mocker):
-    cache_set = mocker.patch('django.core.cache.cache.set')
-    mocker.patch('django.core.cache.cache.get', return_value=True)
+    cache_set = mocker.patch("django.core.cache.cache.set")
+    mocker.patch("django.core.cache.cache.get", return_value=True)
     get_cache_versions()
-    cache_set.assert_not_called()
+    cache_set.assert_not_called()

+ 1 - 1
misago/cache/tests/test_invalidate_caches_management_command.py

@@ -5,5 +5,5 @@ from django.core.management import call_command
 
 def test_management_command_invalidates_all_caches(mocker):
     invalidate_all_caches = mocker.patch("misago.cache.versions.invalidate_all_caches")
-    call_command('invalidateversionedcaches', stdout=Mock())
+    call_command("invalidateversionedcaches", stdout=Mock())
     invalidate_all_caches.assert_called_once()

+ 8 - 6
misago/cache/tests/test_invalidating_caches.py

@@ -1,17 +1,17 @@
 import pytest
 
-from misago.cache.versions import (
-    CACHE_NAME, invalidate_cache, invalidate_all_caches
-)
+from misago.cache.versions import CACHE_NAME, invalidate_cache, invalidate_all_caches
 from misago.cache.models import CacheVersion
 
 
 @pytest.fixture
 def cache_delete(mocker):
-    return mocker.patch('django.core.cache.cache.delete')
+    return mocker.patch("django.core.cache.cache.delete")
 
 
-def test_invalidating_cache_updates_cache_version_in_database(cache_delete, cache_version):
+def test_invalidating_cache_updates_cache_version_in_database(
+    cache_delete, cache_version
+):
     invalidate_cache(cache_version.cache)
     updated_cache_version = CacheVersion.objects.get(cache=cache_version.cache)
     assert cache_version.version != updated_cache_version.version
@@ -22,7 +22,9 @@ def test_invalidating_cache_deletes_versions_cache(cache_delete, cache_version):
     cache_delete.assert_called_once_with(CACHE_NAME)
 
 
-def test_invalidating_all_caches_updates_cache_version_in_database(cache_delete, cache_version):
+def test_invalidating_all_caches_updates_cache_version_in_database(
+    cache_delete, cache_version
+):
     invalidate_all_caches()
     updated_cache_version = CacheVersion.objects.get(cache=cache_version.cache)
     assert cache_version.version != updated_cache_version.version

+ 2 - 2
misago/cache/versions.py

@@ -25,7 +25,7 @@ def get_cache_versions_from_db():
 
 def invalidate_cache(cache_name):
     CacheVersion.objects.filter(cache=cache_name).update(
-        version=generate_version_string(),
+        version=generate_version_string()
     )
     cache.delete(CACHE_NAME)
 
@@ -33,6 +33,6 @@ def invalidate_cache(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(),
+            version=generate_version_string()
         )
     cache.delete(CACHE_NAME)

+ 1 - 1
misago/categories/__init__.py

@@ -1,3 +1,3 @@
 from .constants import *
 
-default_app_config = 'misago.categories.apps.MisagoCategoriesConfig'
+default_app_config = "misago.categories.apps.MisagoCategoriesConfig"

+ 54 - 35
misago/categories/admin.py

@@ -2,67 +2,86 @@ from django.conf.urls import url
 from django.utils.translation import gettext_lazy as _
 
 from .views.categoriesadmin import (
-    CategoriesList, DeleteCategory, EditCategory, MoveDownCategory, MoveUpCategory, NewCategory)
+    CategoriesList,
+    DeleteCategory,
+    EditCategory,
+    MoveDownCategory,
+    MoveUpCategory,
+    NewCategory,
+)
 from .views.permsadmin import (
-    CategoryPermissions, CategoryRolesList, DeleteCategoryRole, EditCategoryRole, NewCategoryRole,
-    RoleCategoriesACL)
+    CategoryPermissions,
+    CategoryRolesList,
+    DeleteCategoryRole,
+    EditCategoryRole,
+    NewCategoryRole,
+    RoleCategoriesACL,
+)
 
 
 class MisagoAdminExtension(object):
     def register_urlpatterns(self, urlpatterns):
         # Categories section
-        urlpatterns.namespace(r'^categories/', 'categories')
+        urlpatterns.namespace(r"^categories/", "categories")
 
         # Nodes
-        urlpatterns.namespace(r'^nodes/', 'nodes', 'categories')
+        urlpatterns.namespace(r"^nodes/", "nodes", "categories")
         urlpatterns.patterns(
-            'categories:nodes',
-            url(r'^$', CategoriesList.as_view(), name='index'),
-            url(r'^new/$', NewCategory.as_view(), name='new'),
-            url(r'^edit/(?P<pk>\d+)/$', EditCategory.as_view(), name='edit'),
-            url(r'^permissions/(?P<pk>\d+)/$', CategoryPermissions.as_view(), name='permissions'),
-            url(r'^move/down/(?P<pk>\d+)/$', MoveDownCategory.as_view(), name='down'),
-            url(r'^move/up/(?P<pk>\d+)/$', MoveUpCategory.as_view(), name='up'),
-            url(r'^delete/(?P<pk>\d+)/$', DeleteCategory.as_view(), name='delete'),
+            "categories:nodes",
+            url(r"^$", CategoriesList.as_view(), name="index"),
+            url(r"^new/$", NewCategory.as_view(), name="new"),
+            url(r"^edit/(?P<pk>\d+)/$", EditCategory.as_view(), name="edit"),
+            url(
+                r"^permissions/(?P<pk>\d+)/$",
+                CategoryPermissions.as_view(),
+                name="permissions",
+            ),
+            url(r"^move/down/(?P<pk>\d+)/$", MoveDownCategory.as_view(), name="down"),
+            url(r"^move/up/(?P<pk>\d+)/$", MoveUpCategory.as_view(), name="up"),
+            url(r"^delete/(?P<pk>\d+)/$", DeleteCategory.as_view(), name="delete"),
         )
 
         # Category Roles
-        urlpatterns.namespace(r'^categories/', 'categories', 'permissions')
+        urlpatterns.namespace(r"^categories/", "categories", "permissions")
         urlpatterns.patterns(
-            'permissions:categories',
-            url(r'^$', CategoryRolesList.as_view(), name='index'),
-            url(r'^new/$', NewCategoryRole.as_view(), name='new'),
-            url(r'^edit/(?P<pk>\d+)/$', EditCategoryRole.as_view(), name='edit'),
-            url(r'^delete/(?P<pk>\d+)/$', DeleteCategoryRole.as_view(), name='delete'),
+            "permissions:categories",
+            url(r"^$", CategoryRolesList.as_view(), name="index"),
+            url(r"^new/$", NewCategoryRole.as_view(), name="new"),
+            url(r"^edit/(?P<pk>\d+)/$", EditCategoryRole.as_view(), name="edit"),
+            url(r"^delete/(?P<pk>\d+)/$", DeleteCategoryRole.as_view(), name="delete"),
         )
 
         # Change Role Category Permissions
         urlpatterns.patterns(
-            'permissions:users',
-            url(r'^categories/(?P<pk>\d+)/$', RoleCategoriesACL.as_view(), name='categories'),
+            "permissions:users",
+            url(
+                r"^categories/(?P<pk>\d+)/$",
+                RoleCategoriesACL.as_view(),
+                name="categories",
+            ),
         )
 
     def register_navigation_nodes(self, site):
         site.add_node(
             name=_("Categories"),
-            icon='fa fa-comments',
-            parent='misago:admin',
-            before='misago:admin:permissions:users:index',
-            namespace='misago:admin:categories',
-            link='misago:admin:categories:nodes:index',
+            icon="fa fa-comments",
+            parent="misago:admin",
+            before="misago:admin:permissions:users:index",
+            namespace="misago:admin:categories",
+            link="misago:admin:categories:nodes:index",
         )
         site.add_node(
             name=_("Categories hierarchy"),
-            icon='fa fa-sitemap',
-            parent='misago:admin:categories',
-            namespace='misago:admin:categories:nodes',
-            link='misago:admin:categories:nodes:index',
+            icon="fa fa-sitemap",
+            parent="misago:admin:categories",
+            namespace="misago:admin:categories:nodes",
+            link="misago:admin:categories:nodes:index",
         )
         site.add_node(
             name=_("Category roles"),
-            icon='fa fa-comments-o',
-            parent='misago:admin:permissions',
-            after='misago:admin:permissions:users:index',
-            namespace='misago:admin:permissions:categories',
-            link='misago:admin:permissions:categories:index',
+            icon="fa fa-comments-o",
+            parent="misago:admin:permissions",
+            after="misago:admin:permissions:users:index",
+            namespace="misago:admin:permissions:categories",
+            link="misago:admin:permissions:categories:index",
         )

+ 3 - 1
misago/categories/api.py

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

+ 2 - 2
misago/categories/apps.py

@@ -2,8 +2,8 @@ from django.apps import AppConfig
 
 
 class MisagoCategoriesConfig(AppConfig):
-    name = 'misago.categories'
-    label = 'misago_categories'
+    name = "misago.categories"
+    label = "misago_categories"
     verbose_name = "Misago Categories"
 
     def ready(self):

+ 2 - 2
misago/categories/constants.py

@@ -1,2 +1,2 @@
-PRIVATE_THREADS_ROOT_NAME = 'private_threads'
-THREADS_ROOT_NAME = 'root_category'
+PRIVATE_THREADS_ROOT_NAME = "private_threads"
+THREADS_ROOT_NAME = "root_category"

+ 59 - 49
misago/categories/forms.py

@@ -15,15 +15,15 @@ from .models import Category, CategoryRole
 
 class AdminCategoryFieldMixin(object):
     def __init__(self, *args, **kwargs):
-        self.base_level = kwargs.pop('base_level', 1)
-        kwargs['level_indicator'] = kwargs.get('level_indicator', '- - ')
+        self.base_level = kwargs.pop("base_level", 1)
+        kwargs["level_indicator"] = kwargs.get("level_indicator", "- - ")
 
         threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
         queryset = Category.objects.filter(tree_id=threads_tree_id)
-        if not kwargs.pop('include_root', False):
+        if not kwargs.pop("include_root", False):
             queryset = queryset.exclude(special_role="root_category")
 
-        kwargs.setdefault('queryset', queryset)
+        kwargs.setdefault("queryset", queryset)
 
         super().__init__(*args, **kwargs)
 
@@ -32,14 +32,16 @@ class AdminCategoryFieldMixin(object):
         if level > 0:
             return mark_safe(conditional_escape(self.level_indicator) * level)
         else:
-            return ''
+            return ""
 
 
 class AdminCategoryChoiceField(AdminCategoryFieldMixin, TreeNodeChoiceField):
     pass
 
 
-class AdminCategoryMultipleChoiceField(AdminCategoryFieldMixin, TreeNodeMultipleChoiceField):
+class AdminCategoryMultipleChoiceField(
+    AdminCategoryFieldMixin, TreeNodeMultipleChoiceField
+):
     pass
 
 
@@ -49,7 +51,7 @@ class CategoryFormBase(forms.ModelForm):
         label=_("Description"),
         max_length=2048,
         required=False,
-        widget=forms.Textarea(attrs={'rows': 3}),
+        widget=forms.Textarea(attrs={"rows": 3}),
         help_text=_("Optional description explaining category intented purpose."),
     )
     css_class = forms.CharField(
@@ -62,7 +64,9 @@ class CategoryFormBase(forms.ModelForm):
     is_closed = YesNoSwitch(
         label=_("Closed category"),
         required=False,
-        help_text=_("Only members with valid permissions can post in closed categories."),
+        help_text=_(
+            "Only members with valid permissions can post in closed categories."
+        ),
     )
     css_class = forms.CharField(
         label=_("CSS class"),
@@ -74,12 +78,16 @@ class CategoryFormBase(forms.ModelForm):
     require_threads_approval = YesNoSwitch(
         label=_("Threads"),
         required=False,
-        help_text=_("All threads started in this category will require moderator approval."),
+        help_text=_(
+            "All threads started in this category will require moderator approval."
+        ),
     )
     require_replies_approval = YesNoSwitch(
         label=_("Replies"),
         required=False,
-        help_text=_("All replies posted in this category will require moderator approval."),
+        help_text=_(
+            "All replies posted in this category will require moderator approval."
+        ),
     )
     require_edits_approval = YesNoSwitch(
         label=_("Edits"),
@@ -108,27 +116,27 @@ class CategoryFormBase(forms.ModelForm):
     class Meta:
         model = Category
         fields = [
-            'name',
-            'description',
-            'css_class',
-            'is_closed',
-            'require_threads_approval',
-            'require_replies_approval',
-            'require_edits_approval',
-            'prune_started_after',
-            'prune_replied_after',
-            'archive_pruned_in',
+            "name",
+            "description",
+            "css_class",
+            "is_closed",
+            "require_threads_approval",
+            "require_replies_approval",
+            "require_edits_approval",
+            "prune_started_after",
+            "prune_replied_after",
+            "archive_pruned_in",
         ]
 
     def clean_copy_permissions(self):
-        data = self.cleaned_data['copy_permissions']
+        data = self.cleaned_data["copy_permissions"]
         if data and data.pk == self.instance.pk:
             message = _("Permissions cannot be copied from category into itself.")
             raise forms.ValidationError(message)
         return data
 
     def clean_archive_pruned_in(self):
-        data = self.cleaned_data['archive_pruned_in']
+        data = self.cleaned_data["archive_pruned_in"]
         if data and data.pk == self.instance.pk:
             message = _("Category cannot act as archive for itself.")
             raise forms.ValidationError(message)
@@ -136,26 +144,28 @@ class CategoryFormBase(forms.ModelForm):
 
     def clean(self):
         data = super().clean()
-        self.instance.set_name(data.get('name'))
+        self.instance.set_name(data.get("name"))
         return data
 
 
 def CategoryFormFactory(instance):
-    parent_queryset = Category.objects.all_categories(True).order_by('lft')
+    parent_queryset = Category.objects.all_categories(True).order_by("lft")
     if instance.pk:
         not_siblings = models.Q(lft__lt=instance.lft)
         not_siblings = not_siblings | models.Q(rght__gt=instance.rght)
         parent_queryset = parent_queryset.filter(not_siblings)
 
     return type(
-        'CategoryFormFinal', (CategoryFormBase, ), {
-            'new_parent': AdminCategoryChoiceField(
+        "CategoryFormFinal",
+        (CategoryFormBase,),
+        {
+            "new_parent": AdminCategoryChoiceField(
                 label=_("Parent category"),
                 queryset=parent_queryset,
                 initial=instance.parent,
                 empty_label=None,
             ),
-            'copy_permissions': AdminCategoryChoiceField(
+            "copy_permissions": AdminCategoryChoiceField(
                 label=_("Copy permissions"),
                 help_text=_(
                     "You can replace this category permissions with "
@@ -165,7 +175,7 @@ def CategoryFormFactory(instance):
                 empty_label=_("Don't copy permissions"),
                 required=False,
             ),
-            'archive_pruned_in': AdminCategoryChoiceField(
+            "archive_pruned_in": AdminCategoryChoiceField(
                 label=_("Archive"),
                 help_text=_(
                     "Instead of being deleted, pruned threads can be "
@@ -175,7 +185,7 @@ def CategoryFormFactory(instance):
                 empty_label=_("Don't archive pruned threads"),
                 required=False,
             ),
-        }
+        },
     )
 
 
@@ -187,13 +197,13 @@ class DeleteCategoryFormBase(forms.ModelForm):
     def clean(self):
         data = super().clean()
 
-        if data.get('move_threads_to'):
-            if data['move_threads_to'].pk == self.instance.pk:
+        if data.get("move_threads_to"):
+            if data["move_threads_to"].pk == self.instance.pk:
                 message = _("You are trying to move this category threads to itself.")
                 raise forms.ValidationError(message)
 
-            moving_to_child = self.instance.has_child(data['move_threads_to'])
-            if moving_to_child and not data.get('move_children_to'):
+            moving_to_child = self.instance.has_child(data["move_threads_to"])
+            if moving_to_child and not data.get("move_children_to"):
                 message = _(
                     "You are trying to move this category threads to a "
                     "child category that will be deleted together with "
@@ -205,13 +215,13 @@ class DeleteCategoryFormBase(forms.ModelForm):
 
 
 def DeleteFormFactory(instance):
-    content_queryset = Category.objects.all_categories().order_by('lft')
+    content_queryset = Category.objects.all_categories().order_by("lft")
     fields = {
-        'move_threads_to': AdminCategoryChoiceField(
+        "move_threads_to": AdminCategoryChoiceField(
             label=_("Move category threads to"),
             queryset=content_queryset,
             initial=instance.parent,
-            empty_label=_('Delete with category'),
+            empty_label=_("Delete with category"),
             required=False,
         )
     }
@@ -219,17 +229,17 @@ def DeleteFormFactory(instance):
     not_siblings = models.Q(lft__lt=instance.lft)
     not_siblings = not_siblings | models.Q(rght__gt=instance.rght)
     children_queryset = Category.objects.all_categories(True)
-    children_queryset = children_queryset.filter(not_siblings).order_by('lft')
+    children_queryset = children_queryset.filter(not_siblings).order_by("lft")
 
     if children_queryset.exists():
-        fields['move_children_to'] = AdminCategoryChoiceField(
+        fields["move_children_to"] = AdminCategoryChoiceField(
             label=_("Move child categories to"),
             queryset=children_queryset,
-            empty_label=_('Delete with category'),
+            empty_label=_("Delete with category"),
             required=False,
         )
 
-    return type('DeleteCategoryFormFinal', (DeleteCategoryFormBase, ), fields)
+    return type("DeleteCategoryFormFinal", (DeleteCategoryFormBase,), fields)
 
 
 class CategoryRoleForm(forms.ModelForm):
@@ -237,34 +247,34 @@ class CategoryRoleForm(forms.ModelForm):
 
     class Meta:
         model = CategoryRole
-        fields = ['name']
+        fields = ["name"]
 
 
 def RoleCategoryACLFormFactory(category, category_roles, selected_role):
     attrs = {
-        'category': category,
-        'role': forms.ModelChoiceField(
+        "category": category,
+        "role": forms.ModelChoiceField(
             label=_("Role"),
             required=False,
             queryset=category_roles,
             initial=selected_role,
             empty_label=_("No access"),
-        )
+        ),
     }
 
-    return type('RoleCategoryACLForm', (forms.Form, ), attrs)
+    return type("RoleCategoryACLForm", (forms.Form,), attrs)
 
 
 def CategoryRolesACLFormFactory(role, category_roles, selected_role):
     attrs = {
-        'role': role,
-        'category_role': forms.ModelChoiceField(
+        "role": role,
+        "category_role": forms.ModelChoiceField(
             label=_("Role"),
             required=False,
             queryset=category_roles,
             initial=selected_role,
             empty_label=_("No access"),
-        )
+        ),
     }
 
-    return type('CategoryRolesACLForm', (forms.Form, ), attrs)
+    return type("CategoryRolesACLForm", (forms.Form,), attrs)

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

@@ -11,7 +11,8 @@ class Command(BaseCommand):
     in the database causing MPTT's nested sets to not align correctly.
     A typical case is when injecting default data into the database from outside misago.
     """
-    help = 'Rebuilds the thread category tree'
+
+    help = "Rebuilds the thread category tree"
 
     def handle(self, *args, **options):
         root = Category.objects.root_category()

+ 3 - 2
misago/categories/management/commands/prunecategories.py

@@ -11,7 +11,8 @@ class Command(BaseCommand):
     This command is intended to work as CRON job fired
     every few days (or more often) to execute categories pruning policies
     """
-    help = 'Prunes categories'
+
+    help = "Prunes categories"
 
     def handle(self, *args, **options):
         now = timezone.now()
@@ -55,4 +56,4 @@ class Command(BaseCommand):
             category.synchronize()
             category.save()
 
-        self.stdout.write('\n\nCategories were pruned')
+        self.stdout.write("\n\nCategories were pruned")

+ 4 - 4
misago/categories/management/commands/synchronizecategories.py

@@ -7,15 +7,15 @@ from misago.core.management.progressbar import show_progress
 
 
 class Command(BaseCommand):
-    help = 'Synchronizes categories'
+    help = "Synchronizes categories"
 
     def handle(self, *args, **options):
         categories_to_sync = Category.objects.count()
 
-        message = 'Synchronizing %s categories...\n'
+        message = "Synchronizing %s categories...\n"
         self.stdout.write(message % categories_to_sync)
 
-        message = '\n\nSynchronized %s categories in %s'
+        message = "\n\nSynchronized %s categories in %s"
 
         start_time = time.time()
 
@@ -29,6 +29,6 @@ class Command(BaseCommand):
             show_progress(self, synchronized_count, categories_to_sync)
 
         end_time = time.time() - start_time
-        total_time = time.strftime('%H:%M:%S', time.gmtime(end_time))
+        total_time = time.strftime("%H:%M:%S", time.gmtime(end_time))
 
         self.stdout.write(message % (synchronized_count, total_time))

+ 102 - 70
misago/categories/migrations/0001_initial.py

@@ -13,119 +13,151 @@ class Migration(migrations.Migration):
     initial = True
 
     dependencies = [
-        ('misago_acl', '0001_initial'),
+        ("misago_acl", "0001_initial"),
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
     ]
 
     operations = [
         migrations.CreateModel(
-            name='Category',
+            name="Category",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
                 ),
-                ('special_role', models.CharField(max_length=255, null=True, blank=True)),
-                ('name', models.CharField(max_length=255)),
-                ('slug', models.CharField(max_length=255)),
-                ('description', models.TextField(null=True, blank=True)),
-                ('is_closed', models.BooleanField(default=False)),
-                ('threads', models.PositiveIntegerField(default=0)),
-                ('posts', models.PositiveIntegerField(default=0)),
-                ('last_thread_title', models.CharField(max_length=255, null=True, blank=True)),
-                ('last_thread_slug', models.CharField(max_length=255, null=True, blank=True)),
-                ('last_poster_name', models.CharField(max_length=255, null=True, blank=True)),
-                ('last_poster_slug', models.CharField(max_length=255, null=True, blank=True)),
-                ('last_post_on', models.DateTimeField(null=True, blank=True)),
-                ('prune_started_after', models.PositiveIntegerField(default=0)),
-                ('prune_replied_after', models.PositiveIntegerField(default=0)),
-                ('css_class', models.CharField(max_length=255, null=True, blank=True)),
-                ('lft', models.PositiveIntegerField(editable=False, db_index=True)),
-                ('rght', models.PositiveIntegerField(editable=False, db_index=True)),
-                ('tree_id', models.PositiveIntegerField(editable=False, db_index=True)),
-                ('level', models.PositiveIntegerField(editable=False, db_index=True)),
                 (
-                    'archive_pruned_in', models.ForeignKey(
-                        related_name='pruned_archive',
+                    "special_role",
+                    models.CharField(max_length=255, null=True, blank=True),
+                ),
+                ("name", models.CharField(max_length=255)),
+                ("slug", models.CharField(max_length=255)),
+                ("description", models.TextField(null=True, blank=True)),
+                ("is_closed", models.BooleanField(default=False)),
+                ("threads", models.PositiveIntegerField(default=0)),
+                ("posts", models.PositiveIntegerField(default=0)),
+                (
+                    "last_thread_title",
+                    models.CharField(max_length=255, null=True, blank=True),
+                ),
+                (
+                    "last_thread_slug",
+                    models.CharField(max_length=255, null=True, blank=True),
+                ),
+                (
+                    "last_poster_name",
+                    models.CharField(max_length=255, null=True, blank=True),
+                ),
+                (
+                    "last_poster_slug",
+                    models.CharField(max_length=255, null=True, blank=True),
+                ),
+                ("last_post_on", models.DateTimeField(null=True, blank=True)),
+                ("prune_started_after", models.PositiveIntegerField(default=0)),
+                ("prune_replied_after", models.PositiveIntegerField(default=0)),
+                ("css_class", models.CharField(max_length=255, null=True, blank=True)),
+                ("lft", models.PositiveIntegerField(editable=False, db_index=True)),
+                ("rght", models.PositiveIntegerField(editable=False, db_index=True)),
+                ("tree_id", models.PositiveIntegerField(editable=False, db_index=True)),
+                ("level", models.PositiveIntegerField(editable=False, db_index=True)),
+                (
+                    "archive_pruned_in",
+                    models.ForeignKey(
+                        related_name="pruned_archive",
                         on_delete=django.db.models.deletion.SET_NULL,
                         blank=True,
-                        to='misago_categories.Category',
-                        null=True
-                    )
+                        to="misago_categories.Category",
+                        null=True,
+                    ),
                 ),
                 (
-                    'last_poster', models.ForeignKey(
-                        related_name='+',
+                    "last_poster",
+                    models.ForeignKey(
+                        related_name="+",
                         on_delete=django.db.models.deletion.SET_NULL,
                         blank=True,
                         to=settings.AUTH_USER_MODEL,
-                        null=True
-                    )
+                        null=True,
+                    ),
                 ),
                 (
-                    'parent', mptt.fields.TreeForeignKey(
-                        related_name='children',
+                    "parent",
+                    mptt.fields.TreeForeignKey(
+                        related_name="children",
                         on_delete=django.db.models.deletion.CASCADE,
                         blank=True,
-                        to='misago_categories.Category',
-                        null=True
-                    )
+                        to="misago_categories.Category",
+                        null=True,
+                    ),
                 ),
             ],
-            options={
-                'abstract': False,
-            },
-            bases=(models.Model, ),
+            options={"abstract": False},
+            bases=(models.Model,),
         ),
         migrations.CreateModel(
-            name='CategoryRole',
+            name="CategoryRole",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
+                ),
+                ("name", models.CharField(max_length=255)),
+                (
+                    "special_role",
+                    models.CharField(max_length=255, null=True, blank=True),
                 ),
-                ('name', models.CharField(max_length=255)),
-                ('special_role', models.CharField(max_length=255, null=True, blank=True)),
-                ('permissions', JSONField(default=permissions_default)),
+                ("permissions", JSONField(default=permissions_default)),
             ],
-            options={
-                'abstract': False,
-            },
-            bases=(models.Model, ),
+            options={"abstract": False},
+            bases=(models.Model,),
         ),
         migrations.CreateModel(
-            name='RoleCategoryACL',
+            name="RoleCategoryACL",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
                 ),
                 (
-                    'category', models.ForeignKey(
-                        related_name='category_role_set',
+                    "category",
+                    models.ForeignKey(
+                        related_name="category_role_set",
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_categories.Category',
-                    )
+                        to="misago_categories.Category",
+                    ),
                 ),
                 (
-                    'category_role', models.ForeignKey(
+                    "category_role",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_categories.CategoryRole',
-                        to_field='id',
-                    )
+                        to="misago_categories.CategoryRole",
+                        to_field="id",
+                    ),
                 ),
                 (
-                    'role', models.ForeignKey(
-                        related_name='categories_acls',
+                    "role",
+                    models.ForeignKey(
+                        related_name="categories_acls",
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_acl.Role',
-                    )
+                        to="misago_acl.Role",
+                    ),
                 ),
             ],
             options={},
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
     ]

+ 9 - 13
misago/categories/migrations/0002_default_categories.py

@@ -7,12 +7,12 @@ _ = lambda s: s
 
 
 def create_default_categories_tree(apps, schema_editor):
-    Category = apps.get_model('misago_categories', 'Category')
+    Category = apps.get_model("misago_categories", "Category")
 
     Category.objects.create(
-        special_role='private_threads',
-        name='Private',
-        slug='private',
+        special_role="private_threads",
+        name="Private",
+        slug="private",
         lft=1,
         rght=2,
         tree_id=0,
@@ -20,9 +20,9 @@ def create_default_categories_tree(apps, schema_editor):
     )
 
     root = Category.objects.create(
-        special_role='root_category',
-        name='Root',
-        slug='root',
+        special_role="root_category",
+        name="Root",
+        slug="root",
         lft=3,
         rght=6,
         tree_id=1,
@@ -44,10 +44,6 @@ def create_default_categories_tree(apps, schema_editor):
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_categories', '0001_initial'),
-    ]
+    dependencies = [("misago_categories", "0001_initial")]
 
-    operations = [
-        migrations.RunPython(create_default_categories_tree),
-    ]
+    operations = [migrations.RunPython(create_default_categories_tree)]

+ 76 - 95
misago/categories/migrations/0003_categories_roles.py

@@ -5,158 +5,139 @@ _ = lambda s: s
 
 
 def create_default_categories_roles(apps, schema_editor):
-    CategoryRole = apps.get_model('misago_categories', 'CategoryRole')
+    CategoryRole = apps.get_model("misago_categories", "CategoryRole")
 
     CategoryRole.objects.create(
         name=_("See only"),
         permissions={
             # categories perms
-            'misago.categories.permissions': {
-                'can_see': 1,
-                'can_browse': 0
-            },
-        }
+            "misago.categories.permissions": {"can_see": 1, "can_browse": 0}
+        },
     )
 
     read_only = CategoryRole.objects.create(
         name=_("Read only"),
         permissions={
             # categories perms
-            'misago.categories.permissions': {
-                'can_see': 1,
-                'can_browse': 1
-            },
-
+            "misago.categories.permissions": {"can_see": 1, "can_browse": 1},
             # threads perms
-            'misago.threads.permissions.threads': {
-                'can_see_all_threads': 1,
-                'can_see_posts_likes': 1,
-                'can_download_other_users_attachments': 1,
-                'can_like_posts': 1
+            "misago.threads.permissions.threads": {
+                "can_see_all_threads": 1,
+                "can_see_posts_likes": 1,
+                "can_download_other_users_attachments": 1,
+                "can_like_posts": 1,
             },
-        }
+        },
     )
 
     CategoryRole.objects.create(
         name=_("Reply to threads"),
         permissions={
             # categories perms
-            'misago.categories.permissions': {
-                'can_see': 1,
-                'can_browse': 1
-            },
-
+            "misago.categories.permissions": {"can_see": 1, "can_browse": 1},
             # threads perms
-            'misago.threads.permissions.threads': {
-                'can_see_all_threads': 1,
-                'can_reply_threads': 1,
-                'can_edit_posts': 1,
-                'can_download_other_users_attachments': 1,
-                'max_attachment_size': 500,
-                'can_see_posts_likes': 2,
-                'can_like_posts': 1
+            "misago.threads.permissions.threads": {
+                "can_see_all_threads": 1,
+                "can_reply_threads": 1,
+                "can_edit_posts": 1,
+                "can_download_other_users_attachments": 1,
+                "max_attachment_size": 500,
+                "can_see_posts_likes": 2,
+                "can_like_posts": 1,
             },
-        }
+        },
     )
 
     standard = CategoryRole.objects.create(
         name=_("Start and reply threads"),
         permissions={
             # categories perms
-            'misago.categories.permissions': {
-                'can_see': 1,
-                'can_browse': 1
-            },
-
+            "misago.categories.permissions": {"can_see": 1, "can_browse": 1},
             # threads perms
-            'misago.threads.permissions.threads': {
-                'can_see_all_threads': 1,
-                'can_start_threads': 1,
-                'can_reply_threads': 1,
-                'can_edit_threads': 1,
-                'can_edit_posts': 1,
-                'can_download_other_users_attachments': 1,
-                'max_attachment_size': 500,
-                'can_see_posts_likes': 2,
-                'can_like_posts': 1
+            "misago.threads.permissions.threads": {
+                "can_see_all_threads": 1,
+                "can_start_threads": 1,
+                "can_reply_threads": 1,
+                "can_edit_threads": 1,
+                "can_edit_posts": 1,
+                "can_download_other_users_attachments": 1,
+                "max_attachment_size": 500,
+                "can_see_posts_likes": 2,
+                "can_like_posts": 1,
             },
-        }
+        },
     )
 
     moderator = CategoryRole.objects.create(
         name=_("Moderator"),
         permissions={
             # categories perms
-            'misago.categories.permissions': {
-                'can_see': 1,
-                'can_browse': 1
-            },
-
+            "misago.categories.permissions": {"can_see": 1, "can_browse": 1},
             # threads perms
-            'misago.threads.permissions.threads': {
-                'can_see_all_threads': 1,
-                'can_start_threads': 1,
-                'can_reply_threads': 1,
-                'can_edit_threads': 2,
-                'can_edit_posts': 2,
-                'can_hide_own_threads': 2,
-                'can_hide_own_posts': 2,
-                'thread_edit_time': 0,
-                'post_edit_time': 0,
-                'can_hide_threads': 2,
-                'can_hide_posts': 2,
-                'can_protect_posts': 1,
-                'can_move_posts': 1,
-                'can_merge_posts': 1,
-                'can_announce_threads': 1,
-                'can_pin_threads': 2,
-                'can_close_threads': 1,
-                'can_move_threads': 1,
-                'can_merge_threads': 1,
-                'can_approve_content': 1,
-                'can_download_other_users_attachments': 1,
-                'max_attachment_size': 2500,
-                'can_delete_other_users_attachments': 1,
-                'can_see_posts_likes': 2,
-                'can_like_posts': 1,
-                'can_report_content': 1,
-                'can_see_reports': 1,
-                'can_hide_events': 2
+            "misago.threads.permissions.threads": {
+                "can_see_all_threads": 1,
+                "can_start_threads": 1,
+                "can_reply_threads": 1,
+                "can_edit_threads": 2,
+                "can_edit_posts": 2,
+                "can_hide_own_threads": 2,
+                "can_hide_own_posts": 2,
+                "thread_edit_time": 0,
+                "post_edit_time": 0,
+                "can_hide_threads": 2,
+                "can_hide_posts": 2,
+                "can_protect_posts": 1,
+                "can_move_posts": 1,
+                "can_merge_posts": 1,
+                "can_announce_threads": 1,
+                "can_pin_threads": 2,
+                "can_close_threads": 1,
+                "can_move_threads": 1,
+                "can_merge_threads": 1,
+                "can_approve_content": 1,
+                "can_download_other_users_attachments": 1,
+                "max_attachment_size": 2500,
+                "can_delete_other_users_attachments": 1,
+                "can_see_posts_likes": 2,
+                "can_like_posts": 1,
+                "can_report_content": 1,
+                "can_see_reports": 1,
+                "can_hide_events": 2,
             },
-        }
+        },
     )
 
     # assign category roles to roles
-    Category = apps.get_model('misago_categories', 'Category')
-    Role = apps.get_model('misago_acl', 'Role')
-    RoleCategoryACL = apps.get_model('misago_categories', 'RoleCategoryACL')
+    Category = apps.get_model("misago_categories", "Category")
+    Role = apps.get_model("misago_acl", "Role")
+    RoleCategoryACL = apps.get_model("misago_categories", "RoleCategoryACL")
 
     category = Category.objects.get(tree_id=1, level=1)
 
     RoleCategoryACL.objects.create(
-        role=Role.objects.get(name=_("Moderator")), category=category, category_role=moderator
+        role=Role.objects.get(name=_("Moderator")),
+        category=category,
+        category_role=moderator,
     )
 
     RoleCategoryACL.objects.create(
-        role=Role.objects.get(special_role='authenticated'),
+        role=Role.objects.get(special_role="authenticated"),
         category=category,
-        category_role=standard
+        category_role=standard,
     )
 
     RoleCategoryACL.objects.create(
-        role=Role.objects.get(special_role='anonymous'),
+        role=Role.objects.get(special_role="anonymous"),
         category=category,
-        category_role=read_only
+        category_role=read_only,
     )
 
 
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('misago_categories', '0002_default_categories'),
-        ('misago_acl', '0003_default_roles'),
+        ("misago_categories", "0002_default_categories"),
+        ("misago_acl", "0003_default_roles"),
     ]
 
-    operations = [
-        migrations.RunPython(create_default_categories_roles),
-    ]
+    operations = [migrations.RunPython(create_default_categories_roles)]

+ 8 - 8
misago/categories/migrations/0004_category_last_thread.py

@@ -5,21 +5,21 @@ from django.db import migrations, models
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('misago_threads', '0001_initial'),
-        ('misago_categories', '0003_categories_roles'),
+        ("misago_threads", "0001_initial"),
+        ("misago_categories", "0003_categories_roles"),
     ]
 
     operations = [
         migrations.AddField(
-            model_name='category',
-            name='last_thread',
+            model_name="category",
+            name="last_thread",
             field=models.ForeignKey(
-                related_name='+',
+                related_name="+",
                 on_delete=django.db.models.deletion.SET_NULL,
                 blank=True,
-                to='misago_threads.Thread',
-                null=True
+                to="misago_threads.Thread",
+                null=True,
             ),
             preserve_default=True,
-        ),
+        )
     ]

+ 7 - 9
misago/categories/migrations/0005_auto_20170303_2027.py

@@ -4,24 +4,22 @@ from django.db import migrations, models
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_categories', '0004_category_last_thread'),
-    ]
+    dependencies = [("misago_categories", "0004_category_last_thread")]
 
     operations = [
         migrations.AddField(
-            model_name='category',
-            name='require_edits_approval',
+            model_name="category",
+            name="require_edits_approval",
             field=models.BooleanField(default=False),
         ),
         migrations.AddField(
-            model_name='category',
-            name='require_replies_approval',
+            model_name="category",
+            name="require_replies_approval",
             field=models.BooleanField(default=False),
         ),
         migrations.AddField(
-            model_name='category',
-            name='require_threads_approval',
+            model_name="category",
+            name="require_threads_approval",
             field=models.BooleanField(default=False),
         ),
     ]

+ 8 - 12
misago/categories/migrations/0006_moderation_queue_roles.py

@@ -5,26 +5,22 @@ _ = lambda s: s
 
 
 def create_default_categories_roles(apps, schema_editor):
-    CategoryRole = apps.get_model('misago_categories', 'CategoryRole')
+    CategoryRole = apps.get_model("misago_categories", "CategoryRole")
 
     CategoryRole.objects.create(
         name=_("In moderation queue"),
         permissions={
             # threads perms
-            'misago.threads.permissions.threads': {
-                'require_threads_approval': 1,
-                'require_replies_approval': 1,
-            },
-        }
+            "misago.threads.permissions.threads": {
+                "require_threads_approval": 1,
+                "require_replies_approval": 1,
+            }
+        },
     )
 
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_categories', '0005_auto_20170303_2027'),
-    ]
+    dependencies = [("misago_categories", "0005_auto_20170303_2027")]
 
-    operations = [
-        migrations.RunPython(create_default_categories_roles),
-    ]
+    operations = [migrations.RunPython(create_default_categories_roles)]

+ 15 - 19
misago/categories/migrations/0007_best_answers_roles.py

@@ -6,39 +6,35 @@ _ = lambda s: s
 
 
 def create_default_categories_roles(apps, schema_editor):
-    CategoryRole = apps.get_model('misago_categories', 'CategoryRole')
+    CategoryRole = apps.get_model("misago_categories", "CategoryRole")
 
     CategoryRole.objects.create(
         name=_("Q&A user"),
         permissions={
             # best answers perms
-            'misago.threads.permissions.bestanswers': {
-                'can_mark_best_answers': 1,
-                'can_change_marked_answers': 1,
-                'best_answer_change_time': 60 * 36, # 1.5 day
-            },
-        }
+            "misago.threads.permissions.bestanswers": {
+                "can_mark_best_answers": 1,
+                "can_change_marked_answers": 1,
+                "best_answer_change_time": 60 * 36,  # 1.5 day
+            }
+        },
     )
 
     CategoryRole.objects.create(
         name=_("Q&A moderator"),
         permissions={
             # best answers perms
-            'misago.threads.permissions.bestanswers': {
-                'can_mark_best_answers': 2,
-                'can_change_marked_answers': 2,
-                'best_answer_change_time': 0,
-            },
-        }
+            "misago.threads.permissions.bestanswers": {
+                "can_mark_best_answers": 2,
+                "can_change_marked_answers": 2,
+                "best_answer_change_time": 0,
+            }
+        },
     )
 
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_categories', '0006_moderation_queue_roles'),
-    ]
+    dependencies = [("misago_categories", "0006_moderation_queue_roles")]
 
-    operations = [
-        migrations.RunPython(create_default_categories_roles),
-    ]
+    operations = [migrations.RunPython(create_default_categories_roles)]

+ 23 - 27
misago/categories/models.py

@@ -13,7 +13,7 @@ from misago.threads.threadtypes import trees_map
 from . import PRIVATE_THREADS_ROOT_NAME, THREADS_ROOT_NAME
 
 
-CACHE_NAME = 'misago_categories_tree'
+CACHE_NAME = "misago_categories_tree"
 
 
 class CategoryManager(TreeManager):
@@ -24,10 +24,10 @@ class CategoryManager(TreeManager):
         return self.get_special(THREADS_ROOT_NAME)
 
     def get_special(self, special_role):
-        cache_name = '%s_%s' % (CACHE_NAME, special_role)
+        cache_name = "%s_%s" % (CACHE_NAME, special_role)
 
-        special_category = cache.get(cache_name, 'nada')
-        if special_category == 'nada':
+        special_category = cache.get(cache_name, "nada")
+        if special_category == "nada":
             special_category = self.get(special_role=special_role)
             cache.set(cache_name, special_category)
         return special_category
@@ -37,11 +37,11 @@ class CategoryManager(TreeManager):
         queryset = self.filter(tree_id=tree_id)
         if not include_root:
             queryset = queryset.filter(level__gt=0)
-        return queryset.order_by('lft')
+        return queryset.order_by("lft")
 
     def get_cached_categories_dict(self):
-        categories_dict = cache.get(CACHE_NAME, 'nada')
-        if categories_dict == 'nada':
+        categories_dict = cache.get(CACHE_NAME, "nada")
+        if categories_dict == "nada":
             categories_dict = self.get_categories_dict_from_db()
             cache.set(CACHE_NAME, categories_dict)
         return categories_dict
@@ -58,11 +58,7 @@ class CategoryManager(TreeManager):
 
 class Category(MPTTModel):
     parent = TreeForeignKey(
-        'self',
-        null=True,
-        blank=True,
-        related_name='children',
-        on_delete=models.CASCADE,
+        "self", null=True, blank=True, related_name="children", on_delete=models.CASCADE
     )
     special_role = models.CharField(max_length=255, null=True, blank=True)
     name = models.CharField(max_length=255)
@@ -73,8 +69,8 @@ class Category(MPTTModel):
     posts = models.PositiveIntegerField(default=0)
     last_post_on = models.DateTimeField(null=True, blank=True)
     last_thread = models.ForeignKey(
-        'misago_threads.Thread',
-        related_name='+',
+        "misago_threads.Thread",
+        related_name="+",
         null=True,
         blank=True,
         on_delete=models.SET_NULL,
@@ -83,7 +79,7 @@ class Category(MPTTModel):
     last_thread_slug = models.CharField(max_length=255, null=True, blank=True)
     last_poster = models.ForeignKey(
         settings.AUTH_USER_MODEL,
-        related_name='+',
+        related_name="+",
         null=True,
         blank=True,
         on_delete=models.SET_NULL,
@@ -96,8 +92,8 @@ class Category(MPTTModel):
     prune_started_after = models.PositiveIntegerField(default=0)
     prune_replied_after = models.PositiveIntegerField(default=0)
     archive_pruned_in = models.ForeignKey(
-        'self',
-        related_name='pruned_archive',
+        "self",
+        related_name="pruned_archive",
         null=True,
         blank=True,
         on_delete=models.SET_NULL,
@@ -123,24 +119,28 @@ class Category(MPTTModel):
         self.threads = threads_queryset.count()
 
         if self.threads:
-            replies_sum = threads_queryset.aggregate(models.Sum('replies'))
-            self.posts = self.threads + replies_sum['replies__sum']
+            replies_sum = threads_queryset.aggregate(models.Sum("replies"))
+            self.posts = self.threads + replies_sum["replies__sum"]
         else:
             self.posts = 0
 
         if self.threads:
-            last_thread_qs = threads_queryset.filter(is_hidden=False, is_unapproved=False)
-            last_thread = last_thread_qs.order_by('-last_post_on')[:1][0]
+            last_thread_qs = threads_queryset.filter(
+                is_hidden=False, is_unapproved=False
+            )
+            last_thread = last_thread_qs.order_by("-last_post_on")[:1][0]
             self.set_last_thread(last_thread)
         else:
             self.empty_last_thread()
 
     def delete_content(self):
         from .signals import delete_category_content
+
         delete_category_content.send(sender=self)
 
     def move_content(self, new_category):
         from .signals import move_category_content
+
         move_category_content.send(sender=self, new_category=new_category)
 
     def get_absolute_url(self):
@@ -187,13 +187,9 @@ class CategoryRole(BaseRole):
 
 class RoleCategoryACL(models.Model):
     role = models.ForeignKey(
-        'misago_acl.Role',
-        related_name='categories_acls',
-        on_delete=models.CASCADE,
+        "misago_acl.Role", related_name="categories_acls", on_delete=models.CASCADE
     )
     category = models.ForeignKey(
-        'Category',
-        related_name='category_role_set',
-        on_delete=models.CASCADE,
+        "Category", related_name="category_role_set", on_delete=models.CASCADE
     )
     category_role = models.ForeignKey(CategoryRole, on_delete=models.CASCADE)

+ 29 - 34
misago/categories/permissions.py

@@ -27,11 +27,7 @@ def change_permissions_form(role):
 
 
 def build_acl(acl, roles, key_name):
-    new_acl = {
-        'visible_categories': [],
-        'browseable_categories': [],
-        'categories': {},
-    }
+    new_acl = {"visible_categories": [], "browseable_categories": [], "categories": {}}
     new_acl.update(acl)
 
     roles = get_categories_roles(roles)
@@ -44,7 +40,7 @@ def build_acl(acl, roles, key_name):
 
 def get_categories_roles(roles):
     queryset = RoleCategoryACL.objects.filter(role__in=roles)
-    queryset = queryset.select_related('category_role')
+    queryset = queryset.select_related("category_role")
 
     roles = {}
     for acl_relation in queryset.iterator():
@@ -55,19 +51,16 @@ def get_categories_roles(roles):
 
 def build_category_acl(acl, category, categories_roles, key_name):
     if category.level > 1:
-        if category.parent_id not in acl['visible_categories']:
+        if category.parent_id not in acl["visible_categories"]:
             # dont bother with child categories of invisible parents
             return
-        elif not acl['categories'][category.parent_id]['can_browse']:
+        elif not acl["categories"][category.parent_id]["can_browse"]:
             # parent's visible, but its contents aint
             return
 
     category_roles = categories_roles.get(category.pk, [])
 
-    final_acl = {
-        'can_see': 0,
-        'can_browse': 0,
-    }
+    final_acl = {"can_see": 0, "can_browse": 0}
 
     algebra.sum_acls(
         final_acl,
@@ -77,32 +70,34 @@ def build_category_acl(acl, category, categories_roles, key_name):
         can_browse=algebra.greater,
     )
 
-    if final_acl['can_see']:
-        acl['visible_categories'].append(category.pk)
-        acl['categories'][category.pk] = final_acl
+    if final_acl["can_see"]:
+        acl["visible_categories"].append(category.pk)
+        acl["categories"][category.pk] = final_acl
 
-        if final_acl['can_browse']:
-            acl['browseable_categories'].append(category.pk)
+        if final_acl["can_browse"]:
+            acl["browseable_categories"].append(category.pk)
 
 
 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)
+    target.acl["can_see"] = can_see_category(user_acl, target)
+    target.acl["can_browse"] = can_browse_category(user_acl, target)
 
 
 def serialize_categories_acls(user_acl):
     categories_acl = []
-    for category, acl in user_acl.pop('categories').items():
-        if acl['can_browse']:
-            categories_acl.append({
-                'id': category,
-                'can_start_threads': acl.get('can_start_threads', False),
-                'can_reply_threads': acl.get('can_reply_threads', False),
-                'can_pin_threads': acl.get('can_pin_threads', 0),
-                'can_hide_threads': acl.get('can_hide_threads', 0),
-                'can_close_threads': acl.get('can_close_threads', False),
-            })
-    user_acl['categories'] = categories_acl
+    for category, acl in user_acl.pop("categories").items():
+        if acl["can_browse"]:
+            categories_acl.append(
+                {
+                    "id": category,
+                    "can_start_threads": acl.get("can_start_threads", False),
+                    "can_reply_threads": acl.get("can_reply_threads", False),
+                    "can_pin_threads": acl.get("can_pin_threads", 0),
+                    "can_hide_threads": acl.get("can_hide_threads", 0),
+                    "can_close_threads": acl.get("can_close_threads", False),
+                }
+            )
+    user_acl["categories"] = categories_acl
 
 
 def register_with(registry):
@@ -116,7 +111,7 @@ def allow_see_category(user_acl, target):
     except AttributeError:
         category_id = int(target)
 
-    if not category_id in user_acl['visible_categories']:
+    if not category_id in user_acl["visible_categories"]:
         raise Http404()
 
 
@@ -124,10 +119,10 @@ can_see_category = return_boolean(allow_see_category)
 
 
 def allow_browse_category(user_acl, target):
-    target_acl = user_acl['categories'].get(target.id, {'can_browse': False})
-    if not target_acl['can_browse']:
+    target_acl = user_acl["categories"].get(target.id, {"can_browse": False})
+    if not target_acl["can_browse"]:
         message = _('You don\'t have permission to browse "%(category)s" contents.')
-        raise PermissionDenied(message % {'category': target.name})
+        raise PermissionDenied(message % {"category": target.name})
 
 
 can_browse_category = return_boolean(allow_browse_category)

+ 39 - 36
misago/categories/serializers.py

@@ -8,7 +8,7 @@ from misago.core.utils import format_plaintext_for_html
 from .models import Category
 
 
-__all__ = ['CategorySerializer']
+__all__ = ["CategorySerializer"]
 
 
 def last_activity_detail(f):
@@ -19,7 +19,11 @@ def last_activity_detail(f):
             return None
 
         acl = self.get_acl(obj)
-        tested_acls = (acl.get('can_see'), acl.get('can_browse'), acl.get('can_see_all_threads'), )
+        tested_acls = (
+            acl.get("can_see"),
+            acl.get("can_browse"),
+            acl.get("can_see_all_threads"),
+        )
 
         if not all(tested_acls):
             return None
@@ -41,32 +45,32 @@ class CategorySerializer(serializers.ModelSerializer, MutableFields):
     class Meta:
         model = Category
         fields = [
-            'id',
-            'parent',
-            'name',
-            'description',
-            'is_closed',
-            'threads',
-            'posts',
-            'last_post_on',
-            'last_thread_title',
-            'last_poster',
-            'last_poster_name',
-            'css_class',
-            'is_read',
-            'subcategories',
-            'acl',
-            'level',
-            'lft',
-            'rght',
-            'url',
+            "id",
+            "parent",
+            "name",
+            "description",
+            "is_closed",
+            "threads",
+            "posts",
+            "last_post_on",
+            "last_thread_title",
+            "last_poster",
+            "last_poster_name",
+            "css_class",
+            "is_read",
+            "subcategories",
+            "acl",
+            "level",
+            "lft",
+            "rght",
+            "url",
         ]
 
     def get_description(self, obj):
         if obj.description:
             return {
-                'plain': obj.description,
-                'html': format_plaintext_for_html(obj.description),
+                "plain": obj.description,
+                "html": format_plaintext_for_html(obj.description),
             }
         return None
 
@@ -92,23 +96,21 @@ class CategorySerializer(serializers.ModelSerializer, MutableFields):
     def get_last_poster(self, obj):
         if obj.last_poster_id:
             return {
-                'id': obj.last_poster_id,
-                'avatars': obj.last_poster.avatars,
-                'url': reverse(
-                    'misago:user', kwargs={
-                        'slug': obj.last_poster_slug,
-                        'pk': obj.last_poster_id,
-                    }
-                )
+                "id": obj.last_poster_id,
+                "avatars": obj.last_poster.avatars,
+                "url": reverse(
+                    "misago:user",
+                    kwargs={"slug": obj.last_poster_slug, "pk": obj.last_poster_id},
+                ),
             }
         return None
 
     def get_url(self, obj):
         return {
-            'index': obj.get_absolute_url(),
-            'last_thread': self.get_last_thread_url(obj),
-            'last_thread_new': self.get_last_thread_new_url(obj),
-            'last_post': self.get_last_post_url(obj),
+            "index": obj.get_absolute_url(),
+            "last_thread": self.get_last_thread_url(obj),
+            "last_thread_new": self.get_last_thread_new_url(obj),
+            "last_post": self.get_last_post_url(obj),
         }
 
     @last_activity_detail
@@ -133,4 +135,5 @@ class CategoryWithPosterSerializer(CategorySerializer):
         except AttributeError:
             return []
 
-CategoryWithPosterSerializer = CategoryWithPosterSerializer.extend_fields('last_poster')
+
+CategoryWithPosterSerializer = CategoryWithPosterSerializer.extend_fields("last_poster")

+ 1 - 2
misago/categories/signals.py

@@ -12,6 +12,5 @@ move_category_content = Signal(providing_args=["new_category"])
 @receiver([anonymize_user_data, username_changed])
 def update_usernames(sender, **kwargs):
     Category.objects.filter(last_poster=sender).update(
-        last_poster_name=sender.username,
-        last_poster_slug=sender.slug,
+        last_poster_name=sender.username, last_poster_slug=sender.slug
     )

+ 304 - 291
misago/categories/tests/test_categories_admin_views.py

@@ -11,149 +11,163 @@ from misago.threads.models import Thread
 class CategoryAdminTestCase(AdminTestCase):
     def assertValidTree(self, expected_tree):
         root = Category.objects.root_category()
-        queryset = Category.objects.filter(tree_id=root.tree_id).order_by('lft')
+        queryset = Category.objects.filter(tree_id=root.tree_id).order_by("lft")
 
         current_tree = []
         for category in queryset:
-            current_tree.append((
-                category, category.level, category.lft - root.lft + 1,
-                category.rght - root.lft + 1,
-            ))
+            current_tree.append(
+                (
+                    category,
+                    category.level,
+                    category.lft - root.lft + 1,
+                    category.rght - root.lft + 1,
+                )
+            )
 
         if len(expected_tree) != len(current_tree):
             self.fail(
-                'nodes tree is %s items long, should be %s' %
-                (len(current_tree), len(expected_tree))
+                "nodes tree is %s items long, should be %s"
+                % (len(current_tree), len(expected_tree))
             )
 
         for i, category in enumerate(expected_tree):
             _category = current_tree[i]
             if category[0] != _category[0]:
-                self.fail(('expected category at index #%s to be %s, '
-                           'found %s instead') % (i, category[0], _category[0]))
+                self.fail(
+                    ("expected category at index #%s to be %s, " "found %s instead")
+                    % (i, category[0], _category[0])
+                )
             if category[1] != _category[1]:
-                self.fail(('expected level at index #%s to be %s, '
-                           'found %s instead') % (i, category[1], _category[1]))
+                self.fail(
+                    ("expected level at index #%s to be %s, " "found %s instead")
+                    % (i, category[1], _category[1])
+                )
             if category[2] != _category[2]:
-                self.fail(('expected lft at index #%s to be %s, '
-                           'found %s instead') % (i, category[2], _category[2]))
+                self.fail(
+                    ("expected lft at index #%s to be %s, " "found %s instead")
+                    % (i, category[2], _category[2])
+                )
             if category[3] != _category[3]:
-                self.fail(('expected lft at index #%s to be %s, '
-                           'found %s instead') % (i, category[3], _category[3]))
+                self.fail(
+                    ("expected lft at index #%s to be %s, " "found %s instead")
+                    % (i, category[3], _category[3])
+                )
 
 
 class CategoryAdminViewsTests(CategoryAdminTestCase):
     def test_link_registered(self):
         """admin nav contains categories link"""
-        response = self.client.get(reverse('misago:admin:categories:nodes:index'))
+        response = self.client.get(reverse("misago:admin:categories:nodes:index"))
 
-        self.assertContains(response, reverse('misago:admin:categories:nodes:index'))
+        self.assertContains(response, reverse("misago:admin:categories:nodes:index"))
 
     def test_list_view(self):
         """categories list view returns 200"""
-        response = self.client.get(reverse('misago:admin:categories:nodes:index'))
+        response = self.client.get(reverse("misago:admin:categories:nodes:index"))
 
-        self.assertContains(response, 'First category')
+        self.assertContains(response, "First category")
 
         # Now test that empty categories list contains message
         root = Category.objects.root_category()
         for descendant in root.get_descendants():
             descendant.delete()
 
-        response = self.client.get(reverse('misago:admin:categories:nodes:index'))
+        response = self.client.get(reverse("misago:admin:categories:nodes:index"))
 
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'No categories')
+        self.assertContains(response, "No categories")
 
     def test_new_view(self):
         """new category view has no showstoppers"""
         root = Category.objects.root_category()
-        first_category = Category.objects.get(slug='first-category')
+        first_category = Category.objects.get(slug="first-category")
 
-        response = self.client.get(reverse('misago:admin:categories:nodes:new'))
+        response = self.client.get(reverse("misago:admin:categories:nodes:new"))
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            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,
+                "name": "Test Category",
+                "description": "Lorem ipsum dolor met",
+                "new_parent": root.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:categories:nodes:index'))
-        self.assertContains(response, 'Test Category')
+        response = self.client.get(reverse("misago:admin:categories:nodes:index"))
+        self.assertContains(response, "Test Category")
 
-        test_category = Category.objects.get(slug='test-category')
+        test_category = Category.objects.get(slug="test-category")
 
-        self.assertValidTree([
-            (root, 0, 1, 6),
-            (first_category, 1, 2, 3),
-            (test_category, 1, 4, 5),
-        ])
+        self.assertValidTree(
+            [(root, 0, 1, 6), (first_category, 1, 2, 3), (test_category, 1, 4, 5)]
+        )
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Test Other Category',
-                'description': 'Lorem ipsum dolor met',
-                'new_parent': root.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Test Other Category",
+                "description": "Lorem ipsum dolor met",
+                "new_parent": root.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
         self.assertEqual(response.status_code, 302)
 
-        test_other_category = Category.objects.get(slug='test-other-category')
+        test_other_category = Category.objects.get(slug="test-other-category")
 
-        self.assertValidTree([
-            (root, 0, 1, 8),
-            (first_category, 1, 2, 3),
-            (test_category, 1, 4, 5),
-            (test_other_category, 1, 6, 7),
-        ])
+        self.assertValidTree(
+            [
+                (root, 0, 1, 8),
+                (first_category, 1, 2, 3),
+                (test_category, 1, 4, 5),
+                (test_other_category, 1, 6, 7),
+            ]
+        )
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Test Subcategory',
-                'new_parent': test_category.pk,
-                'copy_permissions': test_category.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Test Subcategory",
+                "new_parent": test_category.pk,
+                "copy_permissions": test_category.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
         self.assertEqual(response.status_code, 302)
 
-        test_subcategory = Category.objects.get(slug='test-subcategory')
+        test_subcategory = Category.objects.get(slug="test-subcategory")
 
-        self.assertValidTree([
-            (root, 0, 1, 10),
-            (first_category, 1, 2, 3),
-            (test_category, 1, 4, 7),
-            (test_subcategory, 2, 5, 6),
-            (test_other_category, 1, 8, 9),
-        ])
+        self.assertValidTree(
+            [
+                (root, 0, 1, 10),
+                (first_category, 1, 2, 3),
+                (test_category, 1, 4, 7),
+                (test_subcategory, 2, 5, 6),
+                (test_other_category, 1, 8, 9),
+            ]
+        )
 
-        response = self.client.get(reverse('misago:admin:categories:nodes:index'))
-        self.assertContains(response, 'Test Subcategory')
+        response = self.client.get(reverse("misago:admin:categories:nodes:index"))
+        self.assertContains(response, "Test Subcategory")
 
     def test_creating_new_category_invalidates_acl_cache(self):
         root = Category.objects.root_category()
 
         with assert_invalidates_cache(ACL_CACHE):
             self.client.post(
-                reverse('misago:admin:categories:nodes:new'),
+                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,
+                    "name": "Test Category",
+                    "description": "Lorem ipsum dolor met",
+                    "new_parent": root.pk,
+                    "prune_started_after": 0,
+                    "prune_replied_after": 0,
                 },
             )
 
@@ -161,215 +175,210 @@ class CategoryAdminViewsTests(CategoryAdminTestCase):
         """edit category view has no showstoppers"""
         private_threads = Category.objects.private_threads()
         root = Category.objects.root_category()
-        first_category = Category.objects.get(slug='first-category')
+        first_category = Category.objects.get(slug="first-category")
 
         response = self.client.get(
-            reverse('misago:admin:categories:nodes:edit', kwargs={
-                'pk': private_threads.pk,
-            })
+            reverse(
+                "misago:admin:categories:nodes:edit", kwargs={"pk": private_threads.pk}
+            )
         )
         self.assertEqual(response.status_code, 302)
 
         response = self.client.get(
-            reverse('misago:admin:categories:nodes:edit', kwargs={
-                'pk': root.pk,
-            })
+            reverse("misago:admin:categories:nodes:edit", kwargs={"pk": root.pk})
         )
         self.assertEqual(response.status_code, 302)
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            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,
+                "name": "Test Category",
+                "description": "Lorem ipsum dolor met",
+                "new_parent": root.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
         self.assertEqual(response.status_code, 302)
 
-        test_category = Category.objects.get(slug='test-category')
+        test_category = Category.objects.get(slug="test-category")
 
         response = self.client.get(
-            reverse('misago:admin:categories:nodes:edit', kwargs={
-                'pk': test_category.pk,
-            })
+            reverse(
+                "misago:admin:categories:nodes:edit", kwargs={"pk": test_category.pk}
+            )
         )
 
-        self.assertContains(response, 'Test Category')
+        self.assertContains(response, "Test Category")
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:edit', kwargs={
-                'pk': test_category.pk,
-            }),
+            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,
+                "name": "Test Category Edited",
+                "new_parent": root.pk,
+                "role": "category",
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
         self.assertEqual(response.status_code, 302)
 
-        self.assertValidTree([
-            (root, 0, 1, 6),
-            (first_category, 1, 2, 3),
-            (test_category, 1, 4, 5),
-        ])
+        self.assertValidTree(
+            [(root, 0, 1, 6), (first_category, 1, 2, 3), (test_category, 1, 4, 5)]
+        )
 
-        response = self.client.get(reverse('misago:admin:categories:nodes:index'))
-        self.assertContains(response, 'Test Category Edited')
+        response = self.client.get(reverse("misago:admin:categories:nodes:index"))
+        self.assertContains(response, "Test Category Edited")
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:edit', kwargs={
-                'pk': test_category.pk,
-            }),
+            reverse(
+                "misago:admin:categories:nodes:edit", kwargs={"pk": test_category.pk}
+            ),
             data={
-                'name': 'Test Category Edited',
-                'new_parent': first_category.pk,
-                'role': 'category',
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Test Category Edited",
+                "new_parent": first_category.pk,
+                "role": "category",
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
         self.assertEqual(response.status_code, 302)
 
-        self.assertValidTree([
-            (root, 0, 1, 6),
-            (first_category, 1, 2, 5),
-            (test_category, 2, 3, 4),
-        ])
+        self.assertValidTree(
+            [(root, 0, 1, 6), (first_category, 1, 2, 5), (test_category, 2, 3, 4)]
+        )
 
-        response = self.client.get(reverse('misago:admin:categories:nodes:index'))
-        self.assertContains(response, 'Test Category Edited')
+        response = self.client.get(reverse("misago:admin:categories:nodes:index"))
+        self.assertContains(response, "Test Category Edited")
 
     def test_editing_category_invalidates_acl_cache(self):
         root = Category.objects.root_category()
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            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,
+                "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')
+
+        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,
-                }),
+                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,
+                    "name": "Test Category Edited",
+                    "new_parent": root.pk,
+                    "role": "category",
+                    "prune_started_after": 0,
+                    "prune_replied_after": 0,
                 },
             )
 
     def test_move_views(self):
         """move up/down views have no showstoppers"""
         root = Category.objects.root_category()
-        first_category = Category.objects.get(slug='first-category')
+        first_category = Category.objects.get(slug="first-category")
 
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Category A',
-                'new_parent': root.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Category A",
+                "new_parent": root.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
-        category_a = Category.objects.get(slug='category-a')
+        category_a = Category.objects.get(slug="category-a")
 
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Category B',
-                'new_parent': root.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Category B",
+                "new_parent": root.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
-        category_b = Category.objects.get(slug='category-b')
+        category_b = Category.objects.get(slug="category-b")
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:up', kwargs={
-                'pk': category_b.pk,
-            })
+            reverse("misago:admin:categories:nodes:up", kwargs={"pk": category_b.pk})
         )
         self.assertEqual(response.status_code, 302)
 
-        self.assertValidTree([
-            (root, 0, 1, 8),
-            (first_category, 1, 2, 3),
-            (category_b, 1, 4, 5),
-            (category_a, 1, 6, 7),
-        ])
+        self.assertValidTree(
+            [
+                (root, 0, 1, 8),
+                (first_category, 1, 2, 3),
+                (category_b, 1, 4, 5),
+                (category_a, 1, 6, 7),
+            ]
+        )
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:up', kwargs={
-                'pk': category_b.pk,
-            })
+            reverse("misago:admin:categories:nodes:up", kwargs={"pk": category_b.pk})
         )
         self.assertEqual(response.status_code, 302)
 
-        self.assertValidTree([
-            (root, 0, 1, 8),
-            (category_b, 1, 2, 3),
-            (first_category, 1, 4, 5),
-            (category_a, 1, 6, 7),
-        ])
+        self.assertValidTree(
+            [
+                (root, 0, 1, 8),
+                (category_b, 1, 2, 3),
+                (first_category, 1, 4, 5),
+                (category_a, 1, 6, 7),
+            ]
+        )
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:down', kwargs={
-                'pk': category_b.pk,
-            })
+            reverse("misago:admin:categories:nodes:down", kwargs={"pk": category_b.pk})
         )
         self.assertEqual(response.status_code, 302)
 
-        self.assertValidTree([
-            (root, 0, 1, 8),
-            (first_category, 1, 2, 3),
-            (category_b, 1, 4, 5),
-            (category_a, 1, 6, 7),
-        ])
+        self.assertValidTree(
+            [
+                (root, 0, 1, 8),
+                (first_category, 1, 2, 3),
+                (category_b, 1, 4, 5),
+                (category_a, 1, 6, 7),
+            ]
+        )
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:down', kwargs={
-                'pk': category_b.pk,
-            })
+            reverse("misago:admin:categories:nodes:down", kwargs={"pk": category_b.pk})
         )
         self.assertEqual(response.status_code, 302)
 
-        self.assertValidTree([
-            (root, 0, 1, 8),
-            (first_category, 1, 2, 3),
-            (category_a, 1, 4, 5),
-            (category_b, 1, 6, 7),
-        ])
+        self.assertValidTree(
+            [
+                (root, 0, 1, 8),
+                (first_category, 1, 2, 3),
+                (category_a, 1, 4, 5),
+                (category_b, 1, 6, 7),
+            ]
+        )
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:down', kwargs={
-                'pk': category_b.pk,
-            })
+            reverse("misago:admin:categories:nodes:down", kwargs={"pk": category_b.pk})
         )
         self.assertEqual(response.status_code, 302)
 
-        self.assertValidTree([
-            (root, 0, 1, 8),
-            (first_category, 1, 2, 3),
-            (category_a, 1, 4, 5),
-            (category_b, 1, 6, 7),
-        ])
+        self.assertValidTree(
+            [
+                (root, 0, 1, 8),
+                (first_category, 1, 2, 3),
+                (category_a, 1, 4, 5),
+                (category_b, 1, 6, 7),
+            ]
+        )
 
 
 class CategoryAdminDeleteViewTests(CategoryAdminTestCase):
@@ -391,74 +400,74 @@ class CategoryAdminDeleteViewTests(CategoryAdminTestCase):
         super().setUp()
 
         self.root = Category.objects.root_category()
-        self.first_category = Category.objects.get(slug='first-category')
+        self.first_category = Category.objects.get(slug="first-category")
 
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Category A',
-                'new_parent': self.root.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Category A",
+                "new_parent": self.root.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
 
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Category E',
-                'new_parent': self.root.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Category E",
+                "new_parent": self.root.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
 
-        self.category_a = Category.objects.get(slug='category-a')
-        self.category_e = Category.objects.get(slug='category-e')
+        self.category_a = Category.objects.get(slug="category-a")
+        self.category_e = Category.objects.get(slug="category-e")
 
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Category B',
-                'new_parent': self.category_a.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Category B",
+                "new_parent": self.category_a.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
-        self.category_b = Category.objects.get(slug='category-b')
+        self.category_b = Category.objects.get(slug="category-b")
 
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Subcategory C',
-                'new_parent': self.category_b.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Subcategory C",
+                "new_parent": self.category_b.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
-        self.category_c = Category.objects.get(slug='subcategory-c')
+        self.category_c = Category.objects.get(slug="subcategory-c")
 
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Subcategory D',
-                'new_parent': self.category_b.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Subcategory D",
+                "new_parent": self.category_b.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
-        self.category_d = Category.objects.get(slug='subcategory-d')
+        self.category_d = Category.objects.get(slug="subcategory-d")
 
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Category F',
-                'new_parent': self.category_e.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Category F",
+                "new_parent": self.category_e.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
-        self.category_f = Category.objects.get(slug='category-f')
+        self.category_f = Category.objects.get(slug="category-f")
 
     def test_delete_category_move_contents(self):
         """category was deleted and its contents were moved"""
@@ -467,19 +476,21 @@ class CategoryAdminDeleteViewTests(CategoryAdminTestCase):
         self.assertEqual(Thread.objects.count(), 10)
 
         response = self.client.get(
-            reverse('misago:admin:categories:nodes:delete', kwargs={
-                'pk': self.category_b.pk,
-            })
+            reverse(
+                "misago:admin:categories:nodes:delete",
+                kwargs={"pk": self.category_b.pk},
+            )
         )
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:delete', kwargs={
-                'pk': self.category_b.pk,
-            }),
+            reverse(
+                "misago:admin:categories:nodes:delete",
+                kwargs={"pk": self.category_b.pk},
+            ),
             data={
-                'move_children_to': self.category_e.pk,
-                'move_threads_to': self.category_d.pk,
+                "move_children_to": self.category_e.pk,
+                "move_threads_to": self.category_d.pk,
             },
         )
         self.assertEqual(response.status_code, 302)
@@ -488,15 +499,17 @@ class CategoryAdminDeleteViewTests(CategoryAdminTestCase):
         for thread in Thread.objects.all():
             self.assertEqual(thread.category_id, self.category_d.pk)
 
-        self.assertValidTree([
-            (self.root, 0, 1, 14),
-            (self.first_category, 1, 2, 3),
-            (self.category_a, 1, 4, 5),
-            (self.category_e, 1, 6, 13),
-            (self.category_f, 2, 7, 8),
-            (self.category_c, 2, 9, 10),
-            (self.category_d, 2, 11, 12),
-        ])
+        self.assertValidTree(
+            [
+                (self.root, 0, 1, 14),
+                (self.first_category, 1, 2, 3),
+                (self.category_a, 1, 4, 5),
+                (self.category_e, 1, 6, 13),
+                (self.category_f, 2, 7, 8),
+                (self.category_c, 2, 9, 10),
+                (self.category_d, 2, 11, 12),
+            ]
+        )
 
     def test_delete_category_and_contents(self):
         """category and its contents were deleted"""
@@ -504,33 +517,34 @@ class CategoryAdminDeleteViewTests(CategoryAdminTestCase):
             testutils.post_thread(self.category_b)
 
         response = self.client.get(
-            reverse('misago:admin:categories:nodes:delete', kwargs={
-                'pk': self.category_b.pk,
-            })
+            reverse(
+                "misago:admin:categories:nodes:delete",
+                kwargs={"pk": self.category_b.pk},
+            )
         )
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:delete', kwargs={
-                'pk': self.category_b.pk,
-            }),
-            data={
-                'move_children_to': '',
-                'move_threads_to': '',
-            }
+            reverse(
+                "misago:admin:categories:nodes:delete",
+                kwargs={"pk": self.category_b.pk},
+            ),
+            data={"move_children_to": "", "move_threads_to": ""},
         )
         self.assertEqual(response.status_code, 302)
 
         self.assertEqual(Category.objects.all_categories().count(), 4)
         self.assertEqual(Thread.objects.count(), 0)
 
-        self.assertValidTree([
-            (self.root, 0, 1, 10),
-            (self.first_category, 1, 2, 3),
-            (self.category_a, 1, 4, 5),
-            (self.category_e, 1, 6, 9),
-            (self.category_f, 2, 7, 8),
-        ])
+        self.assertValidTree(
+            [
+                (self.root, 0, 1, 10),
+                (self.first_category, 1, 2, 3),
+                (self.category_a, 1, 4, 5),
+                (self.category_e, 1, 6, 9),
+                (self.category_f, 2, 7, 8),
+            ]
+        )
 
     def test_delete_leaf_category_and_contents(self):
         """leaf category was deleted with contents"""
@@ -539,44 +553,43 @@ class CategoryAdminDeleteViewTests(CategoryAdminTestCase):
         self.assertEqual(Thread.objects.count(), 10)
 
         response = self.client.get(
-            reverse('misago:admin:categories:nodes:delete', kwargs={
-                'pk': self.category_d.pk,
-            })
+            reverse(
+                "misago:admin:categories:nodes:delete",
+                kwargs={"pk": self.category_d.pk},
+            )
         )
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:delete', kwargs={
-                'pk': self.category_d.pk,
-            }),
-            data={
-                'move_children_to': '',
-                'move_threads_to': '',
-            }
+            reverse(
+                "misago:admin:categories:nodes:delete",
+                kwargs={"pk": self.category_d.pk},
+            ),
+            data={"move_children_to": "", "move_threads_to": ""},
         )
         self.assertEqual(response.status_code, 302)
 
         self.assertEqual(Category.objects.all_categories().count(), 6)
         self.assertEqual(Thread.objects.count(), 0)
 
-        self.assertValidTree([
-            (self.root, 0, 1, 14),
-            (self.first_category, 1, 2, 3),
-            (self.category_a, 1, 4, 9),
-            (self.category_b, 2, 5, 8),
-            (self.category_c, 3, 6, 7),
-            (self.category_e, 1, 10, 13),
-            (self.category_f, 2, 11, 12),
-        ])
+        self.assertValidTree(
+            [
+                (self.root, 0, 1, 14),
+                (self.first_category, 1, 2, 3),
+                (self.category_a, 1, 4, 9),
+                (self.category_b, 2, 5, 8),
+                (self.category_c, 3, 6, 7),
+                (self.category_e, 1, 10, 13),
+                (self.category_f, 2, 11, 12),
+            ]
+        )
 
     def test_deleting_category_invalidates_acl_cache(self):
         with assert_invalidates_cache(ACL_CACHE):
             self.client.post(
-                reverse('misago:admin:categories:nodes:delete', kwargs={
-                    'pk': self.category_d.pk,
-                }),
-                data={
-                    'move_children_to': '',
-                    'move_threads_to': '',
-                }
+                reverse(
+                    "misago:admin:categories:nodes:delete",
+                    kwargs={"pk": self.category_d.pk},
+                ),
+                data={"move_children_to": "", "move_threads_to": ""},
             )

+ 7 - 12
misago/categories/tests/test_category_model.py

@@ -11,23 +11,23 @@ class CategoryManagerTests(TestCase):
         """private_threads returns private threads category"""
         category = Category.objects.private_threads()
 
-        self.assertEqual(category.special_role, 'private_threads')
+        self.assertEqual(category.special_role, "private_threads")
 
     def test_root_category(self):
         """root_category returns categories tree root"""
         category = Category.objects.root_category()
 
-        self.assertEqual(category.special_role, 'root_category')
+        self.assertEqual(category.special_role, "root_category")
 
     def test_all_categories(self):
         """all_categories returns queryset with categories tree"""
         root = Category.objects.root_category()
 
-        test_category_a = Category(name='Test')
-        test_category_a.insert_at(root, position='last-child', save=True)
+        test_category_a = Category(name="Test")
+        test_category_a.insert_at(root, position="last-child", save=True)
 
-        test_category_b = Category(name='Test 2')
-        test_category_b.insert_at(root, position='last-child', save=True)
+        test_category_b = Category(name="Test 2")
+        test_category_b.insert_at(root, position="last-child", save=True)
 
         all_categories_from_db = list(Category.objects.all_categories(True))
 
@@ -132,12 +132,7 @@ class CategoryModelTests(TestCase):
 
         # we are using category so we don't have to fake another category
         new_category = Category.objects.create(
-            lft=7,
-            rght=8,
-            tree_id=2,
-            level=0,
-            name='Archive',
-            slug='archive',
+            lft=7, rght=8, tree_id=2, level=0, name="Archive", slug="archive"
         )
         self.category.move_content(new_category)
 

+ 40 - 24
misago/categories/tests/test_fixcategoriestree.py

@@ -21,38 +21,51 @@ class FixCategoriesTreeTests(TestCase):
     The purpose is the verify that the management command
     fixes the lft/rght values of the thread category tree.
     """
+
     def setUp(self):
-        Category.objects.create(name='Test', slug='test', parent=Category.objects.root_category())
+        Category.objects.create(
+            name="Test", slug="test", parent=Category.objects.root_category()
+        )
         self.fetch_categories()
 
     def assertValidTree(self, expected_tree):
         root = Category.objects.root_category()
-        queryset = Category.objects.filter(tree_id=root.tree_id).order_by('lft')
+        queryset = Category.objects.filter(tree_id=root.tree_id).order_by("lft")
 
         current_tree = []
         for category in queryset:
-            current_tree.append((category, category.get_level(), category.lft, category.rght))
+            current_tree.append(
+                (category, category.get_level(), category.lft, category.rght)
+            )
 
         for i, category in enumerate(expected_tree):
             _category = current_tree[i]
             if category[0] != _category[0]:
-                self.fail(('expected category at index #%s to be %s, '
-                           'found %s instead') % (i, category[0], _category[0]))
+                self.fail(
+                    ("expected category at index #%s to be %s, " "found %s instead")
+                    % (i, category[0], _category[0])
+                )
             if category[1] != _category[1]:
-                self.fail(('expected level at index #%s to be %s, '
-                           'found %s instead') % (i, category[1], _category[1]))
+                self.fail(
+                    ("expected level at index #%s to be %s, " "found %s instead")
+                    % (i, category[1], _category[1])
+                )
             if category[2] != _category[2]:
-                self.fail(('expected lft at index #%s to be %s, '
-                           'found %s instead') % (i, category[2], _category[2]))
+                self.fail(
+                    ("expected lft at index #%s to be %s, " "found %s instead")
+                    % (i, category[2], _category[2])
+                )
             if category[3] != _category[3]:
-                self.fail(('expected lft at index #%s to be %s, '
-                           'found %s instead') % (i, category[3], _category[3]))
+                self.fail(
+                    ("expected lft at index #%s to be %s, " "found %s instead")
+                    % (i, category[3], _category[3])
+                )
 
     def fetch_categories(self):
         """gets a fresh version from the database"""
         self.root = Category.objects.root_category()
-        self.first_category = Category.objects.get(slug='first-category')
-        self.test_category = Category.objects.get(slug='test')
+        self.first_category = Category.objects.get(slug="first-category")
+        self.test_category = Category.objects.get(slug="test")
 
     def test_fix_categories_tree_unaffected(self):
         """Command should not affect a healthy three"""
@@ -61,11 +74,13 @@ class FixCategoriesTreeTests(TestCase):
 
         self.fetch_categories()
 
-        self.assertValidTree([
-            (self.root, 0, 1, 6),
-            (self.first_category, 1, 2, 3),
-            (self.test_category, 1, 4, 5),
-        ])
+        self.assertValidTree(
+            [
+                (self.root, 0, 1, 6),
+                (self.first_category, 1, 2, 3),
+                (self.test_category, 1, 4, 5),
+            ]
+        )
 
         self.assertEqual(self.root.tree_id, tree_id, msg="tree_id changed by command")
 
@@ -79,13 +94,14 @@ class FixCategoriesTreeTests(TestCase):
         run_command()
         self.fetch_categories()
 
-        self.assertValidTree([
-            (self.root, 0, 1, 6),
-            (self.test_category, 1, 2, 3),
-            (self.first_category, 1, 4, 5),
-        ])
+        self.assertValidTree(
+            [
+                (self.root, 0, 1, 6),
+                (self.test_category, 1, 2, 3),
+                (self.first_category, 1, 4, 5),
+            ]
+        )
 
     def test_fixing_categories_tree_invalidates_acl_cache(self):
         with assert_invalidates_cache(ACL_CACHE):
             run_command()
-

+ 143 - 156
misago/categories/tests/test_permissions_admin_views.py

@@ -15,121 +15,112 @@ def create_data(data_dict):
 class CategoryRoleAdminViewsTests(AdminTestCase):
     def test_link_registered(self):
         """admin nav contains category roles link"""
-        response = self.client.get(reverse('misago:admin:permissions:categories:index'))
+        response = self.client.get(reverse("misago:admin:permissions:categories:index"))
 
-        self.assertContains(response, reverse('misago:admin:permissions:categories:index'))
+        self.assertContains(
+            response, reverse("misago:admin:permissions:categories:index")
+        )
 
     def test_list_view(self):
         """roles list view returns 200"""
-        response = self.client.get(reverse('misago:admin:permissions:categories:index'))
+        response = self.client.get(reverse("misago:admin:permissions:categories:index"))
 
         self.assertEqual(response.status_code, 200)
 
     def test_new_view(self):
         """new role view has no showstoppers"""
-        response = self.client.get(reverse('misago:admin:permissions:categories:new'))
+        response = self.client.get(reverse("misago:admin:permissions:categories:new"))
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
-            reverse('misago:admin:permissions:categories:new'),
-            data=create_data({
-                'name': 'Test CategoryRole',
-            }),
+            reverse("misago:admin:permissions:categories:new"),
+            data=create_data({"name": "Test CategoryRole"}),
         )
         self.assertEqual(response.status_code, 302)
 
-        test_role = CategoryRole.objects.get(name='Test CategoryRole')
-        response = self.client.get(reverse('misago:admin:permissions:categories:index'))
+        test_role = CategoryRole.objects.get(name="Test CategoryRole")
+        response = self.client.get(reverse("misago:admin:permissions:categories:index"))
         self.assertContains(response, test_role.name)
 
     def test_edit_view(self):
         """edit role view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:permissions:categories:new'),
-            data=create_data({
-                'name': 'Test CategoryRole',
-            }),
+            reverse("misago:admin:permissions:categories:new"),
+            data=create_data({"name": "Test CategoryRole"}),
         )
 
-        test_role = CategoryRole.objects.get(name='Test CategoryRole')
+        test_role = CategoryRole.objects.get(name="Test CategoryRole")
 
         response = self.client.get(
-            reverse('misago:admin:permissions:categories:edit', kwargs={
-                'pk': test_role.pk,
-            })
+            reverse(
+                "misago:admin:permissions:categories:edit", kwargs={"pk": test_role.pk}
+            )
         )
-        self.assertContains(response, 'Test CategoryRole')
+        self.assertContains(response, "Test CategoryRole")
 
         response = self.client.post(
-            reverse('misago:admin:permissions:categories:edit', kwargs={
-                'pk': test_role.pk,
-            }),
-            data=create_data({
-                'name': 'Top Lel',
-            }),
+            reverse(
+                "misago:admin:permissions:categories:edit", kwargs={"pk": test_role.pk}
+            ),
+            data=create_data({"name": "Top Lel"}),
         )
         self.assertEqual(response.status_code, 302)
 
-        test_role = CategoryRole.objects.get(name='Top Lel')
-        response = self.client.get(reverse('misago:admin:permissions:categories:index'))
+        test_role = CategoryRole.objects.get(name="Top Lel")
+        response = self.client.get(reverse("misago:admin:permissions:categories:index"))
         self.assertContains(response, test_role.name)
 
     def test_editing_role_invalidates_acl_cache(self):
         self.client.post(
-            reverse('misago:admin:permissions:categories:new'),
-            data=create_data({
-                'name': 'Test CategoryRole',
-            }),
+            reverse("misago:admin:permissions:categories:new"),
+            data=create_data({"name": "Test CategoryRole"}),
         )
 
-        test_role = CategoryRole.objects.get(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=create_data({
-                    'name': 'Top Lel',
-                }),
+                reverse(
+                    "misago:admin:permissions:categories:edit",
+                    kwargs={"pk": test_role.pk},
+                ),
+                data=create_data({"name": "Top Lel"}),
             )
 
     def test_delete_view(self):
         """delete role view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:permissions:categories:new'),
-            data=create_data({
-                'name': 'Test CategoryRole',
-            }),
+            reverse("misago:admin:permissions:categories:new"),
+            data=create_data({"name": "Test CategoryRole"}),
         )
 
-        test_role = CategoryRole.objects.get(name='Test CategoryRole')
+        test_role = CategoryRole.objects.get(name="Test CategoryRole")
         response = self.client.post(
-            reverse('misago:admin:permissions:categories:delete', kwargs={
-                'pk': test_role.pk,
-            })
+            reverse(
+                "misago:admin:permissions:categories:delete",
+                kwargs={"pk": test_role.pk},
+            )
         )
         self.assertEqual(response.status_code, 302)
 
-        self.client.get(reverse('misago:admin:permissions:categories:index'))
-        response = self.client.get(reverse('misago:admin:permissions:categories:index'))
+        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)
 
     def test_deleting_role_invalidates_acl_cache(self):
         self.client.post(
-            reverse('misago:admin:permissions:categories:new'),
-            data=create_data({
-                'name': 'Test CategoryRole',
-            }),
+            reverse("misago:admin:permissions:categories:new"),
+            data=create_data({"name": "Test CategoryRole"}),
         )
 
-        test_role = CategoryRole.objects.get(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,
-                })
+                reverse(
+                    "misago:admin:permissions:categories:delete",
+                    kwargs={"pk": test_role.pk},
+                )
             )
 
     def test_change_category_roles_view(self):
@@ -147,56 +138,51 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         """
         root = Category.objects.root_category()
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Category A',
-                'new_parent': root.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Category A",
+                "new_parent": root.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
-        test_category = Category.objects.get(slug='category-a')
+        test_category = Category.objects.get(slug="category-a")
 
         self.assertEqual(Category.objects.count(), 3)
         """
         Create test roles
         """
         self.client.post(
-            reverse('misago:admin:permissions:users:new'),
-            data=mock_role_form_data(Role(), {'name': 'Test Role A'})
+            reverse("misago:admin:permissions:users:new"),
+            data=mock_role_form_data(Role(), {"name": "Test Role A"}),
         )
         self.client.post(
-            reverse('misago:admin:permissions:users:new'),
-            data=mock_role_form_data(Role(), {'name': 'Test Role B'})
+            reverse("misago:admin:permissions:users:new"),
+            data=mock_role_form_data(Role(), {"name": "Test Role B"}),
         )
 
-        test_role_a = Role.objects.get(name='Test Role A')
-        test_role_b = Role.objects.get(name='Test Role B')
+        test_role_a = Role.objects.get(name="Test Role A")
+        test_role_b = Role.objects.get(name="Test Role B")
 
         self.client.post(
-            reverse('misago:admin:permissions:categories:new'),
-            data=create_data({
-                'name': 'Test Comments',
-            }),
+            reverse("misago:admin:permissions:categories:new"),
+            data=create_data({"name": "Test Comments"}),
         )
         self.client.post(
-            reverse('misago:admin:permissions:categories:new'),
-            data=create_data({
-                'name': 'Test Full',
-            }),
+            reverse("misago:admin:permissions:categories:new"),
+            data=create_data({"name": "Test Full"}),
         )
 
-        role_comments = CategoryRole.objects.get(name='Test Comments')
-        role_full = CategoryRole.objects.get(name='Test Full')
+        role_comments = CategoryRole.objects.get(name="Test Comments")
+        role_full = CategoryRole.objects.get(name="Test Full")
         """
         Test view itself
         """
         # See if form page is rendered
         response = self.client.get(
             reverse(
-                'misago:admin:categories:nodes:permissions', kwargs={
-                    'pk': test_category.pk,
-                }
+                "misago:admin:categories:nodes:permissions",
+                kwargs={"pk": test_category.pk},
             )
         )
         self.assertContains(response, test_category.name)
@@ -208,46 +194,46 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         # Assign roles to categories
         response = self.client.post(
             reverse(
-                'misago:admin:categories:nodes:permissions', kwargs={
-                    'pk': test_category.pk,
-                }
+                "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,
+                ("%s-category_role" % test_role_a.pk): role_full.pk,
+                ("%s-category_role" % test_role_b.pk): role_comments.pk,
             },
         )
         self.assertEqual(response.status_code, 302)
 
         # Check that roles were assigned
         category_role_set = test_category.category_role_set
-        self.assertEqual(category_role_set.get(role=test_role_a).category_role_id, role_full.pk)
+        self.assertEqual(
+            category_role_set.get(role=test_role_a).category_role_id, role_full.pk
+        )
         self.assertEqual(
             category_role_set.get(role=test_role_b).category_role_id, role_comments.pk
         )
-        
+
         # Check that ACL was invalidated
         with assert_invalidates_cache(ACL_CACHE):
             self.client.post(
                 reverse(
-                    'misago:admin:categories:nodes:permissions', kwargs={
-                        'pk': test_category.pk,
-                    }
+                    "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,
+                    ("%s-category_role" % test_role_a.pk): role_full.pk,
+                    ("%s-category_role" % test_role_b.pk): role_comments.pk,
                 },
             )
 
     def test_change_role_categories_permissions_view(self):
         """change role categories perms view works"""
         self.client.post(
-            reverse('misago:admin:permissions:users:new'),
-            data=mock_role_form_data(Role(), {'name': 'Test CategoryRole'})
+            reverse("misago:admin:permissions:users:new"),
+            data=mock_role_form_data(Role(), {"name": "Test CategoryRole"}),
         )
 
-        test_role = Role.objects.get(name='Test CategoryRole')
+        test_role = Role.objects.get(name="Test CategoryRole")
 
         root = Category.objects.root_category()
         for descendant in root.get_descendants():
@@ -255,9 +241,9 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
 
         self.assertEqual(Category.objects.count(), 2)
         response = self.client.get(
-            reverse('misago:admin:permissions:users:categories', kwargs={
-                'pk': test_role.pk,
-            })
+            reverse(
+                "misago:admin:permissions:users:categories", kwargs={"pk": test_role.pk}
+            )
         )
         self.assertEqual(response.status_code, 302)
         """
@@ -270,56 +256,56 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         """
         root = Category.objects.root_category()
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Category A',
-                'new_parent': root.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Category A",
+                "new_parent": root.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Category C',
-                'new_parent': root.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Category C",
+                "new_parent": root.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
 
-        category_a = Category.objects.get(slug='category-a')
-        category_c = Category.objects.get(slug='category-c')
+        category_a = Category.objects.get(slug="category-a")
+        category_c = Category.objects.get(slug="category-c")
 
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Category B',
-                'new_parent': category_a.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Category B",
+                "new_parent": category_a.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
-        category_b = Category.objects.get(slug='category-b')
+        category_b = Category.objects.get(slug="category-b")
 
         self.client.post(
-            reverse('misago:admin:categories:nodes:new'),
+            reverse("misago:admin:categories:nodes:new"),
             data={
-                'name': 'Category D',
-                'new_parent': category_c.pk,
-                'prune_started_after': 0,
-                'prune_replied_after': 0,
+                "name": "Category D",
+                "new_parent": category_c.pk,
+                "prune_started_after": 0,
+                "prune_replied_after": 0,
             },
         )
-        category_d = Category.objects.get(slug='category-d')
+        category_d = Category.objects.get(slug="category-d")
 
         self.assertEqual(Category.objects.count(), 6)
 
         # See if form page is rendered
         response = self.client.get(
-            reverse('misago:admin:permissions:users:categories', kwargs={
-                'pk': test_role.pk,
-            })
+            reverse(
+                "misago:admin:permissions:users:categories", kwargs={"pk": test_role.pk}
+            )
         )
         self.assertContains(response, category_a.name)
         self.assertContains(response, category_b.name)
@@ -328,40 +314,36 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
 
         # Set test roles
         self.client.post(
-            reverse('misago:admin:permissions:categories:new'),
-            data=create_data({
-                'name': 'Test Comments',
-            }),
+            reverse("misago:admin:permissions:categories:new"),
+            data=create_data({"name": "Test Comments"}),
         )
-        role_comments = CategoryRole.objects.get(name='Test Comments')
+        role_comments = CategoryRole.objects.get(name="Test Comments")
 
         self.client.post(
-            reverse('misago:admin:permissions:categories:new'),
-            data=create_data({
-                'name': 'Test Full',
-            }),
+            reverse("misago:admin:permissions:categories:new"),
+            data=create_data({"name": "Test Full"}),
         )
-        role_full = CategoryRole.objects.get(name='Test Full')
+        role_full = CategoryRole.objects.get(name="Test Full")
 
         # See if form contains those roles
         response = self.client.get(
-            reverse('misago:admin:permissions:users:categories', kwargs={
-                'pk': test_role.pk,
-            })
+            reverse(
+                "misago:admin:permissions:users:categories", kwargs={"pk": test_role.pk}
+            )
         )
         self.assertContains(response, role_comments.name)
         self.assertContains(response, role_full.name)
 
         # Assign roles to categories
         response = self.client.post(
-            reverse('misago:admin:permissions:users:categories', kwargs={
-                'pk': test_role.pk,
-            }),
+            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,
+                ("%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,
             },
         )
         self.assertEqual(response.status_code, 302)
@@ -374,19 +356,24 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         self.assertEqual(
             categories_acls.get(category=category_b).category_role_id, role_comments.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_c).category_role_id, role_full.pk
+        )
+        self.assertEqual(
+            categories_acls.get(category=category_d).category_role_id, role_full.pk
+        )
 
         # Check that ACL was invalidated
         with assert_invalidates_cache(ACL_CACHE):
             self.client.post(
-                reverse('misago:admin:permissions:users:categories', kwargs={
-                    'pk': test_role.pk,
-                }),
+                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,
+                    ("%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,
                 },
-            )
+            )

+ 6 - 16
misago/categories/tests/test_prunecategories.py

@@ -46,7 +46,7 @@ class PruneCategoriesTests(TestCase):
             category.thread_set.get(id=thread.id)
 
         command_output = out.getvalue().strip()
-        self.assertEqual(command_output, 'Categories were pruned')
+        self.assertEqual(command_output, "Categories were pruned")
 
     def test_category_prune_by_last_reply(self):
         """command prunes category content based on last reply date"""
@@ -82,18 +82,13 @@ class PruneCategoriesTests(TestCase):
             category.thread_set.get(id=thread.id)
 
         command_output = out.getvalue().strip()
-        self.assertEqual(command_output, 'Categories were pruned')
+        self.assertEqual(command_output, "Categories were pruned")
 
     def test_category_archive_by_start_date(self):
         """command archives category content based on start date"""
         category = Category.objects.all_categories()[:1][0]
         archive = Category.objects.create(
-            lft=7,
-            rght=8,
-            tree_id=2,
-            level=0,
-            name='Archive',
-            slug='archive',
+            lft=7, rght=8, tree_id=2, level=0, name="Archive", slug="archive"
         )
 
         category.prune_started_after = 20
@@ -132,18 +127,13 @@ class PruneCategoriesTests(TestCase):
             category.thread_set.get(id=thread.id)
 
         command_output = out.getvalue().strip()
-        self.assertEqual(command_output, 'Categories were pruned')
+        self.assertEqual(command_output, "Categories were pruned")
 
     def test_category_archive_by_last_reply(self):
         """command archives category content based on last reply date"""
         category = Category.objects.all_categories()[:1][0]
         archive = Category.objects.create(
-            lft=7,
-            rght=8,
-            tree_id=2,
-            level=0,
-            name='Archive',
-            slug='archive',
+            lft=7, rght=8, tree_id=2, level=0, name="Archive", slug="archive"
         )
 
         category.prune_replied_after = 20
@@ -181,4 +171,4 @@ class PruneCategoriesTests(TestCase):
             category.thread_set.get(id=thread.id)
 
         command_output = out.getvalue().strip()
-        self.assertEqual(command_output, 'Categories were pruned')
+        self.assertEqual(command_output, "Categories were pruned")

+ 1 - 1
misago/categories/tests/test_synchronizecategories.py

@@ -30,4 +30,4 @@ class SynchronizeCategoriesTests(TestCase):
         self.assertEqual(category.posts, 60)
 
         command_output = out.getvalue().splitlines()[-1].strip()
-        self.assertTrue(command_output.startswith('Synchronized 3 categories in'))
+        self.assertTrue(command_output.startswith("Synchronized 3 categories in"))

+ 27 - 55
misago/categories/tests/test_utils.py

@@ -9,10 +9,10 @@ cache_versions = get_cache_versions()
 
 def get_patched_user_acl(user):
     user_acl = get_user_acl(user, cache_versions)
-    categories_acl = {'categories': {}, 'visible_categories': []}
+    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}
+        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
 
@@ -35,63 +35,33 @@ class CategoriesUtilsTests(AuthenticatedUserTestCase):
         super().setUp()
 
         self.root = Category.objects.root_category()
-        self.first_category = Category.objects.get(slug='first-category')
-
-        Category(
-            name='Category A',
-            slug='category-a',
-        ).insert_at(
-            self.root,
-            position='last-child',
-            save=True,
+        self.first_category = Category.objects.get(slug="first-category")
+
+        Category(name="Category A", slug="category-a").insert_at(
+            self.root, position="last-child", save=True
         )
-        Category(
-            name='Category E',
-            slug='category-e',
-        ).insert_at(
-            self.root,
-            position='last-child',
-            save=True,
+        Category(name="Category E", slug="category-e").insert_at(
+            self.root, position="last-child", save=True
         )
 
-        self.category_a = Category.objects.get(slug='category-a')
+        self.category_a = Category.objects.get(slug="category-a")
 
-        Category(
-            name='Category B',
-            slug='category-b',
-        ).insert_at(
-            self.category_a,
-            position='last-child',
-            save=True,
+        Category(name="Category B", slug="category-b").insert_at(
+            self.category_a, position="last-child", save=True
         )
 
-        self.category_b = Category.objects.get(slug='category-b')
+        self.category_b = Category.objects.get(slug="category-b")
 
-        Category(
-            name='Subcategory C',
-            slug='subcategory-c',
-        ).insert_at(
-            self.category_b,
-            position='last-child',
-            save=True,
+        Category(name="Subcategory C", slug="subcategory-c").insert_at(
+            self.category_b, position="last-child", save=True
         )
-        Category(
-            name='Subcategory D',
-            slug='subcategory-d',
-        ).insert_at(
-            self.category_b,
-            position='last-child',
-            save=True,
+        Category(name="Subcategory D", slug="subcategory-d").insert_at(
+            self.category_b, position="last-child", save=True
         )
 
-        self.category_e = Category.objects.get(slug='category-e')
-        Category(
-            name='Subcategory F',
-            slug='subcategory-f',
-        ).insert_at(
-            self.category_e,
-            position='last-child',
-            save=True,
+        self.category_e = Category.objects.get(slug="category-e")
+        Category(name="Subcategory F", slug="subcategory-f").insert_at(
+            self.category_e, position="last-child", save=True
         )
 
         self.user_acl = get_patched_user_acl(self.user)
@@ -101,20 +71,22 @@ class CategoriesUtilsTests(AuthenticatedUserTestCase):
         categories_tree = get_categories_tree(self.user, self.user_acl)
         self.assertEqual(len(categories_tree), 3)
 
-        self.assertEqual(categories_tree[0], Category.objects.get(slug='first-category'))
-        self.assertEqual(categories_tree[1], Category.objects.get(slug='category-a'))
-        self.assertEqual(categories_tree[2], Category.objects.get(slug='category-e'))
+        self.assertEqual(
+            categories_tree[0], Category.objects.get(slug="first-category")
+        )
+        self.assertEqual(categories_tree[1], Category.objects.get(slug="category-a"))
+        self.assertEqual(categories_tree[2], Category.objects.get(slug="category-e"))
 
     def test_root_categories_tree_with_parent(self):
         """get_categories_tree returns all children of given node"""
         categories_tree = get_categories_tree(self.user, self.user_acl, self.category_a)
         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):
         """get_categories_tree returns all children of given node"""
         categories_tree = get_categories_tree(
-            self.user, self.user_acl, Category.objects.get(slug='subcategory-f')
+            self.user, self.user_acl, Category.objects.get(slug="subcategory-f")
         )
         self.assertEqual(len(categories_tree), 0)
 

+ 14 - 14
misago/categories/tests/test_views.py

@@ -11,11 +11,11 @@ class CategoryViewsTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.category = Category.objects.get(slug='first-category')
+        self.category = Category.objects.get(slug="first-category")
 
     def test_index_renders(self):
         """categories list renders for authenticated"""
-        response = self.client.get(reverse('misago:categories'))
+        response = self.client.get(reverse("misago:categories"))
         self.assertContains(response, self.category.name)
         self.assertContains(response, self.category.get_absolute_url())
 
@@ -23,23 +23,23 @@ class CategoryViewsTests(AuthenticatedUserTestCase):
         """categories list renders for guest"""
         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())
 
-    @patch_user_acl({'visible_categories': []})
+    @patch_user_acl({"visible_categories": []})
     def test_index_no_perms_renders(self):
         """categories list renders no visible categories for authenticated"""
-        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())
 
-    @patch_user_acl({'visible_categories': []})
+    @patch_user_acl({"visible_categories": []})
     def test_index_no_perms_renders_for_guest(self):
         """categories list renders no visible categories for guest"""
         self.logout_user()
 
-        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())
 
@@ -48,11 +48,11 @@ class CategoryAPIViewsTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.category = Category.objects.get(slug='first-category')
+        self.category = Category.objects.get(slug="first-category")
 
     def test_list_renders(self):
         """api returns categories for authenticated"""
-        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())
 
@@ -60,20 +60,20 @@ class CategoryAPIViewsTests(AuthenticatedUserTestCase):
         """api returns categories for guest"""
         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())
 
-    @patch_user_acl({'visible_categories': []})
+    @patch_user_acl({"visible_categories": []})
     def test_list_no_perms_renders(self):
         """api returns no categories for authenticated"""
-        response = self.client.get(reverse('misago:api:category-list'))
+        response = self.client.get(reverse("misago:api:category-list"))
         assert json.loads(response.content) == []
 
-    @patch_user_acl({'visible_categories': []})
+    @patch_user_acl({"visible_categories": []})
     def test_list_no_perms_renders_for_guest(self):
         """api returns no categories for guest"""
         self.logout_user()
 
-        response = self.client.get(reverse('misago:api:category-list'))
+        response = self.client.get(reverse("misago:api:category-list"))
         assert json.loads(response.content) == []

+ 4 - 5
misago/categories/urls/__init__.py

@@ -6,13 +6,12 @@ from misago.core.views import home_redirect
 from misago.categories.views.categorieslist import categories
 
 if settings.MISAGO_THREADS_ON_INDEX:
-    URL_PATH = r'^categories/$'
+    URL_PATH = r"^categories/$"
 else:
-    URL_PATH = r'^$'
+    URL_PATH = r"^$"
 
 urlpatterns = [
-    url(URL_PATH, categories, name='categories'),
-
+    url(URL_PATH, categories, name="categories"),
     # fallback for after we changed index setting
-    url(r'^categories/$', home_redirect),
+    url(r"^categories/$", home_redirect),
 ]

+ 1 - 1
misago/categories/urls/api.py

@@ -3,5 +3,5 @@ from misago.core.apirouter import MisagoApiRouter
 
 
 router = MisagoApiRouter()
-router.register(r'categories', CategoryViewSet, base_name='category')
+router.register(r"categories", CategoryViewSet, base_name="category")
 urlpatterns = router.urls

+ 5 - 5
misago/categories/utils.py

@@ -5,17 +5,17 @@ from .models import Category
 
 
 def get_categories_tree(user, user_acl, parent=None, join_posters=False):
-    if not user_acl['visible_categories']:
+    if not user_acl["visible_categories"]:
         return []
 
     if parent:
-        queryset = parent.get_descendants().order_by('lft')
+        queryset = parent.get_descendants().order_by("lft")
     else:
         queryset = Category.objects.all_categories()
 
-    queryset_with_acl = queryset.filter(id__in=user_acl['visible_categories'])
+    queryset_with_acl = queryset.filter(id__in=user_acl["visible_categories"])
     if join_posters:
-        queryset_with_acl = queryset_with_acl.select_related('last_poster')
+        queryset_with_acl = queryset_with_acl.select_related("last_poster")
 
     visible_categories = list(queryset_with_acl)
 
@@ -36,7 +36,7 @@ def get_categories_tree(user, user_acl, parent=None, join_posters=False):
     categoriestracker.make_read_aware(user, user_acl, categories_list)
 
     for category in reversed(visible_categories):
-        if category.acl['can_browse']:
+        if category.acl["can_browse"]:
             category.parent = categories_dict.get(category.parent_id)
             if category.parent:
                 category.parent.threads += category.threads

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

@@ -11,9 +11,9 @@ from misago.threads.threadtypes import trees_map
 
 
 class CategoryAdmin(generic.AdminBaseMixin):
-    root_link = 'misago:admin:categories:nodes:index'
+    root_link = "misago:admin:categories:nodes:index"
     model = Category
-    templates_dir = 'misago/admin/categories'
+    templates_dir = "misago/admin/categories"
     message_404 = _("Requested category does not exist.")
 
     def get_target(self, kwargs):
@@ -35,11 +35,11 @@ class CategoriesList(CategoryAdmin, generic.ListView):
         return Category.objects.all_categories()
 
     def process_context(self, request, context):
-        context['items'] = [f for f in context['items']]
+        context["items"] = [f for f in context["items"]]
 
         children_lists = {}
 
-        for item in context['items']:
+        for item in context["items"]:
             item.level_range = range(item.level - 1)
             item.first = False
             item.last = False
@@ -58,22 +58,22 @@ class CategoryFormMixin(object):
 
     def handle_form(self, form, request, target):
         if form.instance.pk:
-            if form.instance.parent_id != form.cleaned_data['new_parent'].pk:
-                form.instance.move_to(form.cleaned_data['new_parent'], position='last-child')
+            if form.instance.parent_id != form.cleaned_data["new_parent"].pk:
+                form.instance.move_to(
+                    form.cleaned_data["new_parent"], position="last-child"
+                )
             form.instance.save()
-            if form.instance.parent_id != form.cleaned_data['new_parent'].pk:
+            if form.instance.parent_id != form.cleaned_data["new_parent"].pk:
                 Category.objects.clear_cache()
         else:
             form.instance.insert_at(
-                form.cleaned_data['new_parent'],
-                position='last-child',
-                save=True,
+                form.cleaned_data["new_parent"], position="last-child", save=True
             )
             Category.objects.clear_cache()
 
-        if form.cleaned_data.get('copy_permissions'):
+        if form.cleaned_data.get("copy_permissions"):
             form.instance.category_role_set.all().delete()
-            copy_from = form.cleaned_data['copy_permissions']
+            copy_from = form.cleaned_data["copy_permissions"]
 
             copied_acls = []
             for acl in copy_from.category_role_set.all():
@@ -89,7 +89,7 @@ class CategoryFormMixin(object):
                 RoleCategoryACL.objects.bulk_create(copied_acls)
 
         clear_acl_cache()
-        messages.success(request, self.message_submit % {'name': target.name})
+        messages.success(request, self.message_submit % {"name": target.name})
 
 
 class NewCategory(CategoryFormMixin, CategoryAdmin, generic.ModelFormView):
@@ -102,14 +102,14 @@ class EditCategory(CategoryFormMixin, CategoryAdmin, generic.ModelFormView):
 
 class DeleteCategory(CategoryAdmin, generic.ModelFormView):
     message_submit = _('Category "%(name)s" has been deleted.')
-    template = 'delete.html'
+    template = "delete.html"
 
     def create_form_type(self, request, target):
         return DeleteFormFactory(target)
 
     def handle_form(self, form, request, target):
-        move_children_to = form.cleaned_data.get('move_children_to')
-        move_threads_to = form.cleaned_data.get('move_threads_to')
+        move_children_to = form.cleaned_data.get("move_children_to")
+        move_threads_to = form.cleaned_data.get("move_threads_to")
 
         if move_children_to:
             for child in target.get_children():
@@ -117,11 +117,11 @@ class DeleteCategory(CategoryAdmin, generic.ModelFormView):
                 move_children_to = Category.objects.get(pk=move_children_to.pk)
                 child = Category.objects.get(pk=child.pk)
 
-                child.move_to(move_children_to, 'last-child')
+                child.move_to(move_children_to, "last-child")
                 if move_threads_to and child.pk == move_threads_to.pk:
                     move_threads_to = child
         else:
-            for child in target.get_descendants().order_by('-lft'):
+            for child in target.get_descendants().order_by("-lft"):
                 child.delete_content()
                 child.delete()
 
@@ -136,7 +136,7 @@ class DeleteCategory(CategoryAdmin, generic.ModelFormView):
         instance = Category.objects.get(pk=form.instance.pk)
         instance.delete()
 
-        messages.success(request, self.message_submit % {'name': target.name})
+        messages.success(request, self.message_submit % {"name": target.name})
         return redirect(self.root_link)
 
 
@@ -148,11 +148,11 @@ class MoveDownCategory(CategoryAdmin, generic.ButtonView):
             other_target = None
 
         if other_target:
-            Category.objects.move_node(target, other_target, 'right')
+            Category.objects.move_node(target, other_target, "right")
             Category.objects.clear_cache()
 
             message = _('Category "%(name)s" has been moved below "%(other)s".')
-            targets_names = {'name': target.name, 'other': other_target.name}
+            targets_names = {"name": target.name, "other": other_target.name}
             messages.success(request, message % targets_names)
 
 
@@ -164,9 +164,9 @@ class MoveUpCategory(CategoryAdmin, generic.ButtonView):
             other_target = None
 
         if other_target:
-            Category.objects.move_node(target, other_target, 'left')
+            Category.objects.move_node(target, other_target, "left")
             Category.objects.clear_cache()
 
             message = _('Category "%(name)s" has been moved above "%(other)s".')
-            targets_names = {'name': target.name, 'other': other_target.name}
+            targets_names = {"name": target.name, "other": other_target.name}
             messages.success(request, message % targets_names)

+ 15 - 9
misago/categories/views/categorieslist.py

@@ -1,18 +1,24 @@
 from django.shortcuts import render
 from django.urls import reverse
 
-from misago.categories.serializers import CategoryWithPosterSerializer as CategorySerializer
+from misago.categories.serializers import (
+    CategoryWithPosterSerializer as CategorySerializer,
+)
 from misago.categories.utils import get_categories_tree
 
 
 def categories(request):
-    categories_tree = get_categories_tree(request.user, request.user_acl, join_posters=True)
+    categories_tree = get_categories_tree(
+        request.user, request.user_acl, join_posters=True
+    )
 
-    request.frontend_context.update({
-        'CATEGORIES': CategorySerializer(categories_tree, many=True).data,
-        'CATEGORIES_API': reverse('misago:api:category-list'),
-    })
+    request.frontend_context.update(
+        {
+            "CATEGORIES": CategorySerializer(categories_tree, many=True).data,
+            "CATEGORIES_API": reverse("misago:api:category-list"),
+        }
+    )
 
-    return render(request, 'misago/categories/list.html', {
-        'categories': categories_tree,
-    })
+    return render(
+        request, "misago/categories/list.html", {"categories": categories_tree}
+    )

+ 47 - 51
misago/categories/views/permsadmin.py

@@ -8,21 +8,24 @@ from misago.acl.models import Role
 from misago.acl.views import RoleAdmin, RolesList
 from misago.admin.views import generic
 from misago.categories.forms import (
-    CategoryRoleForm, CategoryRolesACLFormFactory, RoleCategoryACLFormFactory)
+    CategoryRoleForm,
+    CategoryRolesACLFormFactory,
+    RoleCategoryACLFormFactory,
+)
 from misago.categories.models import Category, CategoryRole, RoleCategoryACL
 
 from .categoriesadmin import CategoriesList, CategoryAdmin
 
 
 class CategoryRoleAdmin(generic.AdminBaseMixin):
-    root_link = 'misago:admin:permissions:categories:index'
+    root_link = "misago:admin:permissions:categories:index"
     model = CategoryRole
-    templates_dir = 'misago/admin/categoryroles'
+    templates_dir = "misago/admin/categoryroles"
     message_404 = _("Requested role does not exist.")
 
 
 class CategoryRolesList(CategoryRoleAdmin, generic.ListView):
-    ordering = (('name', None), )
+    ordering = (("name", None),)
 
 
 class RoleFormMixin(object):
@@ -31,7 +34,7 @@ class RoleFormMixin(object):
 
         perms_forms = get_permissions_forms(target)
 
-        if request.method == 'POST':
+        if request.method == "POST":
             perms_forms = get_permissions_forms(target, request.POST)
             valid_forms = 0
             for permissions_form in perms_forms:
@@ -48,9 +51,9 @@ class RoleFormMixin(object):
                 form.instance.permissions = new_permissions
                 form.instance.save()
 
-                messages.success(request, self.message_submit % {'name': target.name})
+                messages.success(request, self.message_submit % {"name": target.name})
 
-                if 'stay' in request.POST:
+                if "stay" in request.POST:
                     return redirect(request.path)
                 else:
                     return redirect(self.root_link)
@@ -58,12 +61,7 @@ class RoleFormMixin(object):
                 form.add_error(None, _("Form contains errors."))
 
         return self.render(
-            request,
-            {
-                'form': form,
-                'target': target,
-                'perms_forms': perms_forms,
-            },
+            request, {"form": form, "target": target, "perms_forms": perms_forms}
         )
 
 
@@ -79,50 +77,51 @@ class DeleteCategoryRole(CategoryRoleAdmin, generic.ButtonView):
     def check_permissions(self, request, target):
         if target.special_role:
             message = _('Role "%(name)s" is special role and can\'t be deleted.')
-            return message % {'name': target.name}
+            return message % {"name": target.name}
 
     def button_action(self, request, target):
         target.delete()
         message = _('Role "%(name)s" has been deleted.')
-        messages.success(request, message % {'name': target.name})
+        messages.success(request, message % {"name": target.name})
 
 
 class CategoryPermissions(CategoryAdmin, generic.ModelFormView):
     """category roles view for assinging roles to category, add link to it in categories list"""
-    templates_dir = 'misago/admin/categoryroles'
-    template = 'categoryroles.html'
+
+    templates_dir = "misago/admin/categoryroles"
+    template = "categoryroles.html"
 
     def real_dispatch(self, request, target):
-        category_roles = CategoryRole.objects.order_by('name')
+        category_roles = CategoryRole.objects.order_by("name")
 
         assigned_roles = {}
-        for acl in target.category_role_set.select_related('category_role'):
+        for acl in target.category_role_set.select_related("category_role"):
             assigned_roles[acl.role_id] = acl.category_role
 
         forms = []
         forms_are_valid = True
-        for role in Role.objects.order_by('name'):
+        for role in Role.objects.order_by("name"):
             FormType = CategoryRolesACLFormFactory(
                 role, category_roles, assigned_roles.get(role.pk)
             )
 
-            if request.method == 'POST':
+            if request.method == "POST":
                 forms.append(FormType(request.POST, prefix=role.pk))
                 if not forms[-1].is_valid():
                     forms_are_valid = False
             else:
                 forms.append(FormType(prefix=role.pk))
 
-        if request.method == 'POST' and forms_are_valid:
+        if request.method == "POST" and forms_are_valid:
             target.category_role_set.all().delete()
             new_permissions = []
             for form in forms:
-                if form.cleaned_data['category_role']:
+                if form.cleaned_data["category_role"]:
                     new_permissions.append(
                         RoleCategoryACL(
                             role=form.role,
                             category=target,
-                            category_role=form.cleaned_data['category_role'],
+                            category_role=form.cleaned_data["category_role"],
                         )
                     )
             if new_permissions:
@@ -131,66 +130,66 @@ class CategoryPermissions(CategoryAdmin, generic.ModelFormView):
             clear_acl_cache()
 
             message = _("Category %(name)s permissions have been changed.")
-            messages.success(request, message % {'name': target.name})
-            if 'stay' in request.POST:
+            messages.success(request, message % {"name": target.name})
+            if "stay" in request.POST:
                 return redirect(request.path)
             else:
                 return redirect(self.root_link)
 
-        return self.render(request, {
-            'forms': forms,
-            'target': target,
-        })
+        return self.render(request, {"forms": forms, "target": target})
 
 
 CategoriesList.add_item_action(
     name=_("Category permissions"),
-    icon='fa fa-adjust',
-    link='misago:admin:categories:nodes:permissions',
-    style='success',
+    icon="fa fa-adjust",
+    link="misago:admin:categories:nodes:permissions",
+    style="success",
 )
 
 
 class RoleCategoriesACL(RoleAdmin, generic.ModelFormView):
     """role categories view for assinging categories to role, add link to it in user roles list"""
-    templates_dir = 'misago/admin/categoryroles'
-    template = 'rolecategories.html'
+
+    templates_dir = "misago/admin/categoryroles"
+    template = "rolecategories.html"
 
     def real_dispatch(self, request, target):
         categories = Category.objects.all_categories()
-        roles = CategoryRole.objects.order_by('name')
+        roles = CategoryRole.objects.order_by("name")
 
         if not categories:
             messages.info(request, _("No categories exist."))
             return redirect(self.root_link)
 
         choices = {}
-        for choice in target.categories_acls.select_related('category_role'):
+        for choice in target.categories_acls.select_related("category_role"):
             choices[choice.category_id] = choice.category_role
 
         forms = []
         forms_are_valid = True
         for category in categories:
             category.level_range = range(category.level - 1)
-            FormType = RoleCategoryACLFormFactory(category, roles, choices.get(category.pk))
+            FormType = RoleCategoryACLFormFactory(
+                category, roles, choices.get(category.pk)
+            )
 
-            if request.method == 'POST':
+            if request.method == "POST":
                 forms.append(FormType(request.POST, prefix=category.pk))
                 if not forms[-1].is_valid():
                     forms_are_valid = False
             else:
                 forms.append(FormType(prefix=category.pk))
 
-        if request.method == 'POST' and forms_are_valid:
+        if request.method == "POST" and forms_are_valid:
             target.categories_acls.all().delete()
             new_permissions = []
             for form in forms:
-                if form.cleaned_data['role']:
+                if form.cleaned_data["role"]:
                     new_permissions.append(
                         RoleCategoryACL(
                             role=target,
                             category=form.category,
-                            category_role=form.cleaned_data['role'],
+                            category_role=form.cleaned_data["role"],
                         )
                     )
             if new_permissions:
@@ -199,21 +198,18 @@ class RoleCategoriesACL(RoleAdmin, generic.ModelFormView):
             clear_acl_cache()
 
             message = _("Category permissions for role %(name)s have been changed.")
-            messages.success(request, message % {'name': target.name})
-            if 'stay' in request.POST:
+            messages.success(request, message % {"name": target.name})
+            if "stay" in request.POST:
                 return redirect(request.path)
             else:
                 return redirect(self.root_link)
 
-        return self.render(request, {
-            'forms': forms,
-            'target': target,
-        })
+        return self.render(request, {"forms": forms, "target": target})
 
 
 RolesList.add_item_action(
     name=_("Categories permissions"),
-    icon='fa fa-comments-o',
-    link='misago:admin:permissions:users:categories',
-    style='success',
+    icon="fa fa-comments-o",
+    link="misago:admin:permissions:users:categories",
+    style="success",
 )

+ 1 - 1
misago/conf/__init__.py

@@ -1,6 +1,6 @@
 from .staticsettings import StaticSettings
 
-default_app_config = 'misago.conf.apps.MisagoConfConfig'
+default_app_config = "misago.conf.apps.MisagoConfConfig"
 
 SETTINGS_CACHE = "settings"
 

+ 7 - 7
misago/conf/admin.py

@@ -6,18 +6,18 @@ from . import views
 
 class MisagoAdminExtension(object):
     def register_urlpatterns(self, urlpatterns):
-        urlpatterns.namespace(r'^settings/', 'settings', 'system')
+        urlpatterns.namespace(r"^settings/", "settings", "system")
 
         urlpatterns.patterns(
-            'system:settings',
-            url(r'^$', views.index, name='index'),
-            url(r'^(?P<key>(\w|-)+)/$', views.group, name='group'),
+            "system:settings",
+            url(r"^$", views.index, name="index"),
+            url(r"^(?P<key>(\w|-)+)/$", views.group, name="group"),
         )
 
     def register_navigation_nodes(self, site):
         site.add_node(
             name=_("Settings"),
-            icon='fa fa-sliders',
-            parent='misago:admin:system',
-            link='misago:admin:system:settings:index',
+            icon="fa fa-sliders",
+            parent="misago:admin:system",
+            link="misago:admin:system:settings:index",
         )

+ 2 - 2
misago/conf/apps.py

@@ -2,6 +2,6 @@ from django.apps import AppConfig
 
 
 class MisagoConfConfig(AppConfig):
-    name = 'misago.conf'
-    label = 'misago_conf'
+    name = "misago.conf"
+    label = "misago_conf"
     verbose_name = "Misago Configuration"

+ 30 - 26
misago/conf/context_processors.py

@@ -11,37 +11,41 @@ BLANK_AVATAR_URL = static(settings.MISAGO_BLANK_AVATAR)
 
 def conf(request):
     return {
-        'BLANK_AVATAR_URL': BLANK_AVATAR_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,
+        "BLANK_AVATAR_URL": BLANK_AVATAR_URL,
+        "DEBUG": settings.DEBUG,
+        "LANGUAGE_CODE_SHORT": get_language()[:2],
+        "LOGIN_REDIRECT_URL": settings.LOGIN_REDIRECT_URL,
+        "LOGIN_URL": settings.LOGIN_URL,
+        "LOGOUT_URL": settings.LOGOUT_URL,
+        "THREADS_ON_INDEX": settings.MISAGO_THREADS_ON_INDEX,
+        "settings": request.settings,
     }
 
 
 def preload_settings_json(request):
     preloaded_settings = request.settings.get_public_settings()
 
-    preloaded_settings.update({
-        'LOGIN_API_URL': settings.MISAGO_LOGIN_API_URL,
-        'LOGIN_REDIRECT_URL': reverse(settings.LOGIN_REDIRECT_URL),
-        'LOGIN_URL': reverse(settings.LOGIN_URL),
-        'LOGOUT_URL': reverse(settings.LOGOUT_URL),
-        'SOCIAL_AUTH': get_enabled_social_auth_sites_list(),
-    })
-
-    request.frontend_context.update({
-        'BLANK_AVATAR_URL': BLANK_AVATAR_URL,
-        '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,
-    })
+    preloaded_settings.update(
+        {
+            "LOGIN_API_URL": settings.MISAGO_LOGIN_API_URL,
+            "LOGIN_REDIRECT_URL": reverse(settings.LOGIN_REDIRECT_URL),
+            "LOGIN_URL": reverse(settings.LOGIN_URL),
+            "LOGOUT_URL": reverse(settings.LOGOUT_URL),
+            "SOCIAL_AUTH": get_enabled_social_auth_sites_list(),
+        }
+    )
+
+    request.frontend_context.update(
+        {
+            "BLANK_AVATAR_URL": BLANK_AVATAR_URL,
+            "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 {}

+ 1 - 1
misago/conf/debugtoolbar.py

@@ -2,4 +2,4 @@ import os
 
 
 def enable_debug_toolbar(_):
-    return os.environ.get('IN_MISAGO_DOCKER', '') == "1"
+    return os.environ.get("IN_MISAGO_DOCKER", "") == "1"

+ 126 - 119
misago/conf/defaults.py

@@ -16,17 +16,17 @@ MISAGO_ADDRESS = None
 # https://misago.readthedocs.io/en/latest/developers/acls.html#extending-permissions-system
 
 MISAGO_ACL_EXTENSIONS = [
-    'misago.users.permissions.account',
-    'misago.users.permissions.profiles',
-    'misago.users.permissions.moderation',
-    'misago.users.permissions.delete',
-    'misago.categories.permissions',
-    'misago.threads.permissions.attachments',
-    'misago.threads.permissions.polls',
-    'misago.threads.permissions.threads',
-    'misago.threads.permissions.privatethreads',
-    'misago.threads.permissions.bestanswers',
-    'misago.search.permissions',
+    "misago.users.permissions.account",
+    "misago.users.permissions.profiles",
+    "misago.users.permissions.moderation",
+    "misago.users.permissions.delete",
+    "misago.categories.permissions",
+    "misago.threads.permissions.attachments",
+    "misago.threads.permissions.polls",
+    "misago.threads.permissions.threads",
+    "misago.threads.permissions.privatethreads",
+    "misago.threads.permissions.bestanswers",
+    "misago.search.permissions",
 ]
 
 
@@ -93,60 +93,57 @@ MISAGO_POST_SEARCH_FILTERS = []
 
 MISAGO_POSTING_MIDDLEWARES = [
     # Always keep FloodProtectionMiddleware middleware first one
-    'misago.threads.api.postingendpoint.floodprotection.FloodProtectionMiddleware',
-
-    'misago.threads.api.postingendpoint.category.CategoryMiddleware',
-    'misago.threads.api.postingendpoint.privatethread.PrivateThreadMiddleware',
-    'misago.threads.api.postingendpoint.reply.ReplyMiddleware',
-    'misago.threads.api.postingendpoint.moderationqueue.ModerationQueueMiddleware',
-    'misago.threads.api.postingendpoint.attachments.AttachmentsMiddleware',
-    'misago.threads.api.postingendpoint.participants.ParticipantsMiddleware',
-    'misago.threads.api.postingendpoint.pin.PinMiddleware',
-    'misago.threads.api.postingendpoint.close.CloseMiddleware',
-    'misago.threads.api.postingendpoint.hide.HideMiddleware',
-    'misago.threads.api.postingendpoint.protect.ProtectMiddleware',
-    'misago.threads.api.postingendpoint.recordedit.RecordEditMiddleware',
-    'misago.threads.api.postingendpoint.updatestats.UpdateStatsMiddleware',
-    'misago.threads.api.postingendpoint.mentions.MentionsMiddleware',
-    'misago.threads.api.postingendpoint.subscribe.SubscribeMiddleware',
-    'misago.threads.api.postingendpoint.syncprivatethreads.SyncPrivateThreadsMiddleware',
-
+    "misago.threads.api.postingendpoint.floodprotection.FloodProtectionMiddleware",
+    "misago.threads.api.postingendpoint.category.CategoryMiddleware",
+    "misago.threads.api.postingendpoint.privatethread.PrivateThreadMiddleware",
+    "misago.threads.api.postingendpoint.reply.ReplyMiddleware",
+    "misago.threads.api.postingendpoint.moderationqueue.ModerationQueueMiddleware",
+    "misago.threads.api.postingendpoint.attachments.AttachmentsMiddleware",
+    "misago.threads.api.postingendpoint.participants.ParticipantsMiddleware",
+    "misago.threads.api.postingendpoint.pin.PinMiddleware",
+    "misago.threads.api.postingendpoint.close.CloseMiddleware",
+    "misago.threads.api.postingendpoint.hide.HideMiddleware",
+    "misago.threads.api.postingendpoint.protect.ProtectMiddleware",
+    "misago.threads.api.postingendpoint.recordedit.RecordEditMiddleware",
+    "misago.threads.api.postingendpoint.updatestats.UpdateStatsMiddleware",
+    "misago.threads.api.postingendpoint.mentions.MentionsMiddleware",
+    "misago.threads.api.postingendpoint.subscribe.SubscribeMiddleware",
+    "misago.threads.api.postingendpoint.syncprivatethreads.SyncPrivateThreadsMiddleware",
     # Always keep SaveChangesMiddleware middleware after all state-changing middlewares
-    'misago.threads.api.postingendpoint.savechanges.SaveChangesMiddleware',
-
+    "misago.threads.api.postingendpoint.savechanges.SaveChangesMiddleware",
     # Those middlewares are last because they don't change app state
-    'misago.threads.api.postingendpoint.emailnotification.EmailNotificationMiddleware',
+    "misago.threads.api.postingendpoint.emailnotification.EmailNotificationMiddleware",
 ]
 
 
 # Configured thread types
 
 MISAGO_THREAD_TYPES = [
-    'misago.threads.threadtypes.thread.Thread',
-    'misago.threads.threadtypes.privatethread.PrivateThread',
+    "misago.threads.threadtypes.thread.Thread",
+    "misago.threads.threadtypes.privatethread.PrivateThread",
 ]
 
 
 # Search extensions
 
 MISAGO_SEARCH_EXTENSIONS = [
-    'misago.threads.search.SearchThreads',
-    'misago.users.search.SearchUsers',
+    "misago.threads.search.SearchThreads",
+    "misago.users.search.SearchUsers",
 ]
 
 
 # Misago-admin specific date formats
 
-MISAGO_COMPACT_DATE_FORMAT_DAY_MONTH = 'j M'
-MISAGO_COMPACT_DATE_FORMAT_DAY_MONTH_YEAR = 'M \'y'
+MISAGO_COMPACT_DATE_FORMAT_DAY_MONTH = "j M"
+MISAGO_COMPACT_DATE_FORMAT_DAY_MONTH_YEAR = "M 'y"
 
 
 # Additional registration validators
 # https://misago.readthedocs.io/en/latest/developers/validating_registrations.html
 
 MISAGO_NEW_REGISTRATIONS_VALIDATORS = [
-    'misago.users.validators.validate_gmail_email',
-    'misago.users.validators.validate_with_sfs',
+    "misago.users.validators.validate_gmail_email",
+    "misago.users.validators.validate_with_sfs",
 ]
 
 
@@ -164,26 +161,23 @@ MISAGO_STOP_FORUM_SPAM_MIN_CONFIDENCE = 80
 # Social Auth Backends Names Overrides
 # This seeting may be used to customise auth backends names displayed in the UI
 
-MISAGO_SOCIAL_AUTH_BACKENDS_NAMES = {} 
+MISAGO_SOCIAL_AUTH_BACKENDS_NAMES = {}
 
 
 # Login API URL
 
-MISAGO_LOGIN_API_URL = 'auth'
+MISAGO_LOGIN_API_URL = "auth"
 
 
 # Misago Admin Path
 # Omit starting and trailing slashes. To disable Misago admin, empty this value.
 
-MISAGO_ADMIN_PATH = 'admincp'
+MISAGO_ADMIN_PATH = "admincp"
 
 
 # Admin urls namespaces that Misago's AdminAuthMiddleware should protect
 
-MISAGO_ADMIN_NAMESPACES = [
-    'admin',
-    'misago:admin',
-]
+MISAGO_ADMIN_NAMESPACES = ["admin", "misago:admin"]
 
 
 # How long (in minutes) since previous request to admin namespace should admin session last.
@@ -212,7 +206,7 @@ MISAGO_HOURLY_POST_LIMIT = 100
 
 # Function used for generating individual avatar for user
 
-MISAGO_DYNAMIC_AVATAR_DRAWER = 'misago.users.avatars.dynamic.draw_default'
+MISAGO_DYNAMIC_AVATAR_DRAWER = "misago.users.avatars.dynamic.draw_default"
 
 
 # Path to directory containing avatar galleries
@@ -230,7 +224,7 @@ MISAGO_AVATARS_SIZES = [400, 200, 150, 100, 64, 50, 30]
 
 # Path to blank avatar image used for guests and removed users.
 
-MISAGO_BLANK_AVATAR = 'blank-avatar.png'
+MISAGO_BLANK_AVATAR = "blank-avatar.png"
 
 
 # Threads lists pagination settings
@@ -274,8 +268,8 @@ MISAGO_ATTACHMENT_ORPHANED_EXPIRE = 24 * 60
 # Names of files served when user requests file that doesn't exist or is unavailable
 # Those files will be sought within STATIC_ROOT directory
 
-MISAGO_404_IMAGE = 'misago/img/error-404.png'
-MISAGO_403_IMAGE = 'misago/img/error-403.png'
+MISAGO_404_IMAGE = "misago/img/error-404.png"
+MISAGO_403_IMAGE = "misago/img/error-403.png"
 
 
 # Controls max age in days of items that Misago has to process to make rankings
@@ -313,72 +307,85 @@ MISAGO_READTRACKER_CUTOFF = 40
 # Available Moment.js locales
 
 MISAGO_MOMENT_JS_LOCALES = [
-    'af',
-    'ar-ma', 'ar-sa', 'ar-tn', 'ar',
-    'az',
-    'be',
-    'bg',
-    'bn',
-    'bo',
-    'br',
-    'bs',
-    'ca',
-    'cs',
-    'cv',
-    'cy',
-    'da',
-    'de-at', 'de',
-    'el',
-    'en-au', 'en-ca', 'en-gb',
-    'eo',
-    'es',
-    'et',
-    'eu',
-    'fa',
-    'fi',
-    'fo',
-    'fr-ca',
-    'fr',
-    'fy',
-    'gl',
-    'he',
-    'hi',
-    'hr',
-    'hu', 'hy-am',
-    'id',
-    'is',
-    'it',
-    'ja',
-    'ka',
-    'km',
-    'ko',
-    'lb',
-    'lt',
-    'lv',
-    'mk',
-    'ml',
-    'mr',
-    'ms-my', 'my',
-    'nb',
-    'ne',
-    'nl',
-    'nn',
-    'pl',
-    'pt-br', 'pt',
-    'ro',
-    'ru',
-    'sk',
-    'sl',
-    'sq',
-    'sr-cyrl', 'sr',
-    'sv',
-    'ta',
-    'th',
-    'tl-ph',
-    'tr',
-    'tzm-latn', 'tzm',
-    'uk',
-    'uz',
-    'vi',
-    'zh-cn', 'zh-hans', 'zh-tw',
+    "af",
+    "ar-ma",
+    "ar-sa",
+    "ar-tn",
+    "ar",
+    "az",
+    "be",
+    "bg",
+    "bn",
+    "bo",
+    "br",
+    "bs",
+    "ca",
+    "cs",
+    "cv",
+    "cy",
+    "da",
+    "de-at",
+    "de",
+    "el",
+    "en-au",
+    "en-ca",
+    "en-gb",
+    "eo",
+    "es",
+    "et",
+    "eu",
+    "fa",
+    "fi",
+    "fo",
+    "fr-ca",
+    "fr",
+    "fy",
+    "gl",
+    "he",
+    "hi",
+    "hr",
+    "hu",
+    "hy-am",
+    "id",
+    "is",
+    "it",
+    "ja",
+    "ka",
+    "km",
+    "ko",
+    "lb",
+    "lt",
+    "lv",
+    "mk",
+    "ml",
+    "mr",
+    "ms-my",
+    "my",
+    "nb",
+    "ne",
+    "nl",
+    "nn",
+    "pl",
+    "pt-br",
+    "pt",
+    "ro",
+    "ru",
+    "sk",
+    "sl",
+    "sq",
+    "sr-cyrl",
+    "sr",
+    "sv",
+    "ta",
+    "th",
+    "tl-ph",
+    "tr",
+    "tzm-latn",
+    "tzm",
+    "uk",
+    "uz",
+    "vi",
+    "zh-cn",
+    "zh-hans",
+    "zh-tw",
 ]

+ 6 - 6
misago/conf/dynamicsettings.py

@@ -52,14 +52,14 @@ def get_settings_from_db():
     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,
+                "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,
+                "value": setting.value,
+                "is_lazy": setting.is_lazy,
+                "is_public": setting.is_public,
             }
     return settings

+ 53 - 53
misago/conf/forms.py

@@ -4,7 +4,7 @@ from django.utils.translation import ngettext
 
 from misago.admin.forms import YesNoSwitch
 
-__all__ = ['ChangeSettingsForm']
+__all__ = ["ChangeSettingsForm"]
 
 
 class ValidateChoicesNum(object):
@@ -17,82 +17,84 @@ class ValidateChoicesNum(object):
 
         if self.min_choices and self.min_choices > data_len:
             message = ngettext(
-                'You have to select at least %(choices)d option.',
-                'You have to select at least %(choices)d options.',
+                "You have to select at least %(choices)d option.",
+                "You have to select at least %(choices)d options.",
                 self.min_choices,
             )
-            raise forms.ValidationError(message % {'choices': self.min_choices})
+            raise forms.ValidationError(message % {"choices": self.min_choices})
 
         if self.max_choices and self.max_choices < data_len:
             message = ngettext(
-                'You cannot select more than %(choices)d option.',
-                'You cannot select more than %(choices)d options.',
+                "You cannot select more than %(choices)d option.",
+                "You cannot select more than %(choices)d options.",
                 self.max_choices,
             )
-            raise forms.ValidationError(message % {'choices': self.max_choices})
+            raise forms.ValidationError(message % {"choices": self.max_choices})
 
         return data
 
 
 def basic_kwargs(setting, extra):
     kwargs = {
-        'label': _(setting.name),
-        'initial': setting.value,
-        'required': extra.get('min_length') or extra.get('min'),
+        "label": _(setting.name),
+        "initial": setting.value,
+        "required": extra.get("min_length") or extra.get("min"),
     }
 
     if setting.description:
-        kwargs['help_text'] = _(setting.description)
+        kwargs["help_text"] = _(setting.description)
 
-    if setting.form_field == 'yesno':
+    if setting.form_field == "yesno":
         # YesNoSwitch is int-base and setting is bool based
         # this means we need to do quick conversion
-        kwargs['initial'] = 1 if kwargs['initial'] else 0
+        kwargs["initial"] = 1 if kwargs["initial"] else 0
 
-    if kwargs['required']:
-        if kwargs.get('help_text'):
-            format = {'help_text': kwargs['help_text']}
-            kwargs['help_text'] = _('Required. %(help_text)s') % format
+    if kwargs["required"]:
+        if kwargs.get("help_text"):
+            format = {"help_text": kwargs["help_text"]}
+            kwargs["help_text"] = _("Required. %(help_text)s") % format
         else:
-            kwargs['help_text'] = _('This field is required.')
+            kwargs["help_text"] = _("This field is required.")
 
     return kwargs
 
 
 def localise_choices(extra):
-    return [(v, _(l)) for v, l in extra.get('choices', [])]
+    return [(v, _(l)) for v, l in extra.get("choices", [])]
 
 
 def create_checkbox(setting, kwargs, extra):
-    kwargs['widget'] = forms.CheckboxSelectMultiple()
-    kwargs['choices'] = localise_choices(extra)
+    kwargs["widget"] = forms.CheckboxSelectMultiple()
+    kwargs["choices"] = localise_choices(extra)
 
-    if extra.get('min') or extra.get('max'):
-        kwargs['validators'] = [ValidateChoicesNum(extra.pop('min', 0), extra.pop('max', 0))]
+    if extra.get("min") or extra.get("max"):
+        kwargs["validators"] = [
+            ValidateChoicesNum(extra.pop("min", 0), extra.pop("max", 0))
+        ]
 
-    if setting.python_type == 'int':
-        return forms.TypedMultipleChoiceField(coerce='int', **kwargs)
+    if setting.python_type == "int":
+        return forms.TypedMultipleChoiceField(coerce="int", **kwargs)
     else:
         return forms.MultipleChoiceField(**kwargs)
 
 
 def create_choice(setting, kwargs, extra):
-    if setting.form_field == 'choice':
-        kwargs['widget'] = forms.RadioSelect()
+    if setting.form_field == "choice":
+        kwargs["widget"] = forms.RadioSelect()
     else:
-        kwargs['widget'] = forms.Select()
+        kwargs["widget"] = forms.Select()
 
-    kwargs['choices'] = localise_choices(extra)
+    kwargs["choices"] = localise_choices(extra)
 
-    if setting.python_type == 'int':
-        return forms.TypedChoiceField(coerce='int', **kwargs)
+    if setting.python_type == "int":
+        return forms.TypedChoiceField(coerce="int", **kwargs)
     else:
         return forms.ChoiceField(**kwargs)
 
 
 def create_text(setting, kwargs, extra):
     kwargs.update(extra)
-    if setting.python_type == 'int':
+    if setting.python_type == "int":
         return forms.IntegerField(**kwargs)
     else:
         return forms.CharField(**kwargs)
@@ -100,12 +102,12 @@ def create_text(setting, kwargs, extra):
 
 def create_textarea(setting, kwargs, extra):
     widget_kwargs = {}
-    if extra.get('min_length', 0) == 0:
-        kwargs['required'] = False
-    if extra.get('rows', 0):
-        widget_kwargs['attrs'] = {'rows': extra.pop('rows')}
+    if extra.get("min_length", 0) == 0:
+        kwargs["required"] = False
+    if extra.get("rows", 0):
+        widget_kwargs["attrs"] = {"rows": extra.pop("rows")}
 
-    kwargs['widget'] = forms.Textarea(**widget_kwargs)
+    kwargs["widget"] = forms.Textarea(**widget_kwargs)
     return forms.CharField(**kwargs)
 
 
@@ -114,12 +116,12 @@ def create_yesno(setting, kwargs, extra):
 
 
 FIELD_STYPES = {
-    'checkbox': create_checkbox,
-    'radio': create_choice,
-    'select': create_choice,
-    'text': create_text,
-    'textarea': create_textarea,
-    'yesno': create_yesno,
+    "checkbox": create_checkbox,
+    "radio": create_choice,
+    "select": create_choice,
+    "text": create_text,
+    "textarea": create_textarea,
+    "yesno": create_yesno,
 }
 
 
@@ -129,7 +131,9 @@ def setting_field(FormType, setting):
 
     form_field = field_factory(setting, basic_kwargs(setting, field_extra), field_extra)
 
-    FormType = type('FormType%s' % setting.pk, (FormType, ), {setting.setting: form_field})
+    FormType = type(
+        "FormType%s" % setting.pk, (FormType,), {setting.setting: form_field}
+    )
 
     return FormType
 
@@ -145,13 +149,12 @@ def ChangeSettingsForm(data=None, group=None):
     fieldset_legend = None
     fieldset_form = FormType
     fieldset_fields = False
-    for setting in group.setting_set.order_by('order'):
+    for setting in group.setting_set.order_by("order"):
         if setting.legend and setting.legend != fieldset_legend:
             if fieldset_fields:
-                fieldsets.append({
-                    'legend': fieldset_legend,
-                    'form': fieldset_form(data),
-                })
+                fieldsets.append(
+                    {"legend": fieldset_legend, "form": fieldset_form(data)}
+                )
             fieldset_legend = setting.legend
             fieldset_form = FormType
             fieldset_fields = False
@@ -159,9 +162,6 @@ def ChangeSettingsForm(data=None, group=None):
         fieldset_form = setting_field(fieldset_form, setting)
 
     if fieldset_fields:
-        fieldsets.append({
-            'legend': fieldset_legend,
-            'form': fieldset_form(data),
-        })
+        fieldsets.append({"legend": fieldset_legend, "form": fieldset_form(data)})
 
     return fieldsets

+ 10 - 9
misago/conf/hydrators.py

@@ -1,7 +1,8 @@
 # fixme: rename this moduleto serialize
 
+
 def hydrate_string(dry_value):
-    return str(dry_value) if dry_value else ''
+    return str(dry_value) if dry_value else ""
 
 
 def dehydrate_string(wet_value):
@@ -9,11 +10,11 @@ def dehydrate_string(wet_value):
 
 
 def hydrate_bool(dry_value):
-    return dry_value == 'True'
+    return dry_value == "True"
 
 
 def dehydrate_bool(wet_value):
-    return 'True' if wet_value else 'False'
+    return "True" if wet_value else "False"
 
 
 def hydrate_int(dry_value):
@@ -25,18 +26,18 @@ def dehydrate_int(wet_value):
 
 
 def hydrate_list(dry_value):
-    return [x for x in dry_value.split(',') if x]
+    return [x for x in dry_value.split(",") if x]
 
 
 def dehydrate_list(wet_value):
-    return ','.join(wet_value)
+    return ",".join(wet_value)
 
 
 VALUE_HYDRATORS = {
-    'string': (hydrate_string, dehydrate_string),
-    'bool': (hydrate_bool, dehydrate_bool),
-    'int': (hydrate_int, dehydrate_int),
-    'list': (hydrate_list, dehydrate_list),
+    "string": (hydrate_string, dehydrate_string),
+    "bool": (hydrate_bool, dehydrate_bool),
+    "int": (hydrate_int, dehydrate_int),
+    "list": (hydrate_list, dehydrate_list),
 }
 
 

+ 2 - 0
misago/conf/middleware.py

@@ -5,9 +5,11 @@ 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)
 

+ 37 - 29
misago/conf/migrations/0001_initial.py

@@ -11,51 +11,59 @@ class Migration(migrations.Migration):
 
     operations = [
         migrations.CreateModel(
-            name='Setting',
+            name="Setting",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
                 ),
-                ('setting', models.CharField(unique=True, max_length=255)),
-                ('name', models.CharField(max_length=255)),
-                ('description', models.TextField(null=True, blank=True)),
-                ('legend', models.CharField(max_length=255, null=True, blank=True)),
-                ('order', models.IntegerField(default=0, db_index=True)),
-                ('dry_value', models.TextField(null=True, blank=True)),
-                ('default_value', models.TextField(null=True, blank=True)),
-                ('python_type', models.CharField(default='string', max_length=255)),
-                ('is_public', models.BooleanField(default=False)),
-                ('is_lazy', models.BooleanField(default=False)),
-                ('form_field', models.CharField(default='text', max_length=255)),
-                ('field_extra', JSONField()),
+                ("setting", models.CharField(unique=True, max_length=255)),
+                ("name", models.CharField(max_length=255)),
+                ("description", models.TextField(null=True, blank=True)),
+                ("legend", models.CharField(max_length=255, null=True, blank=True)),
+                ("order", models.IntegerField(default=0, db_index=True)),
+                ("dry_value", models.TextField(null=True, blank=True)),
+                ("default_value", models.TextField(null=True, blank=True)),
+                ("python_type", models.CharField(default="string", max_length=255)),
+                ("is_public", models.BooleanField(default=False)),
+                ("is_lazy", models.BooleanField(default=False)),
+                ("form_field", models.CharField(default="text", max_length=255)),
+                ("field_extra", JSONField()),
             ],
             options={},
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
         migrations.CreateModel(
-            name='SettingsGroup',
+            name="SettingsGroup",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
                 ),
-                ('key', models.CharField(unique=True, max_length=255)),
-                ('name', models.CharField(max_length=255)),
-                ('description', models.TextField(null=True, blank=True)),
+                ("key", models.CharField(unique=True, max_length=255)),
+                ("name", models.CharField(max_length=255)),
+                ("description", models.TextField(null=True, blank=True)),
             ],
             options={},
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
         migrations.AddField(
-            model_name='setting',
-            name='group',
+            model_name="setting",
+            name="group",
             field=models.ForeignKey(
                 on_delete=django.db.models.deletion.CASCADE,
-                to='misago_conf.SettingsGroup',
-                to_field='id',
+                to="misago_conf.SettingsGroup",
+                to_field="id",
             ),
             preserve_default=True,
         ),

+ 2 - 6
misago/conf/migrations/0002_cache_version.py

@@ -8,10 +8,6 @@ from misago.conf import SETTINGS_CACHE
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_conf', '0001_initial'),
-    ]
+    dependencies = [("misago_conf", "0001_initial")]
 
-    operations = [
-        StartCacheVersioning(SETTINGS_CACHE)
-    ]
+    operations = [StartCacheVersioning(SETTINGS_CACHE)]

+ 22 - 20
misago/conf/migrationutils.py

@@ -3,9 +3,9 @@ from .utils import get_setting_value, has_custom_value
 
 
 def migrate_settings_group(apps, group_fixture, old_group_key=None):
-    SettingsGroup = apps.get_model('misago_conf', 'SettingsGroup')
-    Setting = apps.get_model('misago_conf', 'Setting')
-    group_key = group_fixture['key']
+    SettingsGroup = apps.get_model("misago_conf", "SettingsGroup")
+    Setting = apps.get_model("misago_conf", "Setting")
+    group_key = group_fixture["key"]
 
     # Fetch settings group
 
@@ -21,10 +21,10 @@ def migrate_settings_group(apps, group_fixture, old_group_key=None):
 
     # Update group's attributes
 
-    group.key = group_fixture['key']
-    group.name = group_fixture['name']
-    if group_fixture.get('description'):
-        group.description = group_fixture.get('description')
+    group.key = group_fixture["key"]
+    group.name = group_fixture["name"]
+    if group_fixture.get("description"):
+        group.description = group_fixture.get("description")
     group.save()
 
     # Delete groups settings and make new ones
@@ -32,8 +32,8 @@ def migrate_settings_group(apps, group_fixture, old_group_key=None):
 
     group.setting_set.all().delete()
 
-    for order, setting_fixture in enumerate(group_fixture['settings']):
-        old_value = custom_settings_values.pop(setting_fixture['setting'], None)
+    for order, setting_fixture in enumerate(group_fixture["settings"]):
+        old_value = custom_settings_values.pop(setting_fixture["setting"], None)
         migrate_setting(Setting, group, setting_fixture, order, old_value)
 
 
@@ -55,27 +55,29 @@ def get_custom_settings_values(group):
 
 
 def migrate_setting(Setting, group, setting_fixture, order, old_value):
-    setting_fixture['group'] = group
-    setting_fixture['order'] = order
+    setting_fixture["group"] = group
+    setting_fixture["order"] = order
 
-    setting_fixture['name'] = setting_fixture['name']
-    if setting_fixture.get('description'):
-        setting_fixture['description'] = setting_fixture.get('description')
+    setting_fixture["name"] = setting_fixture["name"]
+    if setting_fixture.get("description"):
+        setting_fixture["description"] = setting_fixture.get("description")
 
-    if setting_fixture.get('field_extra') and setting_fixture.get('field_extra').get('choices'):
-        untranslated_choices = setting_fixture['field_extra']['choices']
+    if setting_fixture.get("field_extra") and setting_fixture.get("field_extra").get(
+        "choices"
+    ):
+        untranslated_choices = setting_fixture["field_extra"]["choices"]
         translated_choices = []
         for val, name in untranslated_choices:
             translated_choices.append((val, name))
-        setting_fixture['field_extra']['choices'] = tuple(translated_choices)
+        setting_fixture["field_extra"]["choices"] = tuple(translated_choices)
 
     if old_value is None:
-        value = setting_fixture.pop('value', None)
+        value = setting_fixture.pop("value", None)
     else:
         value = old_value
-    setting_fixture.pop('value', None)
+    setting_fixture.pop("value", None)
 
-    field_extra = setting_fixture.pop('field_extra', None)
+    field_extra = setting_fixture.pop("field_extra", None)
 
     setting = Setting(**setting_fixture)
     setting.dry_value = dehydrate_value(setting.python_type, value)

+ 3 - 3
misago/conf/models.py

@@ -35,7 +35,7 @@ class SettingsManager(models.Manager):
             try:
                 setting = self.get(setting=setting)
                 setting.value = wet_value
-                setting.save(update_fields=['dry_value'])
+                setting.save(update_fields=["dry_value"])
             except Setting.DoesNotExist:
                 return 0
 
@@ -49,10 +49,10 @@ class Setting(models.Model):
     order = models.IntegerField(default=0, db_index=True)
     dry_value = models.TextField(null=True, blank=True)
     default_value = models.TextField(null=True, blank=True)
-    python_type = models.CharField(max_length=255, default='string')
+    python_type = models.CharField(max_length=255, default="string")
     is_public = models.BooleanField(default=False)
     is_lazy = models.BooleanField(default=False)
-    form_field = models.CharField(max_length=255, default='text')
+    form_field = models.CharField(max_length=255, default="text")
     field_extra = JSONField()
 
     objects = SettingsManager()

+ 2 - 1
misago/conf/test.py

@@ -18,4 +18,5 @@ class override_dynamic_settings:
         def test_function_wrapper(*args, **kwargs):
             with self as context:
                 return f(*args, **kwargs)
-        return test_function_wrapper
+
+        return test_function_wrapper

+ 9 - 13
misago/conf/tests/test_admin_views.py

@@ -7,20 +7,18 @@ from misago.conf.models import SettingsGroup
 class AdminSettingsViewsTests(AdminTestCase):
     def test_link_registered(self):
         """admin index view contains settings link"""
-        response = self.client.get(reverse('misago:admin:index'))
+        response = self.client.get(reverse("misago:admin:index"))
 
-        self.assertContains(response, reverse('misago:admin:system:settings:index'))
+        self.assertContains(response, reverse("misago:admin:system:settings:index"))
 
     def test_groups_list_view(self):
         """settings group view returns 200 and contains all settings groups"""
-        response = self.client.get(reverse('misago:admin:system:settings:index'))
+        response = self.client.get(reverse("misago:admin:system:settings:index"))
 
         self.assertEqual(response.status_code, 200)
         for group in SettingsGroup.objects.all():
             group_link = reverse(
-                'misago:admin:system:settings:group', kwargs={
-                    'key': group.key,
-                }
+                "misago:admin:system:settings:group", kwargs={"key": group.key}
             )
             self.assertContains(response, group.name)
             self.assertContains(response, group_link)
@@ -28,21 +26,19 @@ class AdminSettingsViewsTests(AdminTestCase):
     def test_invalid_group_handling(self):
         """invalid group results in redirect to settings list"""
         group_link = reverse(
-            'misago:admin:system:settings:group', kwargs={
-                'key': 'invalid-group',
-            }
+            "misago:admin:system:settings:group", kwargs={"key": "invalid-group"}
         )
         response = self.client.get(group_link)
         self.assertEqual(response.status_code, 302)
-        self.assertTrue(reverse('misago:admin:system:settings:index') in response['location'])
+        self.assertTrue(
+            reverse("misago:admin:system:settings:index") in response["location"]
+        )
 
     def test_groups_views(self):
         """each settings group view returns 200 and contains all settings in group"""
         for group in SettingsGroup.objects.all():
             group_link = reverse(
-                'misago:admin:system:settings:group', kwargs={
-                    'key': group.key,
-                }
+                "misago:admin:system:settings:group", kwargs={"key": group.key}
             )
             response = self.client.get(group_link)
 

+ 2 - 2
misago/conf/tests/test_context_processors.py

@@ -7,11 +7,11 @@ from misago.conf.context_processors import conf
 
 def test_request_settings_are_included_in_template_context(db, dynamic_settings):
     mock_request = Mock(settings=dynamic_settings)
-    context_settings = conf(mock_request)['settings']
+    context_settings = conf(mock_request)["settings"]
     assert context_settings == mock_request.settings
 
 
 def test_settings_are_included_in_frontend_context(db, client):
-    response = client.get('/')
+    response = client.get("/")
     assert response.status_code == 200
     assert '"SETTINGS": {"' in response.content.decode("utf-8")

+ 2 - 2
misago/conf/tests/test_dynamic_settings_middleware.py

@@ -54,7 +54,7 @@ def test_middleware_is_not_reading_db(
 
 
 def test_middleware_is_not_reading_cache(db, mocker, get_response, request_mock):
-    cache_get = mocker.patch('django.core.cache.cache.get')
+    cache_get = mocker.patch("django.core.cache.cache.get")
     middleware = dynamic_settings_middleware(get_response)
     middleware(request_mock)
-    cache_get.assert_not_called()
+    cache_get.assert_not_called()

+ 14 - 12
misago/conf/tests/test_getting_dynamic_settings_values.py

@@ -7,8 +7,8 @@ from misago.conf.dynamicsettings import DynamicSettings
 def test_settings_are_loaded_from_database_if_cache_is_not_available(
     db, mocker, cache_versions, django_assert_num_queries
 ):
-    mocker.patch('django.core.cache.cache.set')
-    mocker.patch('django.core.cache.cache.get', return_value=None)
+    mocker.patch("django.core.cache.cache.set")
+    mocker.patch("django.core.cache.cache.get", return_value=None)
     with django_assert_num_queries(1):
         DynamicSettings(cache_versions)
 
@@ -16,16 +16,16 @@ def test_settings_are_loaded_from_database_if_cache_is_not_available(
 def test_settings_are_loaded_from_cache_if_it_is_not_none(
     db, mocker, cache_versions, django_assert_num_queries
 ):
-    mocker.patch('django.core.cache.cache.set')
-    cache_get = mocker.patch('django.core.cache.cache.get', return_value={})
+    mocker.patch("django.core.cache.cache.set")
+    cache_get = mocker.patch("django.core.cache.cache.get", return_value={})
     with django_assert_num_queries(0):
         DynamicSettings(cache_versions)
     cache_get.assert_called_once()
 
 
 def test_settings_cache_is_set_if_none_exists(db, mocker, cache_versions):
-    cache_set = mocker.patch('django.core.cache.cache.set')
-    mocker.patch('django.core.cache.cache.get', return_value=None)
+    cache_set = mocker.patch("django.core.cache.cache.set")
+    mocker.patch("django.core.cache.cache.get", return_value=None)
 
     DynamicSettings(cache_versions)
     cache_set.assert_called_once()
@@ -34,16 +34,16 @@ def test_settings_cache_is_set_if_none_exists(db, mocker, cache_versions):
 def test_settings_cache_is_not_set_if_it_already_exists(
     db, mocker, cache_versions, django_assert_num_queries
 ):
-    cache_set = mocker.patch('django.core.cache.cache.set')
-    mocker.patch('django.core.cache.cache.get', return_value={})
+    cache_set = mocker.patch("django.core.cache.cache.set")
+    mocker.patch("django.core.cache.cache.get", return_value={})
     with django_assert_num_queries(0):
         DynamicSettings(cache_versions)
     cache_set.assert_not_called()
 
 
 def test_settings_cache_key_includes_cache_name_and_version(db, mocker, cache_versions):
-    cache_set = mocker.patch('django.core.cache.cache.set')
-    mocker.patch('django.core.cache.cache.get', return_value=None)
+    cache_set = mocker.patch("django.core.cache.cache.set")
+    mocker.patch("django.core.cache.cache.get", return_value=None)
     DynamicSettings(cache_versions)
     cache_key = cache_set.call_args[0][0]
     assert SETTINGS_CACHE in cache_key
@@ -55,7 +55,9 @@ def test_accessing_attr_returns_setting_value(db, cache_versions):
     assert settings.forum_name == "Misago"
 
 
-def test_accessing_attr_for_undefined_setting_raises_attribute_error(db, cache_versions):
+def test_accessing_attr_for_undefined_setting_raises_attribute_error(
+    db, cache_versions
+):
     settings = DynamicSettings(cache_versions)
     with pytest.raises(AttributeError):
         settings.not_existing
@@ -128,4 +130,4 @@ def test_public_settings_getter_excludes_private_settings_from_dict(
 ):
     settings = DynamicSettings(cache_versions)
     public_settings = settings.get_public_settings()
-    assert "private_setting" not in public_settings
+    assert "private_setting" not in public_settings

+ 1 - 1
misago/conf/tests/test_getting_static_settings_values.py

@@ -28,4 +28,4 @@ def test_undefined_setting_value_can_be_overridden_using_django_util(settings):
 
 def test_accessing_attr_for_undefined_setting_raises_attribute_error(settings):
     with pytest.raises(AttributeError):
-        assert settings.UNDEFINED_SETTING
+        assert settings.UNDEFINED_SETTING

+ 23 - 23
misago/conf/tests/test_hydrators.py

@@ -7,53 +7,53 @@ from misago.conf.models import Setting
 class HydratorsTests(TestCase):
     def test_hydrate_dehydrate_string(self):
         """string value is correctly hydrated and dehydrated"""
-        wet_value = 'Ni!'
-        dry_value = dehydrate_value('string', wet_value)
-        self.assertEqual(hydrate_value('string', dry_value), wet_value)
+        wet_value = "Ni!"
+        dry_value = dehydrate_value("string", wet_value)
+        self.assertEqual(hydrate_value("string", dry_value), wet_value)
 
     def test_hydrate_dehydrate_bool(self):
         """bool values are correctly hydrated and dehydrated"""
         wet_value = True
-        dry_value = dehydrate_value('bool', wet_value)
-        self.assertEqual(hydrate_value('bool', dry_value), wet_value)
+        dry_value = dehydrate_value("bool", wet_value)
+        self.assertEqual(hydrate_value("bool", dry_value), wet_value)
 
         wet_value = False
-        dry_value = dehydrate_value('bool', wet_value)
-        self.assertEqual(hydrate_value('bool', dry_value), wet_value)
+        dry_value = dehydrate_value("bool", wet_value)
+        self.assertEqual(hydrate_value("bool", dry_value), wet_value)
 
     def test_hydrate_dehydrate_int(self):
         """int value is correctly hydrated and dehydrated"""
         wet_value = 9001
-        dry_value = dehydrate_value('int', wet_value)
-        self.assertEqual(hydrate_value('int', dry_value), wet_value)
+        dry_value = dehydrate_value("int", wet_value)
+        self.assertEqual(hydrate_value("int", dry_value), wet_value)
 
     def test_hydrate_dehydrate_list(self):
         """list is correctly hydrated and dehydrated"""
-        wet_value = ['foxtrot', 'uniform', 'hotel']
-        dry_value = dehydrate_value('list', wet_value)
-        self.assertEqual(hydrate_value('list', dry_value), wet_value)
+        wet_value = ["foxtrot", "uniform", "hotel"]
+        dry_value = dehydrate_value("list", wet_value)
+        self.assertEqual(hydrate_value("list", dry_value), wet_value)
 
     def test_hydrate_dehydrate_empty_list(self):
         """empty list is correctly hydrated and dehydrated"""
         wet_value = []
-        dry_value = dehydrate_value('list', wet_value)
-        self.assertEqual(hydrate_value('list', dry_value), wet_value)
+        dry_value = dehydrate_value("list", wet_value)
+        self.assertEqual(hydrate_value("list", dry_value), wet_value)
 
     def test_value_error(self):
         """unsupported type raises ValueError"""
         with self.assertRaises(ValueError):
-            hydrate_value('eric', None)
+            hydrate_value("eric", None)
 
         with self.assertRaises(ValueError):
-            dehydrate_value('eric', None)
+            dehydrate_value("eric", None)
 
 
 class HydratorsModelTests(TestCase):
     def test_hydrate_dehydrate_string(self):
         """string value is correctly hydrated and dehydrated in model"""
-        setting = Setting(python_type='string')
+        setting = Setting(python_type="string")
 
-        wet_value = 'Lorem Ipsum'
+        wet_value = "Lorem Ipsum"
         dry_value = dehydrate_value(setting.python_type, wet_value)
 
         setting.value = wet_value
@@ -62,7 +62,7 @@ class HydratorsModelTests(TestCase):
 
     def test_hydrate_dehydrate_bool(self):
         """bool values are correctly hydrated and dehydrated in model"""
-        setting = Setting(python_type='bool')
+        setting = Setting(python_type="bool")
 
         wet_value = True
         dry_value = dehydrate_value(setting.python_type, wet_value)
@@ -80,7 +80,7 @@ class HydratorsModelTests(TestCase):
 
     def test_hydrate_dehydrate_int(self):
         """int value is correctly hydrated and dehydrated in model"""
-        setting = Setting(python_type='int')
+        setting = Setting(python_type="int")
 
         wet_value = 9001
         dry_value = dehydrate_value(setting.python_type, wet_value)
@@ -91,9 +91,9 @@ class HydratorsModelTests(TestCase):
 
     def test_hydrate_dehydrate_list(self):
         """list is correctly hydrated and dehydrated in model"""
-        setting = Setting(python_type='list')
+        setting = Setting(python_type="list")
 
-        wet_value = ['Lorem', 'Ipsum', 'Dolor', 'Met']
+        wet_value = ["Lorem", "Ipsum", "Dolor", "Met"]
         dry_value = dehydrate_value(setting.python_type, wet_value)
 
         setting.value = wet_value
@@ -102,7 +102,7 @@ class HydratorsModelTests(TestCase):
 
     def test_hydrate_dehydrate_empty_list(self):
         """empty list is correctly hydrated and dehydrated in model"""
-        setting = Setting(python_type='list')
+        setting = Setting(python_type="list")
 
         wet_value = []
         dry_value = dehydrate_value(setting.python_type, wet_value)

+ 38 - 48
misago/conf/tests/test_migrationutils.py

@@ -8,26 +8,21 @@ from misago.conf.models import SettingsGroup
 class DBConfMigrationUtilsTests(TestCase):
     def setUp(self):
         self.test_group = {
-            'key': 'test_group',
-            'name': "Test settings",
-            'description': "Those are test settings.",
-            'settings': [
+            "key": "test_group",
+            "name": "Test settings",
+            "description": "Those are test settings.",
+            "settings": [
                 {
-                    'setting': 'fish_name',
-                    'name': "Fish's name",
-                    'value': "Eric",
-                    'field_extra': {
-                        'min_length': 2,
-                        'max_length': 255,
-                    },
+                    "setting": "fish_name",
+                    "name": "Fish's name",
+                    "value": "Eric",
+                    "field_extra": {"min_length": 2, "max_length": 255},
                 },
                 {
-                    'setting': 'fish_license_no',
-                    'name': "Fish's license number",
-                    'default_value': '123-456',
-                    'field_extra': {
-                        'max_length': 255,
-                    },
+                    "setting": "fish_license_no",
+                    "name": "Fish's license number",
+                    "default_value": "123-456",
+                    "field_extra": {"max_length": 255},
                 },
             ],
         }
@@ -38,59 +33,54 @@ class DBConfMigrationUtilsTests(TestCase):
     def test_get_custom_group_and_settings(self):
         """tests setup created settings group"""
         custom_group = migrationutils.get_group(
-            apps.get_model('misago_conf', 'SettingsGroup'), self.test_group['key']
+            apps.get_model("misago_conf", "SettingsGroup"), self.test_group["key"]
         )
 
-        self.assertEqual(custom_group.key, self.test_group['key'])
-        self.assertEqual(custom_group.name, self.test_group['name'])
-        self.assertEqual(custom_group.description, self.test_group['description'])
+        self.assertEqual(custom_group.key, self.test_group["key"])
+        self.assertEqual(custom_group.name, self.test_group["name"])
+        self.assertEqual(custom_group.description, self.test_group["description"])
 
         custom_settings = migrationutils.get_custom_settings_values(custom_group)
 
-        self.assertEqual(custom_settings['fish_name'], 'Eric')
-        self.assertTrue('fish_license_no' not in custom_settings)
+        self.assertEqual(custom_settings["fish_name"], "Eric")
+        self.assertTrue("fish_license_no" not in custom_settings)
 
     def test_change_group_key(self):
         """migrate_settings_group changed group key"""
 
         new_group = {
-            'key': 'new_test_group',
-            'name': "New test settings",
-            'description': "Those are updated test settings.",
-            'settings': [
+            "key": "new_test_group",
+            "name": "New test settings",
+            "description": "Those are updated test settings.",
+            "settings": [
                 {
-                    'setting': 'fish_new_name',
-                    'name': "Fish's new name",
-                    'value': "Eric",
-                    'field_extra': {
-                        'min_length': 2,
-                        'max_length': 255,
-                    },
+                    "setting": "fish_new_name",
+                    "name": "Fish's new name",
+                    "value": "Eric",
+                    "field_extra": {"min_length": 2, "max_length": 255},
                 },
                 {
-                    'setting': 'fish_new_license_no',
-                    'name': "Fish's changed license number",
-                    'default_value': '123-456',
-                    'field_extra': {
-                        'max_length': 255,
-                    },
+                    "setting": "fish_new_license_no",
+                    "name": "Fish's changed license number",
+                    "default_value": "123-456",
+                    "field_extra": {"max_length": 255},
                 },
             ],
         }
 
         migrationutils.migrate_settings_group(
-            apps, new_group, old_group_key=self.test_group['key']
+            apps, new_group, old_group_key=self.test_group["key"]
         )
 
         db_group = migrationutils.get_group(
-            apps.get_model('misago_conf', 'SettingsGroup'), new_group['key']
+            apps.get_model("misago_conf", "SettingsGroup"), new_group["key"]
         )
 
         self.assertEqual(SettingsGroup.objects.count(), self.groups_count)
-        self.assertEqual(db_group.key, new_group['key'])
-        self.assertEqual(db_group.name, new_group['name'])
-        self.assertEqual(db_group.description, new_group['description'])
+        self.assertEqual(db_group.key, new_group["key"])
+        self.assertEqual(db_group.name, new_group["name"])
+        self.assertEqual(db_group.description, new_group["description"])
 
-        for setting in new_group['settings']:
-            db_setting = db_group.setting_set.get(setting=setting['setting'])
-            self.assertEqual(db_setting.name, setting['name'])
+        for setting in new_group["settings"]:
+            db_setting = db_group.setting_set.get(setting=setting["setting"])
+            self.assertEqual(db_setting.name, setting["name"])

+ 12 - 25
misago/conf/tests/test_models.py

@@ -6,42 +6,29 @@ from misago.conf.models import Setting
 class SettingModelTests(TestCase):
     def test_real_value(self):
         """setting returns real value correctyly"""
-        setting_model = Setting(
-            python_type='list',
-            dry_value='',
-        )
+        setting_model = Setting(python_type="list", dry_value="")
         self.assertEqual(setting_model.value, [])
 
-        setting_model = Setting(
-            python_type='list',
-            dry_value='Arthur,Lancelot,Patsy',
-        )
-        self.assertEqual(setting_model.value, ['Arthur', 'Lancelot', 'Patsy'])
+        setting_model = Setting(python_type="list", dry_value="Arthur,Lancelot,Patsy")
+        self.assertEqual(setting_model.value, ["Arthur", "Lancelot", "Patsy"])
 
-        setting_model = Setting(
-            python_type='list',
-            default_value='Arthur,Patsy',
-        )
-        self.assertEqual(setting_model.value, ['Arthur', 'Patsy'])
+        setting_model = Setting(python_type="list", default_value="Arthur,Patsy")
+        self.assertEqual(setting_model.value, ["Arthur", "Patsy"])
 
         setting_model = Setting(
-            python_type='list',
-            dry_value='Arthur,Robin,Patsy',
-            default_value='Arthur,Patsy',
+            python_type="list",
+            dry_value="Arthur,Robin,Patsy",
+            default_value="Arthur,Patsy",
         )
-        self.assertEqual(setting_model.value, ['Arthur', 'Robin', 'Patsy'])
+        self.assertEqual(setting_model.value, ["Arthur", "Robin", "Patsy"])
 
     def test_set_value(self):
         """setting sets value correctyly"""
-        setting_model = Setting(
-            python_type='int',
-            dry_value='42',
-            default_value='9001',
-        )
+        setting_model = Setting(python_type="int", dry_value="42", default_value="9001")
 
         setting_model.value = 3000
         self.assertEqual(setting_model.value, 3000)
-        self.assertEqual(setting_model.dry_value, '3000')
+        self.assertEqual(setting_model.dry_value, "3000")
 
         setting_model.value = None
         self.assertEqual(setting_model.value, 9001)
@@ -55,6 +42,6 @@ class SettingModelTests(TestCase):
         setting_model.field_extra = test_extra
         self.assertEqual(setting_model.field_extra, test_extra)
 
-        test_extra = {'min_lenght': 5, 'max_length': 12}
+        test_extra = {"min_lenght": 5, "max_length": 12}
         setting_model.field_extra = test_extra
         self.assertEqual(setting_model.field_extra, test_extra)

+ 20 - 16
misago/conf/views.py

@@ -12,16 +12,16 @@ from .models import SettingsGroup
 def render(request, template, context=None):
     context = context or {}
 
-    context['settings_groups'] = SettingsGroup.objects.ordered_alphabetically()
+    context["settings_groups"] = SettingsGroup.objects.ordered_alphabetically()
 
-    if not 'active_group' in context:
-        context['active_group'] = {'key': None}
+    if not "active_group" in context:
+        context["active_group"] = {"key": None}
 
     return mi_render(request, template, context)
 
 
 def index(request):
-    return render(request, 'misago/admin/conf/index.html')
+    return render(request, "misago/admin/conf/index.html")
 
 
 def group(request, key):
@@ -29,32 +29,36 @@ def group(request, key):
         active_group = SettingsGroup.objects.get(key=key)
     except SettingsGroup.DoesNotExist:
         messages.error(request, _("Settings group could not be found."))
-        return redirect('misago:admin:system:settings:index')
+        return redirect("misago:admin:system:settings:index")
 
     fieldsets = ChangeSettingsForm(group=active_group)
-    if request.method == 'POST':
+    if request.method == "POST":
         fieldsets = ChangeSettingsForm(request.POST, group=active_group)
-        valid_fieldsets = len([True for fieldset in fieldsets if fieldset['form'].is_valid()])
+        valid_fieldsets = len(
+            [True for fieldset in fieldsets if fieldset["form"].is_valid()]
+        )
         if len(fieldsets) == valid_fieldsets:
             new_values = {}
             for fieldset in fieldsets:
-                new_values.update(fieldset['form'].cleaned_data)
+                new_values.update(fieldset["form"].cleaned_data)
 
             for setting in active_group.setting_set.all():
                 setting.value = new_values[setting.setting]
-                setting.save(update_fields=['dry_value'])
+                setting.save(update_fields=["dry_value"])
 
             clear_settings_cache()
 
             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)
 
-    use_single_form_template = (len(fieldsets) == 1 and not fieldsets[0]['legend'])
+    use_single_form_template = len(fieldsets) == 1 and not fieldsets[0]["legend"]
 
     return render(
-        request, 'misago/admin/conf/group.html', {
-            'active_group': active_group,
-            'fieldsets': fieldsets,
-            'use_single_form_template': use_single_form_template,
-        }
+        request,
+        "misago/admin/conf/group.html",
+        {
+            "active_group": active_group,
+            "fieldsets": fieldsets,
+            "use_single_form_template": use_single_form_template,
+        },
     )

+ 1 - 5
misago/conftest.py

@@ -10,11 +10,7 @@ from misago.users.testutils import create_test_superuser, create_test_user
 
 
 def get_cache_versions():
-    return {
-        ACL_CACHE: "abcdefgh",
-        BANS_CACHE: "abcdefgh",
-        SETTINGS_CACHE: "abcdefgh",
-    }
+    return {ACL_CACHE: "abcdefgh", BANS_CACHE: "abcdefgh", SETTINGS_CACHE: "abcdefgh"}
 
 
 @pytest.fixture

+ 7 - 8
misago/core/__init__.py

@@ -2,8 +2,8 @@ from django.conf import settings
 from django.core.checks import register, Critical
 
 SUPPORTED_ENGINES = [
-    'django.db.backends.postgresql',
-    'django.db.backends.postgresql_psycopg2',
+    "django.db.backends.postgresql",
+    "django.db.backends.postgresql_psycopg2",
 ]
 
 
@@ -12,15 +12,14 @@ def check_db_engine(app_configs, **kwargs):
     errors = []
 
     try:
-        if settings.DATABASES['default']['ENGINE'] not in SUPPORTED_ENGINES:
+        if settings.DATABASES["default"]["ENGINE"] not in SUPPORTED_ENGINES:
             raise ValueError()
     except (AttributeError, KeyError, ValueError):
-        errors.append(Critical(
-            msg='Misago requires PostgreSQL database.',
-            id='misago.001',
-        ))
+        errors.append(
+            Critical(msg="Misago requires PostgreSQL database.", id="misago.001")
+        )
 
     return errors
 
 
-default_app_config = 'misago.core.apps.MisagoCoreConfig'
+default_app_config = "misago.core.apps.MisagoCoreConfig"

+ 5 - 5
misago/core/admin.py

@@ -3,13 +3,13 @@ from django.utils.translation import gettext_lazy as _
 
 class MisagoAdminExtension(object):
     def register_urlpatterns(self, urlpatterns):
-        urlpatterns.namespace(r'^system/', 'system')
+        urlpatterns.namespace(r"^system/", "system")
 
     def register_navigation_nodes(self, site):
         site.add_node(
             name=_("System"),
-            icon='fa fa-gears',
-            parent='misago:admin',
-            namespace='misago:admin:system',
-            link='misago:admin:system:settings:index',
+            icon="fa fa-gears",
+            parent="misago:admin",
+            namespace="misago:admin:system",
+            link="misago:admin:system:settings:index",
         )

+ 24 - 36
misago/core/apipatch.py

@@ -5,7 +5,7 @@ from django.db import transaction
 from django.http import Http404
 
 
-ALLOWED_OPS = ('add', 'remove', 'replace')
+ALLOWED_OPS = ("add", "remove", "replace")
 
 
 class InvalidAction(Exception):
@@ -17,51 +17,39 @@ class ApiPatch(object):
         self._actions = []
 
     def add(self, path, handler):
-        self._actions.append({
-            'op': 'add',
-            'path': path,
-            'handler': handler,
-        })
+        self._actions.append({"op": "add", "path": path, "handler": handler})
 
     def remove(self, path, handler):
-        self._actions.append({
-            'op': 'remove',
-            'path': path,
-            'handler': handler,
-        })
+        self._actions.append({"op": "remove", "path": path, "handler": handler})
 
     def replace(self, path, handler):
-        self._actions.append({
-            'op': 'replace',
-            'path': path,
-            'handler': handler,
-        })
+        self._actions.append({"op": "replace", "path": path, "handler": handler})
 
     def dispatch(self, request, target):
         if not isinstance(request.data, list):
-            return Response({
-                'detail': "PATCH request should be list of operations",
-            }, status=400)
+            return Response(
+                {"detail": "PATCH request should be list of operations"}, status=400
+            )
 
         detail = []
         is_errored = False
 
-        patch = {'id': target.pk}
+        patch = {"id": target.pk}
         for action in request.data:
             try:
                 self.validate_action(action)
                 self.dispatch_action(patch, request, target, action)
-                detail.append('ok')
+                detail.append("ok")
             except Http404:
                 is_errored = True
-                detail.append('NOT FOUND')
+                detail.append("NOT FOUND")
                 break
             except (InvalidAction, PermissionDenied) as e:
                 is_errored = True
                 detail.append(e.args[0])
                 break
 
-        patch['detail'] = detail
+        patch["detail"] = detail
         if is_errored:
             return Response(patch, status=400)
         else:
@@ -74,21 +62,21 @@ class ApiPatch(object):
         for target in targets:
             detail = []
 
-            patch = {'id': target.pk}
-            for action in request.data['ops']:
+            patch = {"id": target.pk}
+            for action in request.data["ops"]:
                 try:
                     self.validate_action(action)
                     self.dispatch_action(patch, request, target, action)
                 except Http404:
                     is_errored = True
-                    detail.append('NOT FOUND')
+                    detail.append("NOT FOUND")
                     break
                 except (InvalidAction, PermissionDenied) as e:
                     is_errored = True
                     detail.append(e.args[0])
                     break
             if detail:
-                patch['detail'] = detail
+                patch["detail"] = detail
             result.append(patch)
 
         if is_errored:
@@ -97,20 +85,20 @@ class ApiPatch(object):
             return Response(result)
 
     def validate_action(self, action):
-        if not action.get('op'):
+        if not action.get("op"):
             raise InvalidAction("undefined op")
 
-        if action.get('op') not in ALLOWED_OPS:
-            raise InvalidAction('"%s" op is unsupported' % action.get('op'))
+        if action.get("op") not in ALLOWED_OPS:
+            raise InvalidAction('"%s" op is unsupported' % action.get("op"))
 
-        if not action.get('path'):
-            raise InvalidAction('"%s" op has to specify path' % action.get('op'))
+        if not action.get("path"):
+            raise InvalidAction('"%s" op has to specify path' % action.get("op"))
 
-        if 'value' not in action:
-            raise InvalidAction('"%s" op has to specify value' % action.get('op'))
+        if "value" not in action:
+            raise InvalidAction('"%s" op has to specify value' % action.get("op"))
 
     def dispatch_action(self, patch, request, target, action):
         for handler in self._actions:
-            if action['op'] == handler['op'] and action['path'] == handler['path']:
+            if action["op"] == handler["op"] and action["path"] == handler["path"]:
                 with transaction.atomic():
-                    patch.update(handler['handler'](request, target, action['value']))
+                    patch.update(handler["handler"](request, target, action["value"]))

+ 23 - 21
misago/core/apirouter.py

@@ -1,4 +1,9 @@
-from rest_framework.routers import DefaultRouter, DynamicDetailRoute, DynamicListRoute, Route
+from rest_framework.routers import (
+    DefaultRouter,
+    DynamicDetailRoute,
+    DynamicListRoute,
+    Route,
+)
 
 
 class MisagoApiRouter(DefaultRouter):
@@ -8,39 +13,36 @@ class MisagoApiRouter(DefaultRouter):
     routes = [
         # List route.
         Route(
-            url=r'^{prefix}{trailing_slash}$',
-            mapping={
-                'get': 'list',
-                'post': 'create',
-            },
-            name='{basename}-list',
-            initkwargs={'suffix': 'List'}
+            url=r"^{prefix}{trailing_slash}$",
+            mapping={"get": "list", "post": "create"},
+            name="{basename}-list",
+            initkwargs={"suffix": "List"},
         ),
         # Dynamically generated list routes.
         # Generated using @list_route decorator
         # on methods of the viewset.
         DynamicListRoute(
-            url=r'^{prefix}/{methodnamehyphen}{trailing_slash}$',
-            name='{basename}-{methodnamehyphen}',
-            initkwargs={}
+            url=r"^{prefix}/{methodnamehyphen}{trailing_slash}$",
+            name="{basename}-{methodnamehyphen}",
+            initkwargs={},
         ),
         # Detail route.
         Route(
-            url=r'^{prefix}/{lookup}{trailing_slash}$',
+            url=r"^{prefix}/{lookup}{trailing_slash}$",
             mapping={
-                'get': 'retrieve',
-                'put': 'update',
-                'patch': 'partial_update',
-                'delete': 'destroy',
+                "get": "retrieve",
+                "put": "update",
+                "patch": "partial_update",
+                "delete": "destroy",
             },
-            name='{basename}-detail',
-            initkwargs={'suffix': 'Instance'}
+            name="{basename}-detail",
+            initkwargs={"suffix": "Instance"},
         ),
         # Dynamically generated detail routes.
         # Generated using @detail_route decorator on methods of the viewset.
         DynamicDetailRoute(
-            url=r'^{prefix}/{lookup}/{methodnamehyphen}{trailing_slash}$',
-            name='{basename}-{methodnamehyphen}',
-            initkwargs={}
+            url=r"^{prefix}/{lookup}/{methodnamehyphen}{trailing_slash}$",
+            name="{basename}-{methodnamehyphen}",
+            initkwargs={},
         ),
     ]

+ 2 - 2
misago/core/apps.py

@@ -2,6 +2,6 @@ from django.apps import AppConfig
 
 
 class MisagoCoreConfig(AppConfig):
-    name = 'misago.core'
-    label = 'misago_core'
+    name = "misago.core"
+    label = "misago_core"
     verbose_name = "Misago Core"

+ 1 - 1
misago/core/cache.py

@@ -3,6 +3,6 @@ from django.core.cache import InvalidCacheBackendError, caches
 
 
 try:
-    cache = caches['misago']
+    cache = caches["misago"]
 except InvalidCacheBackendError:
     cache = default_cache

+ 14 - 18
misago/core/context_processors.py

@@ -5,48 +5,44 @@ from .momentjs import get_locale_url
 
 def site_address(request):
     if request.is_secure():
-        site_protocol = 'https'
-        address_template = 'https://%s'
+        site_protocol = "https"
+        address_template = "https://%s"
     else:
-        site_protocol = 'http'
-        address_template = 'http://%s'
+        site_protocol = "http"
+        address_template = "http://%s"
 
     host = request.get_host()
 
     return {
-        'SITE_PROTOCOL': site_protocol,
-        'SITE_HOST': host,
-        'SITE_ADDRESS': address_template % host,
-        'REQUEST_PATH': request.path,
+        "SITE_PROTOCOL": site_protocol,
+        "SITE_HOST": host,
+        "SITE_ADDRESS": address_template % host,
+        "REQUEST_PATH": request.path,
     }
 
 
 def current_link(request):
-    if not request.resolver_match or request.frontend_context.get('CURRENT_LINK'):
+    if not request.resolver_match or request.frontend_context.get("CURRENT_LINK"):
         return {}
 
     url_name = request.resolver_match.url_name
     if request.resolver_match.namespaces:
-        namespaces = ':'.join(request.resolver_match.namespaces)
-        link_name = '%s:%s' % (namespaces, url_name)
+        namespaces = ":".join(request.resolver_match.namespaces)
+        link_name = "%s:%s" % (namespaces, url_name)
     else:
         link_name = url_name
 
-    request.frontend_context.update({'CURRENT_LINK': link_name})
+    request.frontend_context.update({"CURRENT_LINK": link_name})
 
     return {}
 
 
 def momentjs_locale(request):
-    return {
-        'MOMENTJS_LOCALE_URL': get_locale_url(get_language()),
-    }
+    return {"MOMENTJS_LOCALE_URL": get_locale_url(get_language())}
 
 
 def frontend_context(request):
     if request.include_frontend_context:
-        return {
-            'frontend_context': request.frontend_context,
-        }
+        return {"frontend_context": request.frontend_context}
     else:
         return {}

+ 3 - 2
misago/core/decorators.py

@@ -15,7 +15,7 @@ def ajax_only(f):
 
 def require_POST(f):
     def decorator(request, *args, **kwargs):
-        if not request.method == 'POST':
+        if not request.method == "POST":
             return not_allowed(request)
         else:
             return f(request, *args, **kwargs)
@@ -25,9 +25,10 @@ def require_POST(f):
 
 def require_dict_data(f):
     def decorator(request, *args, **kwargs):
-        if request.method == 'POST':
+        if request.method == "POST":
             DummySerializer(data=request.data).is_valid(raise_exception=True)
         return f(request, *args, **kwargs)
+
     return decorator
 
 

+ 27 - 28
misago/core/errorpages.py

@@ -11,38 +11,32 @@ from .utils import get_exception_message, is_request_to_misago
 
 
 def _ajax_error(code, exception=None, default_message=None):
-    return JsonResponse({
-        'detail': get_exception_message(exception, default_message),
-    }, status=code)
+    return JsonResponse(
+        {"detail": get_exception_message(exception, default_message)}, status=code
+    )
 
 
 @admin_error_page
 def _error_page(request, code, exception=None, default_message=None):
-    request.frontend_context.update({
-        'CURRENT_LINK': 'misago:error-%s' % code,
-    })
+    request.frontend_context.update({"CURRENT_LINK": "misago:error-%s" % code})
 
     return render(
-        request, 'misago/errorpages/%s.html' % code, {
-            'message': get_exception_message(exception, default_message),
-        }, status=code
+        request,
+        "misago/errorpages/%s.html" % code,
+        {"message": get_exception_message(exception, default_message)},
+        status=code,
     )
 
 
 def banned(request, exception):
     ban = exception.ban
 
-    request.frontend_context.update({
-        'MESSAGE': ban.get_serialized_message(),
-        'CURRENT_LINK': 'misago:error-banned',
-    })
-
-    return render(
-        request, 'misago/errorpages/banned.html', {
-            'ban': ban,
-        }, status=403
+    request.frontend_context.update(
+        {"MESSAGE": ban.get_serialized_message(), "CURRENT_LINK": "misago:error-banned"}
     )
 
+    return render(request, "misago/errorpages/banned.html", {"ban": ban}, status=403)
+
 
 def permission_denied(request, exception):
     if request.is_ajax():
@@ -83,9 +77,9 @@ def social_auth_failed(request, exception):
             "because currently it's the only way to sign in to your account."
         )
     elif backend_name:
-        message = _("A problem was encountered when signing you in using %(backend)s.") % {
-            'backend': backend_name
-        }
+        message = _(
+            "A problem was encountered when signing you in using %(backend)s."
+        ) % {"backend": backend_name}
 
         if isinstance(exception, social_exceptions.AuthCanceled):
             help_text = _("The sign in process has been canceled by user.")
@@ -98,12 +92,17 @@ def social_auth_failed(request, exception):
     else:
         message = _("Unexpected problem has been encountered during sign in process.")
 
-    return render(request, 'misago/errorpages/social.html', {
-        'backend_name': backend_name,
-        'ban': ban,
-        'message': message,
-        'help_text': help_text,
-    }, status=403)
+    return render(
+        request,
+        "misago/errorpages/social.html",
+        {
+            "backend_name": backend_name,
+            "ban": ban,
+            "message": message,
+            "help_text": help_text,
+        },
+        status=403,
+    )
 
 
 @admin_csrf_failure
@@ -111,7 +110,7 @@ def csrf_failure(request, reason=""):
     if request.is_ajax():
         return _ajax_error(403, _("Request authentication is invalid."))
     else:
-        response = render(request, 'misago/errorpages/csrf_failure.html')
+        response = render(request, "misago/errorpages/csrf_failure.html")
         response.status_code = 403
         return response
 

+ 6 - 9
misago/core/exceptionhandler.py

@@ -26,10 +26,7 @@ def is_misago_exception(exception):
 
 
 def handle_ajax_error(request, exception):
-    json = {
-        'is_error': 1,
-        'message': str(exception.message),
-    }
+    json = {"is_error": 1, "message": str(exception.message)}
     return JsonResponse(json, status=exception.code)
 
 
@@ -40,10 +37,10 @@ def handle_banned_exception(request, exception):
 def handle_explicit_first_page_exception(request, exception):
     matched_url = request.resolver_match.url_name
     if request.resolver_match.namespace:
-        matched_url = '%s:%s' % (request.resolver_match.namespace, matched_url)
+        matched_url = "%s:%s" % (request.resolver_match.namespace, matched_url)
 
     url_kwargs = request.resolver_match.kwargs
-    del url_kwargs['page']
+    del url_kwargs["page"]
 
     new_url = reverse(matched_url, kwargs=url_kwargs)
     return HttpResponsePermanentRedirect(new_url)
@@ -58,7 +55,7 @@ def handle_outdated_slug_exception(request, exception):
 
     model = exception.args[0]
     url_kwargs = request.resolver_match.kwargs
-    url_kwargs['slug'] = model.slug
+    url_kwargs["slug"] = model.slug
 
     new_url = reverse(view_name, kwargs=url_kwargs)
     return HttpResponsePermanentRedirect(new_url)
@@ -101,10 +98,10 @@ def handle_api_exception(exception, context):
     response = rest_exception_handler(exception, context)
     if response:
         if isinstance(exception, Banned):
-            response.data['ban'] = exception.ban.get_serialized_message()
+            response.data["ban"] = exception.ban.get_serialized_message()
         elif isinstance(exception, PermissionDenied):
             try:
-                response.data['detail'] = exception.args[0]
+                response.data["detail"] = exception.args[0]
             except IndexError:
                 pass
         return response

+ 4 - 0
misago/core/exceptions.py

@@ -18,6 +18,7 @@ class Banned(PermissionDenied):
 
 class SocialAuthFailed(AuthException):
     """Exception used to return error messages from Misago's social auth to user."""
+
     def __init__(self, backend, message):
         self.backend = backend
         self.message = message
@@ -25,6 +26,7 @@ class SocialAuthFailed(AuthException):
 
 class SocialAuthBanned(AuthException):
     """Exception used to return ban message from Misago's social auth to user."""
+
     def __init__(self, backend, ban):
         self.backend = backend
         self.ban = ban
@@ -32,9 +34,11 @@ class SocialAuthBanned(AuthException):
 
 class ExplicitFirstPage(Exception):
     """The url that was used to reach view contained explicit first page"""
+
     pass
 
 
 class OutdatedSlug(Exception):
     """The url that was used to reach view contained outdated slug"""
+
     pass

+ 16 - 13
misago/core/mail.py

@@ -9,24 +9,27 @@ from .utils import get_host_from_address
 
 def build_mail(recipient, subject, template, sender=None, context=None):
     context = context.copy() if context else {}
-    context.update({
-        'SITE_ADDRESS': settings.MISAGO_ADDRESS,
-        'SITE_HOST': get_host_from_address(settings.MISAGO_ADDRESS),
-        'LANGUAGE_CODE': get_language()[:2],
-        'LOGIN_URL': settings.LOGIN_URL,
-
-        'user': recipient,
-        'sender': sender,
-        'subject': subject,
-    })
+    context.update(
+        {
+            "SITE_ADDRESS": settings.MISAGO_ADDRESS,
+            "SITE_HOST": get_host_from_address(settings.MISAGO_ADDRESS),
+            "LANGUAGE_CODE": get_language()[:2],
+            "LOGIN_URL": settings.LOGIN_URL,
+            "user": recipient,
+            "sender": sender,
+            "subject": subject,
+        }
+    )
 
     if not context.get("settings"):
         raise ValueError("settings key is missing from context")
 
-    message_plain = render_to_string('%s.txt' % template, context)
-    message_html = render_to_string('%s.html' % template, context)
+    message_plain = render_to_string("%s.txt" % template, context)
+    message_html = render_to_string("%s.html" % template, context)
 
-    message = djmail.EmailMultiAlternatives(subject, message_plain, to=[recipient.email])
+    message = djmail.EmailMultiAlternatives(
+        subject, message_plain, to=[recipient.email]
+    )
     message.attach_alternative(message_html, "text/html")
 
     return message

+ 7 - 7
misago/core/management/progressbar.py

@@ -6,27 +6,27 @@ def show_progress(command, step, total, since=None):
     filled = progress // 2
     blank = 50 - filled
 
-    template = '\r%(step)s (%(progress)s%%) [%(progressbar)s]%(estimation)s'
+    template = "\r%(step)s (%(progress)s%%) [%(progressbar)s]%(estimation)s"
     variables = {
         "step": str(step).rjust(len(str(total))),
         "progress": str(progress).rjust(3),
-        "progressbar": "".join(['=' * filled, ' ' * blank]),
+        "progressbar": "".join(["=" * filled, " " * blank]),
         "estimation": get_estimation_str(since, progress, step, total),
     }
 
-    command.stdout.write(template % variables, ending='')
+    command.stdout.write(template % variables, ending="")
     command.stdout.flush()
 
 
 def get_estimation_str(since, progress, step, total):
     if not since:
         return ""
-    
+
     progress_float = float(step) * 100.0 / float(total)
     if progress_float == 0:
-        return ' --:--:-- est.'
+        return " --:--:-- est."
 
     step_time = (time.time() - since) / progress_float
     estimated_time = (100 - progress) * step_time
-    clock = time.strftime('%H:%M:%S', time.gmtime(estimated_time))
-    return ' %s est.' % clock
+    clock = time.strftime("%H:%M:%S", time.gmtime(estimated_time))
+    return " %s est." % clock

+ 12 - 8
misago/core/migrations/0001_initial.py

@@ -9,17 +9,21 @@ class Migration(migrations.Migration):
 
     operations = [
         migrations.CreateModel(
-            name='CacheVersion',
+            name="CacheVersion",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
                 ),
-                ('cache', models.CharField(max_length=128)),
-                ('version', models.PositiveIntegerField(default=0)),
+                ("cache", models.CharField(max_length=128)),
+                ("version", models.PositiveIntegerField(default=0)),
             ],
             options={},
-            bases=(models.Model, ),
-        ),
+            bases=(models.Model,),
+        )
     ]

+ 52 - 59
misago/core/migrations/0002_basic_settings.py

@@ -8,84 +8,77 @@ _ = lambda s: s
 
 def create_basic_settings_group(apps, schema_editor):
     migrate_settings_group(
-        apps, {
-            'key': 'basic',
-            'name': _("Basic forum settings"),
-            'description': _(
+        apps,
+        {
+            "key": "basic",
+            "name": _("Basic forum settings"),
+            "description": _(
                 "Those settings control most basic properties "
                 "of your forum like its name or description."
             ),
-            'settings': [
+            "settings": [
                 {
-                    'setting': 'forum_name',
-                    'name': _("Forum name"),
-                    'legend': _("General"),
-                    'value': "Misago",
-                    'field_extra': {
-                        'min_length': 2,
-                        'max_length': 255
-                    },
-                    'is_public': True,
+                    "setting": "forum_name",
+                    "name": _("Forum name"),
+                    "legend": _("General"),
+                    "value": "Misago",
+                    "field_extra": {"min_length": 2, "max_length": 255},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'forum_index_title',
-                    'name': _("Index title"),
-                    'description': _("You may set custon title on forum index by typing it here."),
-                    'legend': _("Forum index"),
-                    'field_extra': {
-                        'max_length': 255
-                    },
-                    'is_public': True,
+                    "setting": "forum_index_title",
+                    "name": _("Index title"),
+                    "description": _(
+                        "You may set custon title on forum index by typing it here."
+                    ),
+                    "legend": _("Forum index"),
+                    "field_extra": {"max_length": 255},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'forum_index_meta_description',
-                    'name': _("Meta Description"),
-                    'description': _("Short description of your forum for internet crawlers."),
-                    'field_extra': {
-                        'max_length': 255
-                    },
+                    "setting": "forum_index_meta_description",
+                    "name": _("Meta Description"),
+                    "description": _(
+                        "Short description of your forum for internet crawlers."
+                    ),
+                    "field_extra": {"max_length": 255},
                 },
                 {
-                    'setting': 'forum_branding_display',
-                    'name': _("Display branding"),
-                    'description': _("Switch branding in forum's navbar."),
-                    'legend': _("Branding"),
-                    'value': True,
-                    'python_type': 'bool',
-                    'form_field': 'yesno',
-                    'is_public': True,
+                    "setting": "forum_branding_display",
+                    "name": _("Display branding"),
+                    "description": _("Switch branding in forum's navbar."),
+                    "legend": _("Branding"),
+                    "value": True,
+                    "python_type": "bool",
+                    "form_field": "yesno",
+                    "is_public": True,
                 },
                 {
-                    'setting': 'forum_branding_text',
-                    'name': _("Branding text"),
-                    'description': _("Optional text displayed besides brand image in navbar."),
-                    'value': "Misago",
-                    'field_extra': {
-                        'max_length': 255
-                    },
-                    'is_public': True,
+                    "setting": "forum_branding_text",
+                    "name": _("Branding text"),
+                    "description": _(
+                        "Optional text displayed besides brand image in navbar."
+                    ),
+                    "value": "Misago",
+                    "field_extra": {"max_length": 255},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'email_footer',
-                    'name': _("E-mails footer"),
-                    'description': _("Optional short message included at the end of e-mails sent by forum."),
-                    'legend': _("Forum e-mails"),
-                    'field_extra': {
-                        'max_length': 255
-                    },
+                    "setting": "email_footer",
+                    "name": _("E-mails footer"),
+                    "description": _(
+                        "Optional short message included at the end of e-mails sent by forum."
+                    ),
+                    "legend": _("Forum e-mails"),
+                    "field_extra": {"max_length": 255},
                 },
             ],
-        }
+        },
     )
 
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_core', '0001_initial'),
-        ('misago_conf', '0001_initial'),
-    ]
+    dependencies = [("misago_core", "0001_initial"), ("misago_conf", "0001_initial")]
 
-    operations = [
-        migrations.RunPython(create_basic_settings_group),
-    ]
+    operations = [migrations.RunPython(create_basic_settings_group)]

+ 3 - 3
misago/core/momentjs.py

@@ -1,7 +1,7 @@
 from misago.conf import settings
 
 
-MOMENT_STATIC_PATH = 'misago/momentjs/%s.js'
+MOMENT_STATIC_PATH = "misago/momentjs/%s.js"
 
 
 def get_locale_url(language):
@@ -14,14 +14,14 @@ def get_locale_url(language):
 
 def clean_language_name(language):
     # lowercase language
-    language = language.lower().replace('_', '-')
+    language = language.lower().replace("_", "-")
 
     # first try: literal match
     if language in settings.MISAGO_MOMENT_JS_LOCALES:
         return language
 
     # second try: fallback to macrolanguage
-    language = language.split('-')[0]
+    language = language.split("-")[0]
     if language in settings.MISAGO_MOMENT_JS_LOCALES:
         return language
 

+ 37 - 25
misago/core/page.py

@@ -22,16 +22,18 @@ class Page(object):
         while self._unsorted_list:
             iterations += 1
             if iterations > 512:
-                message = (
-                    "%s page hierarchy is invalid or too complex  to resolve. Sections left: %s"
-                )
+                message = "%s page hierarchy is invalid or too complex  to resolve. Sections left: %s"
                 raise ValueError(message % self._unsorted_list)
 
             for index, section in enumerate(self._unsorted_list):
-                if section['after']:
-                    section_added = self._insert_section(section, after=section['after'])
-                elif section['before']:
-                    section_added = self._insert_section(section, before=section['before'])
+                if section["after"]:
+                    section_added = self._insert_section(
+                        section, after=section["after"]
+                    )
+                elif section["before"]:
+                    section_added = self._insert_section(
+                        section, before=section["before"]
+                    )
                 else:
                     section_added = self._insert_section(section)
 
@@ -44,7 +46,7 @@ class Page(object):
             new_sorted_list = []
             for section in self._sorted_list:
                 new_sorted_list.append(section)
-                if section['link'] == after:
+                if section["link"] == after:
                     new_sorted_list.append(inserted_section)
                     self._sorted_list = new_sorted_list
                     return True
@@ -53,7 +55,7 @@ class Page(object):
         elif before:
             new_sorted_list = []
             for section in self._sorted_list:
-                if section['link'] == before:
+                if section["link"] == before:
                     new_sorted_list.append(inserted_section)
                     new_sorted_list.append(section)
                     self._sorted_list = new_sorted_list
@@ -67,22 +69,32 @@ class Page(object):
             return True
 
     def add_section(
-            self, link, after=None, before=None, visible_if=None, get_metadata=None, **kwargs
+        self,
+        link,
+        after=None,
+        before=None,
+        visible_if=None,
+        get_metadata=None,
+        **kwargs
     ):
         if self._finalized:
-            message = "%s page was initialized already and no longer accepts new sections"
+            message = (
+                "%s page was initialized already and no longer accepts new sections"
+            )
             raise RuntimeError(message % self.name)
 
         if after and before:
             raise ValueError("after and before arguments are exclusive")
 
-        kwargs.update({
-            'link': link,
-            'after': after,
-            'before': before,
-            'visible_if': visible_if,
-            'get_metadata': get_metadata,
-        })
+        kwargs.update(
+            {
+                "link": link,
+                "after": after,
+                "before": before,
+                "visible_if": visible_if,
+                "get_metadata": get_metadata,
+            }
+        )
 
         self._unsorted_list.append(kwargs)
 
@@ -91,7 +103,7 @@ class Page(object):
         url_name = request.resolver_match.url_name
 
         if namespace:
-            active_link = '%s:%s' % (namespace, url_name)
+            active_link = "%s:%s" % (namespace, url_name)
         else:
             active_link = url_name
         return active_link
@@ -105,16 +117,16 @@ class Page(object):
             section = section_definition.copy()
 
             is_visible = True
-            if section['visible_if']:
-                is_visible = section['visible_if'](request, *args)
+            if section["visible_if"]:
+                is_visible = section["visible_if"](request, *args)
 
             if is_visible:
-                if section['get_metadata']:
-                    section['metadata'] = section['get_metadata'](request, *args)
-                section['is_active'] = active_link.startswith(section['link'])
+                if section["get_metadata"]:
+                    section["metadata"] = section["get_metadata"](request, *args)
+                section["is_active"] = active_link.startswith(section["link"])
                 visible_sections.append(section)
         return visible_sections
 
     def get_default_link(self):
         self.assert_is_finalized()
-        return self._sorted_list[0]['link']
+        return self._sorted_list[0]["link"]

+ 29 - 31
misago/core/pgutils.py

@@ -3,12 +3,12 @@ from django.db.models import Index
 
 
 class PgPartialIndex(Index):
-    suffix = 'part'
+    suffix = "part"
     max_name_length = 31
 
     def __init__(self, fields=[], name=None, where=None):
         if not where:
-            raise ValueError('partial index requires WHERE clause')
+            raise ValueError("partial index requires WHERE clause")
         self.where = where
 
         super().__init__(fields, name)
@@ -19,20 +19,20 @@ class PgPartialIndex(Index):
         column_names = sorted(self.where.keys())
         where_items = []
         for key in sorted(self.where.keys()):
-            where_items.append('%s:%s' % (key, repr(self.where[key])))
+            where_items.append("%s:%s" % (key, repr(self.where[key])))
 
         # The length of the parts of the name is based on the default max
         # length of 30 characters.
         hash_data = [table_name] + self.fields + where_items + [self.suffix]
-        self.name = '%s_%s_%s' % (
+        self.name = "%s_%s_%s" % (
             table_name[:11],
             column_names[0][:7],
-            '%s_%s' % (self._hash_generator(*hash_data), self.suffix),
+            "%s_%s" % (self._hash_generator(*hash_data), self.suffix),
         )
 
         assert len(self.name) <= self.max_name_length, (
-            'Index too long for multiple database support. Is self.suffix '
-            'longer than 3 characters?'
+            "Index too long for multiple database support. Is self.suffix "
+            "longer than 3 characters?"
         )
         self.check_name()
 
@@ -40,27 +40,23 @@ class PgPartialIndex(Index):
         if self.where is not None:
             where_items = []
             for key in sorted(self.where.keys()):
-                where_items.append('='.join([
-                    key,
-                    repr(self.where[key])
-                ]))
-            return '<%(name)s: fields=%(fields)s, where=%(where)s>' % {
-                'name': self.__class__.__name__,
-                'fields': "'%s'" % (', '.join(self.fields)),
-                'where': "'%s'" % (', '.join(where_items)),
+                where_items.append("=".join([key, repr(self.where[key])]))
+            return "<%(name)s: fields=%(fields)s, where=%(where)s>" % {
+                "name": self.__class__.__name__,
+                "fields": "'%s'" % (", ".join(self.fields)),
+                "where": "'%s'" % (", ".join(where_items)),
             }
         else:
             return super().__repr__()
 
     def deconstruct(self):
         path, args, kwargs = super().deconstruct()
-        kwargs['where'] = self.where
+        kwargs["where"] = self.where
         return path, args, kwargs
 
     def get_sql_create_template_values(self, model, schema_editor, using):
-        parameters = super().get_sql_create_template_values(
-            model, schema_editor, '')
-        parameters['extra'] = self.get_sql_extra(model, schema_editor)
+        parameters = super().get_sql_create_template_values(model, schema_editor, "")
+        parameters["extra"] = self.get_sql_extra(model, schema_editor)
         return parameters
 
     def get_sql_extra(self, model, schema_editor):
@@ -71,30 +67,32 @@ class PgPartialIndex(Index):
         for field, condition in self.where.items():
             field_name = None
             compr = None
-            if field.endswith('__lt'):
+            if field.endswith("__lt"):
                 field_name = field[:-4]
-                compr = '<'
-            elif field.endswith('__gt'):
+                compr = "<"
+            elif field.endswith("__gt"):
                 field_name = field[:-4]
-                compr = '>'
-            elif field.endswith('__lte'):
+                compr = ">"
+            elif field.endswith("__lte"):
                 field_name = field[:-5]
-                compr = '<='
-            elif field.endswith('__gte'):
+                compr = "<="
+            elif field.endswith("__gte"):
                 field_name = field[:-5]
-                compr = '>='
+                compr = ">="
             else:
                 field_name = field
-                compr = '='
+                compr = "="
 
             column = model._meta.get_field(field_name).column
-            clauses.append('%s %s %s' % (quote_name(column), compr, quote_value(condition)))
+            clauses.append(
+                "%s %s %s" % (quote_name(column), compr, quote_value(condition))
+            )
         # sort clauses for their order to be determined and testable
-        return ' WHERE %s' % (' AND '.join(sorted(clauses)))
+        return " WHERE %s" % (" AND ".join(sorted(clauses)))
 
 
 def chunk_queryset(queryset, chunk_size=20):
-    ordered_queryset = queryset.order_by('-pk') # bias to newest items first
+    ordered_queryset = queryset.order_by("-pk")  # bias to newest items first
     chunk = ordered_queryset[:chunk_size]
     while chunk:
         last_pk = None

+ 9 - 9
misago/core/serializers.py

@@ -1,15 +1,15 @@
 class MutableFields(object):
     @classmethod
     def subset_fields(cls, *fields):
-        fields_in_name = [f.title().replace('_', '') for f in fields]
-        name = '%s%sSubset' % (cls.__name__, ''.join(fields_in_name)[:100])
+        fields_in_name = [f.title().replace("_", "") for f in fields]
+        name = "%s%sSubset" % (cls.__name__, "".join(fields_in_name)[:100])
 
         class Meta(cls.Meta):
             pass
 
         Meta.fields = list(fields)
 
-        return type(name, (cls, ), {'Meta': Meta})
+        return type(name, (cls,), {"Meta": Meta})
 
     @classmethod
     def exclude_fields(cls, *fields):
@@ -18,15 +18,15 @@ class MutableFields(object):
             if field not in fields:
                 final_fields.append(field)
 
-        fields_in_name = [f.title().replace('_', '') for f in final_fields]
-        name = '%s%sSubset' % (cls.__name__, ''.join(fields_in_name)[:100])
+        fields_in_name = [f.title().replace("_", "") for f in final_fields]
+        name = "%s%sSubset" % (cls.__name__, "".join(fields_in_name)[:100])
 
         class Meta(cls.Meta):
             pass
 
         Meta.fields = list(final_fields)
 
-        return type(name, (cls, ), {'Meta': Meta})
+        return type(name, (cls,), {"Meta": Meta})
 
     @classmethod
     def extend_fields(cls, *fields):
@@ -35,12 +35,12 @@ class MutableFields(object):
             if field not in final_fields:
                 final_fields.append(field)
 
-        fields_in_name = [f.title().replace('_', '') for f in final_fields]
-        name = '%s%sSubset' % (cls.__name__, ''.join(fields_in_name)[:100])
+        fields_in_name = [f.title().replace("_", "") for f in final_fields]
+        name = "%s%sSubset" % (cls.__name__, "".join(fields_in_name)[:100])
 
         class Meta(cls.Meta):
             pass
 
         Meta.fields = list(final_fields)
 
-        return type(name, (cls, ), {'Meta': Meta})
+        return type(name, (cls,), {"Meta": Meta})

+ 14 - 8
misago/core/setup.py

@@ -22,18 +22,21 @@ def validate_project_name(parser, project_name):
     except ImportError:
         pass
     else:
-        parser.error((
-            "'%s' conflicts with the name of an existing "
-            "Python module and cannot be used as a project "
-            "name. Please try another name."
-        ) % project_name)
+        parser.error(
+            (
+                "'%s' conflicts with the name of an existing "
+                "Python module and cannot be used as a project "
+                "name. Please try another name."
+            )
+            % project_name
+        )
 
     return project_name
 
 
 def get_misago_project_template():
     misago_path = os.path.dirname(os.path.dirname(__file__))
-    return os.path.join(misago_path, 'project_template')
+    return os.path.join(misago_path, "project_template")
 
 
 def start_misago_project():
@@ -50,8 +53,11 @@ def start_misago_project():
     project_name = validate_project_name(parser, args[0])
 
     argv = [
-        'misago-start.py', 'startproject', project_name, directory,
-        '--template=%s' % get_misago_project_template()
+        "misago-start.py",
+        "startproject",
+        project_name,
+        directory,
+        "--template=%s" % get_misago_project_template(),
     ]
 
     management.execute_from_command_line(argv)

+ 28 - 24
misago/core/shortcuts.py

@@ -4,13 +4,13 @@ from django.http import Http404
 
 
 def paginate(
-        object_list,
-        page,
-        per_page,
-        orphans=0,
-        allow_empty_first_page=True,
-        allow_explicit_first_page=False,
-        paginator=None
+    object_list,
+    page,
+    per_page,
+    orphans=0,
+    allow_empty_first_page=True,
+    allow_explicit_first_page=False,
+    paginator=None,
 ):
     from django.core.paginator import Paginator, EmptyPage, InvalidPage
     from .exceptions import ExplicitFirstPage
@@ -24,7 +24,10 @@ def paginate(
 
     try:
         return paginator(
-            object_list, per_page, orphans=orphans, allow_empty_first_page=allow_empty_first_page
+            object_list,
+            per_page,
+            orphans=orphans,
+            allow_empty_first_page=allow_empty_first_page,
         ).page(page)
     except (EmptyPage, InvalidPage):
         raise Http404()
@@ -32,28 +35,28 @@ def paginate(
 
 def pagination_dict(page):
     pagination = {
-        'page': page.number,
-        'pages': page.paginator.num_pages,
-        'count': page.paginator.count,
-        'first': None,
-        'previous': None,
-        'next': None,
-        'last': None,
-        'before': 0,
-        'more': 0,
+        "page": page.number,
+        "pages": page.paginator.num_pages,
+        "count": page.paginator.count,
+        "first": None,
+        "previous": None,
+        "next": None,
+        "last": None,
+        "before": 0,
+        "more": 0,
     }
 
     if page.has_previous():
-        pagination['first'] = 1
-        pagination['previous'] = page.previous_page_number()
+        pagination["first"] = 1
+        pagination["previous"] = page.previous_page_number()
 
     if page.has_next():
-        pagination['last'] = page.paginator.num_pages
-        pagination['next'] = page.next_page_number()
+        pagination["last"] = page.paginator.num_pages
+        pagination["next"] = page.next_page_number()
 
     if page.start_index():
-        pagination['before'] = page.start_index() - 1
-    pagination['more'] = page.paginator.count - page.end_index()
+        pagination["before"] = page.start_index() - 1
+    pagination["more"] = page.paginator.count - page.end_index()
 
     return pagination
 
@@ -65,7 +68,7 @@ def paginated_response(page, serializer=None, data=None, extra=None):
     if serializer:
         results = serializer(results, many=True).data
 
-    response_data.update({'results': results})
+    response_data.update({"results": results})
 
     if extra:
         response_data.update(extra)
@@ -75,6 +78,7 @@ def paginated_response(page, serializer=None, data=None, extra=None):
 
 def validate_slug(model, slug):
     from .exceptions import OutdatedSlug
+
     if model.slug != slug:
         raise OutdatedSlug(model)
 

+ 1 - 1
misago/core/slugify.py

@@ -6,4 +6,4 @@ from django.template.defaultfilters import slugify as django_slugify
 def default(string):
     string = str(string)
     string = unidecode(string)
-    return django_slugify(string.replace('_', ' ').strip())
+    return django_slugify(string.replace("_", " ").strip())

+ 4 - 4
misago/core/templatetags/misago_absoluteurl.py

@@ -11,13 +11,13 @@ def absoluteurl(url_or_name, *args, **kwargs):
     if not settings.MISAGO_ADDRESS:
         return None
 
-    absolute_url_prefix = settings.MISAGO_ADDRESS.rstrip('/')
+    absolute_url_prefix = settings.MISAGO_ADDRESS.rstrip("/")
 
     try:
         url_or_name = reverse(url_or_name, args=args, kwargs=kwargs)
     except NoReverseMatch:
         # don't use URLValidator because its too explicit
-        if not url_or_name.startswith('/'):
+        if not url_or_name.startswith("/"):
             return url_or_name
-    
-    return '%s%s' % (absolute_url_prefix, url_or_name)
+
+    return "%s%s" % (absolute_url_prefix, url_or_name)

+ 4 - 4
misago/core/templatetags/misago_capture.py

@@ -19,19 +19,19 @@ def capture(parser, token):
     split_contents = token.split_contents()
 
     if len(split_contents) == 4:
-        if split_contents[1] != 'trimmed' or split_contents[2].lower() != 'as':
+        if split_contents[1] != "trimmed" or split_contents[2].lower() != "as":
             raise template.TemplateSyntaxError(SYNTAX_ERROR)
         is_trimmed = True
         variable = split_contents[3]
     elif len(split_contents) == 3:
-        if split_contents[1].lower() != 'as':
+        if split_contents[1].lower() != "as":
             raise template.TemplateSyntaxError(SYNTAX_ERROR)
         is_trimmed = False
         variable = split_contents[2]
     else:
         raise template.TemplateSyntaxError(SYNTAX_ERROR)
 
-    nodelist = parser.parse(('endcapture', ))
+    nodelist = parser.parse(("endcapture",))
     parser.delete_first_token()
     return CaptureNode(variable, nodelist, trim=is_trimmed)
 
@@ -47,4 +47,4 @@ class CaptureNode(template.Node):
         if self.is_trimmed:
             captured_output = captured_output.strip()
         context[self.variable] = captured_output
-        return ''
+        return ""

+ 4 - 4
misago/core/templatetags/misago_pagetitle.py

@@ -7,10 +7,10 @@ register = template.Library()
 
 @register.simple_tag
 def pagetitle(title, **kwargs):
-    if 'page' in kwargs and kwargs['page'] > 1:
-        title += " (%s)" % (_("page: %(page)s") % {'page': kwargs['page']})
+    if "page" in kwargs and kwargs["page"] > 1:
+        title += " (%s)" % (_("page: %(page)s") % {"page": kwargs["page"]})
 
-    if 'parent' in kwargs:
-        title += " | %s" % kwargs['parent']
+    if "parent" in kwargs:
+        title += " | %s" % kwargs["parent"]
 
     return title

+ 1 - 1
misago/core/templatetags/misago_stringutils.py

@@ -7,4 +7,4 @@ register = template.Library()
 @register.filter
 def isdescriptionshort(string):
     string_lowered = string.lower()
-    return string_lowered.count('<p') == 1 and not string_lowered.count('<br')
+    return string_lowered.count("<p") == 1 and not string_lowered.count("<br")

+ 1 - 1
misago/core/testproject/searchfilters.py

@@ -1,2 +1,2 @@
 def test_filter(search):
-    return search.replace('MMM', 'Marines, Marauders and Medics')
+    return search.replace("MMM", "Marines, Marauders and Medics")

+ 1 - 3
misago/core/testproject/serializers.py

@@ -5,9 +5,7 @@ class MockSerializer(serializers.Serializer):
     id = serializers.SerializerMethodField()
 
     class Meta:
-        fields = [
-            'id',
-        ]
+        fields = ["id"]
 
     def get_id(self, obj):
         return obj * 2

+ 57 - 38
misago/core/testproject/urls.py

@@ -1,4 +1,5 @@
 from django.conf.urls import include, url
+
 # Setup Django admin to work with Misago auth
 from django.contrib import admin
 from django.utils import timezone
@@ -15,67 +16,85 @@ admin.autodiscover()
 admin.site.login_form = AdminAuthenticationForm
 
 urlpatterns = [
-    url(r'^', include('social_django.urls', namespace='social')),
-    url(r'^forum/', include('misago.urls', namespace='misago')),
-    url(r'^django-admin/', admin.site.urls),
+    url(r"^", include("social_django.urls", namespace="social")),
+    url(r"^forum/", include("misago.urls", namespace="misago")),
+    url(r"^django-admin/", admin.site.urls),
     url(
-        r'^django-i18n.js$',
-        cache_page(86400 * 2, key_prefix='misagojsi18n')(
+        r"^django-i18n.js$",
+        cache_page(86400 * 2, key_prefix="misagojsi18n")(
             last_modified(lambda req, **kw: timezone.now())(
-                JavaScriptCatalog.as_view(
-                    packages=['misago'],
-                ),
-            ),
+                JavaScriptCatalog.as_view(packages=["misago"])
+            )
         ),
-        name='django-i18n'
+        name="django-i18n",
     ),
-    url(r'^forum/test-pagination/$', views.test_pagination, name='test-pagination'),
+    url(r"^forum/test-pagination/$", views.test_pagination, name="test-pagination"),
     url(
-        r'^forum/test-pagination/(?P<page>[1-9][0-9]*)/$',
+        r"^forum/test-pagination/(?P<page>[1-9][0-9]*)/$",
         views.test_pagination,
-        name='test-pagination'
+        name="test-pagination",
     ),
     url(
-        r'^forum/test-paginated-response/$',
+        r"^forum/test-paginated-response/$",
         views.test_paginated_response,
-        name='test-paginated-response'
+        name="test-paginated-response",
     ),
     url(
-        r'^forum/test-paginated-response-data/$',
+        r"^forum/test-paginated-response-data/$",
         views.test_paginated_response_data,
-        name='test-paginated-response-data'
+        name="test-paginated-response-data",
     ),
     url(
-        r'^forum/test-paginated-response-serializer/$',
+        r"^forum/test-paginated-response-serializer/$",
         views.test_paginated_response_serializer,
-        name='test-paginated-response-serializer'
+        name="test-paginated-response-serializer",
     ),
     url(
-        r'^forum/test-paginated-response-data-serializer/$',
+        r"^forum/test-paginated-response-data-serializer/$",
         views.test_paginated_response_data_serializer,
-        name='test-paginated-response-data-serializer'
+        name="test-paginated-response-data-serializer",
     ),
     url(
-        r'^forum/test-paginated-response-data-extra/$',
+        r"^forum/test-paginated-response-data-extra/$",
         views.test_paginated_response_data_extra,
-        name='test-paginated-response-data-extra'
+        name="test-paginated-response-data-extra",
     ),
     url(
-        r'^forum/test-valid-slug/(?P<slug>[a-z0-9\-]+)-(?P<pk>\d+)/$',
+        r"^forum/test-valid-slug/(?P<slug>[a-z0-9\-]+)-(?P<pk>\d+)/$",
         views.validate_slug_view,
-        name='validate-slug-view'
+        name="validate-slug-view",
+    ),
+    url(r"^forum/test-banned/$", views.raise_misago_banned, name="raise-misago-banned"),
+    url(r"^forum/test-403/$", views.raise_misago_403, name="raise-misago-403"),
+    url(r"^forum/test-404/$", views.raise_misago_404, name="raise-misago-404"),
+    url(r"^forum/test-405/$", views.raise_misago_405, name="raise-misago-405"),
+    url(
+        r"^forum/social-auth-failed/$",
+        views.raise_social_auth_failed,
+        name="raise-social-auth-failed",
+    ),
+    url(
+        r"^forum/social-wrong-backend/$",
+        views.raise_social_wrong_backend,
+        name="raise-social-wrong-backend",
+    ),
+    url(
+        r"^forum/social-not-allowed-to-disconnect/$",
+        views.raise_social_not_allowed_to_disconnect,
+        name="raise-social-not-allowed-to-disconnect",
+    ),
+    url(
+        r"^forum/raise-social-auth-failed-message/$",
+        views.raise_social_auth_failed_message,
+        name="raise-social-auth-failed-message",
+    ),
+    url(
+        r"^forum/raise-social-auth-banned/$",
+        views.raise_social_auth_banned,
+        name="raise-social-auth-banned",
     ),
-    url(r'^forum/test-banned/$', views.raise_misago_banned, name='raise-misago-banned'),
-    url(r'^forum/test-403/$', views.raise_misago_403, name='raise-misago-403'),
-    url(r'^forum/test-404/$', views.raise_misago_404, name='raise-misago-404'),
-    url(r'^forum/test-405/$', views.raise_misago_405, name='raise-misago-405'),
-    url(r'^forum/social-auth-failed/$', views.raise_social_auth_failed, name='raise-social-auth-failed'),
-    url(r'^forum/social-wrong-backend/$', views.raise_social_wrong_backend, name='raise-social-wrong-backend'),
-    url(r'^forum/social-not-allowed-to-disconnect/$', views.raise_social_not_allowed_to_disconnect, name='raise-social-not-allowed-to-disconnect'),
-    url(r'^forum/raise-social-auth-failed-message/$', views.raise_social_auth_failed_message, name='raise-social-auth-failed-message'),
-    url(r'^forum/raise-social-auth-banned/$', views.raise_social_auth_banned, name='raise-social-auth-banned'),
-    url(r'^test-403/$', views.raise_403, name='raise-403'),
-    url(r'^test-404/$', views.raise_404, name='raise-404'),
-    url(r'^test-redirect/$', views.test_redirect, name='test-redirect'),
-    url(r'^test-require-post/$', views.test_require_post, name='test-require-post'),
+    url(r"^test-403/$", views.raise_403, name="raise-403"),
+    url(r"^test-404/$", views.raise_404, name="raise-404"),
+    url(r"^test-redirect/$", views.test_redirect, name="test-redirect"),
+    url(r"^test-require-post/$", views.test_require_post, name="test-require-post"),
 ]

+ 2 - 2
misago/core/testproject/urlswitherrorhandlers.py

@@ -1,5 +1,5 @@
 from .urls import *
 
 
-handler403 = 'misago.core.testproject.views.mock_custom_403_error_page'
-handler404 = 'misago.core.testproject.views.mock_custom_404_error_page'
+handler403 = "misago.core.testproject.views.mock_custom_403_error_page"
+handler404 = "misago.core.testproject.views.mock_custom_404_error_page"

+ 2 - 2
misago/core/testproject/validators.py

@@ -2,8 +2,8 @@ from rest_framework import serializers
 
 
 def test_post_validator(context, data):
-    title_match = 'casino' in data.get('title', '').lower()
-    post_match = 'casino' in data.get('post', '').lower()
+    title_match = "casino" in data.get("title", "").lower()
+    post_match = "casino" in data.get("post", "").lower()
 
     if title_match or post_match:
         raise serializers.ValidationError("Don't discuss gambling!")

+ 7 - 14
misago/core/testproject/views.py

@@ -35,7 +35,7 @@ def test_paginated_response_data(request):
     data = range(100)
     page = paginate(data, 2, 10)
 
-    return paginated_response(page, data=['a', 'b', 'c', 'd', 'e'])
+    return paginated_response(page, data=["a", "b", "c", "d", "e"])
 
 
 @api_view()
@@ -52,9 +52,7 @@ def test_paginated_response_data_serializer(request):
     page = paginate(data, 0, 10)
 
     return paginated_response(
-        page,
-        data=['a', 'b', 'c', 'd'],
-        serializer=MockSerializer,
+        page, data=["a", "b", "c", "d"], serializer=MockSerializer
     )
 
 
@@ -64,17 +62,12 @@ def test_paginated_response_data_extra(request):
     page = paginate(data, 0, 10)
 
     return paginated_response(
-        page,
-        data=['a', 'b', 'c', 'd'],
-        extra={
-            'next': 'EXTRA',
-            'lorem': 'ipsum',
-        },
+        page, data=["a", "b", "c", "d"], extra={"next": "EXTRA", "lorem": "ipsum"}
     )
 
 
 def validate_slug_view(request, pk, slug):
-    model = Model(int(pk), 'eric-the-fish')
+    model = Model(int(pk), "eric-the-fish")
     validate_slug(model, slug)
     return HttpResponse("Allright!")
 
@@ -85,11 +78,11 @@ def raise_misago_banned(request):
 
 
 def raise_misago_403(request):
-    raise PermissionDenied('Misago 403')
+    raise PermissionDenied("Misago 403")
 
 
 def raise_misago_404(request):
-    raise Http404('Misago 404')
+    raise Http404("Misago 404")
 
 
 def raise_misago_405(request):
@@ -109,7 +102,7 @@ def raise_social_auth_failed(require_POST):
 
 
 def raise_social_wrong_backend(request):
-    raise WrongBackend('facebook')
+    raise WrongBackend("facebook")
 
 
 def raise_social_not_allowed_to_disconnect(request):

+ 104 - 168
misago/core/tests/test_apipatch.py

@@ -24,12 +24,12 @@ class ApiPatchTests(TestCase):
         def mock_function():
             pass
 
-        patch.add('test-add', mock_function)
+        patch.add("test-add", mock_function)
 
         self.assertEqual(len(patch._actions), 1)
-        self.assertEqual(patch._actions[0]['op'], 'add')
-        self.assertEqual(patch._actions[0]['path'], 'test-add')
-        self.assertEqual(patch._actions[0]['handler'], mock_function)
+        self.assertEqual(patch._actions[0]["op"], "add")
+        self.assertEqual(patch._actions[0]["path"], "test-add")
+        self.assertEqual(patch._actions[0]["handler"], mock_function)
 
     def test_remove(self):
         """remove method adds function to patch object"""
@@ -38,12 +38,12 @@ class ApiPatchTests(TestCase):
         def mock_function():
             pass
 
-        patch.remove('test-remove', mock_function)
+        patch.remove("test-remove", mock_function)
 
         self.assertEqual(len(patch._actions), 1)
-        self.assertEqual(patch._actions[0]['op'], 'remove')
-        self.assertEqual(patch._actions[0]['path'], 'test-remove')
-        self.assertEqual(patch._actions[0]['handler'], mock_function)
+        self.assertEqual(patch._actions[0]["op"], "remove")
+        self.assertEqual(patch._actions[0]["path"], "test-remove")
+        self.assertEqual(patch._actions[0]["handler"], mock_function)
 
     def test_replace(self):
         """replace method adds function to patch object"""
@@ -52,40 +52,28 @@ class ApiPatchTests(TestCase):
         def mock_function():
             pass
 
-        patch.replace('test-replace', mock_function)
+        patch.replace("test-replace", mock_function)
 
         self.assertEqual(len(patch._actions), 1)
-        self.assertEqual(patch._actions[0]['op'], 'replace')
-        self.assertEqual(patch._actions[0]['path'], 'test-replace')
-        self.assertEqual(patch._actions[0]['handler'], mock_function)
+        self.assertEqual(patch._actions[0]["op"], "replace")
+        self.assertEqual(patch._actions[0]["path"], "test-replace")
+        self.assertEqual(patch._actions[0]["handler"], mock_function)
 
     def test_validate_action(self):
         """validate_action method validates action dict"""
         patch = ApiPatch()
 
         VALID_ACTIONS = [
-            {
-                'op': 'add',
-                'path': 'test',
-                'value': 42
-            },
-            {
-                'op': 'remove',
-                'path': 'other-test',
-                'value': 'Lorem'
-            },
-            {
-                'op': 'replace',
-                'path': 'false-test',
-                'value': None
-            },
+            {"op": "add", "path": "test", "value": 42},
+            {"op": "remove", "path": "other-test", "value": "Lorem"},
+            {"op": "replace", "path": "false-test", "value": None},
         ]
 
         for action in VALID_ACTIONS:
             patch.validate_action(action)
 
         # undefined op
-        UNSUPPORTED_ACTIONS = ({}, {'op': ''}, {'no': 'op'}, )
+        UNSUPPORTED_ACTIONS = ({}, {"op": ""}, {"no": "op"})
 
         for action in UNSUPPORTED_ACTIONS:
             try:
@@ -95,32 +83,25 @@ class ApiPatchTests(TestCase):
 
         # unsupported op
         try:
-            patch.validate_action({'op': 'nope'})
+            patch.validate_action({"op": "nope"})
         except InvalidAction as e:
             self.assertEqual(e.args[0], '"nope" op is unsupported')
 
         # op lacking patch
         try:
-            patch.validate_action({'op': 'add'})
+            patch.validate_action({"op": "add"})
         except InvalidAction as e:
             self.assertEqual(e.args[0], '"add" op has to specify path')
 
         # op lacking value
         try:
-            patch.validate_action({
-                'op': 'add',
-                'path': 'yolo',
-            })
+            patch.validate_action({"op": "add", "path": "yolo"})
         except InvalidAction as e:
             self.assertEqual(e.args[0], '"add" op has to specify value')
 
         # empty value is allowed
         try:
-            patch.validate_action({
-                'op': 'add',
-                'path': 'yolo',
-                'value': '',
-            })
+            patch.validate_action({"op": "add", "path": "yolo", "value": ""})
         except InvalidAction as e:
             self.assertEqual(e.args[0], '"add" op has to specify value')
 
@@ -131,193 +112,148 @@ class ApiPatchTests(TestCase):
         mock_target = MockObject(13)
 
         def action_a(request, target, value):
-            self.assertEqual(request, 'request')
+            self.assertEqual(request, "request")
             self.assertEqual(target, mock_target)
-            return {'a': value * 2, 'b': 111}
+            return {"a": value * 2, "b": 111}
 
-        patch.replace('abc', action_a)
+        patch.replace("abc", action_a)
 
         def action_b(request, target, value):
-            self.assertEqual(request, 'request')
+            self.assertEqual(request, "request")
             self.assertEqual(target, mock_target)
-            return {'b': value * 10}
+            return {"b": value * 10}
 
-        patch.replace('abc', action_b)
+        patch.replace("abc", action_b)
 
         def action_fail(request, target, value):
             self.fail("unrequired action was called")
 
-        patch.add('c', action_fail)
-        patch.remove('c', action_fail)
-        patch.replace('c', action_fail)
+        patch.add("c", action_fail)
+        patch.remove("c", action_fail)
+        patch.replace("c", action_fail)
 
-        patch_dict = {'id': 123}
+        patch_dict = {"id": 123}
 
         patch.dispatch_action(
-            patch_dict, 'request', mock_target, {
-                'op': 'replace',
-                'path': 'abc',
-                'value': 5,
-            }
+            patch_dict,
+            "request",
+            mock_target,
+            {"op": "replace", "path": "abc", "value": 5},
         )
 
         self.assertEqual(len(patch_dict), 3)
-        self.assertEqual(patch_dict['id'], 123)
-        self.assertEqual(patch_dict['a'], 10)
-        self.assertEqual(patch_dict['b'], 50)
+        self.assertEqual(patch_dict["id"], 123)
+        self.assertEqual(patch_dict["a"], 10)
+        self.assertEqual(patch_dict["b"], 50)
 
     def test_dispatch(self):
         """dispatch calls actions and returns response"""
         patch = ApiPatch()
 
         def action_error(request, target, value):
-            if value == '404':
+            if value == "404":
                 raise Http404()
-            if value == 'perm':
+            if value == "perm":
                 raise PermissionDenied("yo ain't doing that!")
 
-        patch.replace('error', action_error)
+        patch.replace("error", action_error)
 
         def action_mutate(request, target, value):
-            return {'value': value * 2}
+            return {"value": value * 2}
 
-        patch.replace('mutate', action_mutate)
+        patch.replace("mutate", action_mutate)
 
         # dispatch requires list as an argument
         response = patch.dispatch(MockRequest({}), {})
         self.assertEqual(response.status_code, 400)
 
-        self.assertEqual(response.data['detail'], "PATCH request should be list of operations")
+        self.assertEqual(
+            response.data["detail"], "PATCH request should be list of operations"
+        )
 
         # valid dispatch
         response = patch.dispatch(
-            MockRequest([
-                {
-                    'op': 'replace',
-                    'path': 'mutate',
-                    'value': 2,
-                },
-                {
-                    'op': 'replace',
-                    'path': 'mutate',
-                    'value': 6,
-                },
-                {
-                    'op': 'replace',
-                    'path': 'mutate',
-                    'value': 7,
-                },
-            ]), MockObject(13)
+            MockRequest(
+                [
+                    {"op": "replace", "path": "mutate", "value": 2},
+                    {"op": "replace", "path": "mutate", "value": 6},
+                    {"op": "replace", "path": "mutate", "value": 7},
+                ]
+            ),
+            MockObject(13),
         )
 
         self.assertEqual(response.status_code, 200)
 
-        self.assertEqual(len(response.data['detail']), 3)
-        self.assertEqual(response.data['detail'][0], 'ok')
-        self.assertEqual(response.data['detail'][1], 'ok')
-        self.assertEqual(response.data['detail'][2], 'ok')
-        self.assertEqual(response.data['id'], 13)
-        self.assertEqual(response.data['value'], 14)
+        self.assertEqual(len(response.data["detail"]), 3)
+        self.assertEqual(response.data["detail"][0], "ok")
+        self.assertEqual(response.data["detail"][1], "ok")
+        self.assertEqual(response.data["detail"][2], "ok")
+        self.assertEqual(response.data["id"], 13)
+        self.assertEqual(response.data["value"], 14)
 
         # invalid action in dispatch
         response = patch.dispatch(
-            MockRequest([
-                {
-                    'op': 'replace',
-                    'path': 'mutate',
-                    'value': 2,
-                },
-                {
-                    'op': 'replace',
-                    'path': 'mutate',
-                    'value': 6,
-                },
-                {
-                    'op': 'replace',
-                },
-                {
-                    'op': 'replace',
-                    'path': 'mutate',
-                    'value': 7,
-                },
-            ]), MockObject(13)
+            MockRequest(
+                [
+                    {"op": "replace", "path": "mutate", "value": 2},
+                    {"op": "replace", "path": "mutate", "value": 6},
+                    {"op": "replace"},
+                    {"op": "replace", "path": "mutate", "value": 7},
+                ]
+            ),
+            MockObject(13),
         )
 
         self.assertEqual(response.status_code, 400)
 
-        self.assertEqual(len(response.data['detail']), 3)
-        self.assertEqual(response.data['detail'][0], 'ok')
-        self.assertEqual(response.data['detail'][1], 'ok')
-        self.assertEqual(response.data['detail'][2], '"replace" op has to specify path')
-        self.assertEqual(response.data['id'], 13)
-        self.assertEqual(response.data['value'], 12)
+        self.assertEqual(len(response.data["detail"]), 3)
+        self.assertEqual(response.data["detail"][0], "ok")
+        self.assertEqual(response.data["detail"][1], "ok")
+        self.assertEqual(response.data["detail"][2], '"replace" op has to specify path')
+        self.assertEqual(response.data["id"], 13)
+        self.assertEqual(response.data["value"], 12)
 
         # action in dispatch raised 404
         response = patch.dispatch(
-            MockRequest([
-                {
-                    'op': 'replace',
-                    'path': 'mutate',
-                    'value': 2,
-                },
-                {
-                    'op': 'replace',
-                    'path': 'error',
-                    'value': '404',
-                },
-                {
-                    'op': 'replace',
-                    'path': 'mutate',
-                    'value': 6,
-                },
-                {
-                    'op': 'replace',
-                    'path': 'mutate',
-                    'value': 7,
-                },
-            ]), MockObject(13)
+            MockRequest(
+                [
+                    {"op": "replace", "path": "mutate", "value": 2},
+                    {"op": "replace", "path": "error", "value": "404"},
+                    {"op": "replace", "path": "mutate", "value": 6},
+                    {"op": "replace", "path": "mutate", "value": 7},
+                ]
+            ),
+            MockObject(13),
         )
 
         self.assertEqual(response.status_code, 400)
 
-        self.assertEqual(len(response.data['detail']), 2)
-        self.assertEqual(response.data['detail'][0], 'ok')
-        self.assertEqual(response.data['detail'][1], "NOT FOUND")
-        self.assertEqual(response.data['id'], 13)
-        self.assertEqual(response.data['value'], 4)
+        self.assertEqual(len(response.data["detail"]), 2)
+        self.assertEqual(response.data["detail"][0], "ok")
+        self.assertEqual(response.data["detail"][1], "NOT FOUND")
+        self.assertEqual(response.data["id"], 13)
+        self.assertEqual(response.data["value"], 4)
 
         # action in dispatch raised perm denied
         response = patch.dispatch(
-            MockRequest([
-                {
-                    'op': 'replace',
-                    'path': 'mutate',
-                    'value': 2,
-                },
-                {
-                    'op': 'replace',
-                    'path': 'mutate',
-                    'value': 6,
-                },
-                {
-                    'op': 'replace',
-                    'path': 'mutate',
-                    'value': 9,
-                },
-                {
-                    'op': 'replace',
-                    'path': 'error',
-                    'value': 'perm',
-                },
-            ]), MockObject(13)
+            MockRequest(
+                [
+                    {"op": "replace", "path": "mutate", "value": 2},
+                    {"op": "replace", "path": "mutate", "value": 6},
+                    {"op": "replace", "path": "mutate", "value": 9},
+                    {"op": "replace", "path": "error", "value": "perm"},
+                ]
+            ),
+            MockObject(13),
         )
 
         self.assertEqual(response.status_code, 400)
 
-        self.assertEqual(len(response.data['detail']), 4)
-        self.assertEqual(response.data['detail'][0], 'ok')
-        self.assertEqual(response.data['detail'][1], 'ok')
-        self.assertEqual(response.data['detail'][2], 'ok')
-        self.assertEqual(response.data['detail'][3], "yo ain't doing that!")
-        self.assertEqual(response.data['id'], 13)
-        self.assertEqual(response.data['value'], 18)
+        self.assertEqual(len(response.data["detail"]), 4)
+        self.assertEqual(response.data["detail"][0], "ok")
+        self.assertEqual(response.data["detail"][1], "ok")
+        self.assertEqual(response.data["detail"][2], "ok")
+        self.assertEqual(response.data["detail"][3], "yo ain't doing that!")
+        self.assertEqual(response.data["id"], 13)
+        self.assertEqual(response.data["value"], 18)

+ 5 - 5
misago/core/tests/test_checks.py

@@ -6,9 +6,9 @@ from misago.core import SUPPORTED_ENGINES, check_db_engine
 
 
 INVALID_ENGINES = [
-    'django.db.backends.sqlite3',
-    'django.db.backends.mysql',
-    'django.db.backends.oracle',
+    "django.db.backends.sqlite3",
+    "django.db.backends.mysql",
+    "django.db.backends.oracle",
 ]
 
 
@@ -19,7 +19,7 @@ class TestCheckDBEngine(TestCase):
             warnings.simplefilter("ignore")
 
             for engine in SUPPORTED_ENGINES:
-                with self.settings(DATABASES={'default': {'ENGINE': engine}}):
+                with self.settings(DATABASES={"default": {"ENGINE": engine}}):
                     errors = check_db_engine(None)
                     self.assertEqual(errors, [])
 
@@ -29,6 +29,6 @@ class TestCheckDBEngine(TestCase):
             warnings.simplefilter("ignore")
 
             for engine in INVALID_ENGINES:
-                with self.settings(DATABASES={'default': {'ENGINE': engine}}):
+                with self.settings(DATABASES={"default": {"ENGINE": engine}}):
                     errors = check_db_engine(None)
                     self.assertTrue(errors)

+ 2 - 3
misago/core/tests/test_chunk_queryset.py

@@ -12,7 +12,7 @@ class ChunkQuerysetTest(TestCase):
         # create 100 items
         items_ids = []
         for _ in range(100):
-            obj = CacheVersion.objects.create(cache='nomatter')
+            obj = CacheVersion.objects.create(cache="nomatter")
             items_ids.append(obj.id)
         self.items_ids = list(reversed(items_ids))
 
@@ -26,7 +26,7 @@ class ChunkQuerysetTest(TestCase):
                 chunked_ids.append(obj.id)
 
         self.assertEqual(chunked_ids, self.items_ids)
-            
+
     def test_chunk_shrinking_queryset(self):
         """chunk_queryset utility chunks queryset in delete action"""
         with self.assertNumQueries(121):
@@ -35,4 +35,3 @@ class ChunkQuerysetTest(TestCase):
                 obj.delete()
 
         self.assertEqual(CacheVersion.objects.count(), 0)
-            

+ 1 - 1
misago/core/tests/test_common_middleware_redirect.py

@@ -10,4 +10,4 @@ class CommonMiddlewareRedirectTests(AuthenticatedUserTestCase):
         """
         response = self.client.get(self.user.get_absolute_url()[:-1])
         self.assertEqual(response.status_code, 301)
-        self.assertTrue(response['location'].endswith(self.user.get_absolute_url()))
+        self.assertTrue(response["location"].endswith(self.user.get_absolute_url()))

+ 31 - 38
misago/core/tests/test_context_processors.py

@@ -5,7 +5,7 @@ from misago.core import context_processors
 
 
 class MockRequest(object):
-    path = '/'
+    path = "/"
 
     def __init__(self, secure, host):
         self.secure = secure
@@ -26,74 +26,67 @@ class MetaMockRequest(object):
 class MomentjsLocaleTests(TestCase):
     def test_momentjs_locale(self):
         """momentjs_locale adds MOMENTJS_LOCALE_URL to context"""
-        with translation.override('no-no'):
+        with translation.override("no-no"):
             self.assertEqual(
-                context_processors.momentjs_locale(True), {
-                    'MOMENTJS_LOCALE_URL': None,
-                }
+                context_processors.momentjs_locale(True), {"MOMENTJS_LOCALE_URL": None}
             )
 
-        with translation.override('en-us'):
+        with translation.override("en-us"):
             self.assertEqual(
-                context_processors.momentjs_locale(True), {
-                    'MOMENTJS_LOCALE_URL': None,
-                }
+                context_processors.momentjs_locale(True), {"MOMENTJS_LOCALE_URL": None}
             )
 
-        with translation.override('de'):
+        with translation.override("de"):
             self.assertEqual(
-                context_processors.momentjs_locale(True), {
-                    'MOMENTJS_LOCALE_URL': 'misago/momentjs/de.js',
-                }
+                context_processors.momentjs_locale(True),
+                {"MOMENTJS_LOCALE_URL": "misago/momentjs/de.js"},
             )
 
-        with translation.override('pl-de'):
+        with translation.override("pl-de"):
             self.assertEqual(
-                context_processors.momentjs_locale(True), {
-                    'MOMENTJS_LOCALE_URL': 'misago/momentjs/pl.js',
-                }
+                context_processors.momentjs_locale(True),
+                {"MOMENTJS_LOCALE_URL": "misago/momentjs/pl.js"},
             )
 
 
 class SiteAddressTests(TestCase):
     def test_site_address_for_http(self):
         """Correct SITE_ADDRESS set for HTTP request"""
-        mock_request = MockRequest(False, 'somewhere.com')
+        mock_request = MockRequest(False, "somewhere.com")
         self.assertEqual(
-            context_processors.site_address(mock_request), {
-                'REQUEST_PATH': '/',
-                'SITE_ADDRESS': 'http://somewhere.com',
-                'SITE_HOST': 'somewhere.com',
-                'SITE_PROTOCOL': 'http',
-            }
+            context_processors.site_address(mock_request),
+            {
+                "REQUEST_PATH": "/",
+                "SITE_ADDRESS": "http://somewhere.com",
+                "SITE_HOST": "somewhere.com",
+                "SITE_PROTOCOL": "http",
+            },
         )
 
     def test_site_address_for_https(self):
         """Correct SITE_ADDRESS set for HTTPS request"""
-        mock_request = MockRequest(True, 'somewhere.com')
+        mock_request = MockRequest(True, "somewhere.com")
         self.assertEqual(
-            context_processors.site_address(mock_request), {
-                'REQUEST_PATH': '/',
-                'SITE_ADDRESS': 'https://somewhere.com',
-                'SITE_HOST': 'somewhere.com',
-                'SITE_PROTOCOL': 'https',
-            }
+            context_processors.site_address(mock_request),
+            {
+                "REQUEST_PATH": "/",
+                "SITE_ADDRESS": "https://somewhere.com",
+                "SITE_HOST": "somewhere.com",
+                "SITE_PROTOCOL": "https",
+            },
         )
 
 
 class FrontendContextTests(TestCase):
     def test_frontend_context(self):
         """frontend_context is available in templates"""
-        mock_request = MockRequest(False, 'somewhere.com')
+        mock_request = MockRequest(False, "somewhere.com")
         mock_request.include_frontend_context = True
-        mock_request.frontend_context = {'someValue': 'Something'}
+        mock_request.frontend_context = {"someValue": "Something"}
 
         self.assertEqual(
-            context_processors.frontend_context(mock_request), {
-                'frontend_context': {
-                    'someValue': 'Something',
-                },
-            }
+            context_processors.frontend_context(mock_request),
+            {"frontend_context": {"someValue": "Something"}},
         )
 
         mock_request.include_frontend_context = False

+ 4 - 4
misago/core/tests/test_decorators.py

@@ -2,14 +2,14 @@ from django.test import TestCase, override_settings
 from django.urls import reverse
 
 
-@override_settings(ROOT_URLCONF='misago.core.testproject.urls')
+@override_settings(ROOT_URLCONF="misago.core.testproject.urls")
 class RequirePostTests(TestCase):
     def test_require_POST_success(self):
         """require_POST decorator allowed POST request"""
-        response = self.client.post(reverse('test-require-post'))
-        self.assertContains(response, 'Request method: POST')
+        response = self.client.post(reverse("test-require-post"))
+        self.assertContains(response, "Request method: POST")
 
     def test_require_POST_fail_GET(self):
         """require_POST decorator failed on GET request"""
-        response = self.client.get(reverse('test-require-post'))
+        response = self.client.get(reverse("test-require-post"))
         self.assertContains(response, "Wrong way", status_code=405)

+ 28 - 21
misago/core/tests/test_errorpages.py

@@ -8,7 +8,10 @@ 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
 
 
@@ -16,62 +19,66 @@ class CSRFErrorViewTests(TestCase):
     def test_csrf_failure(self):
         """csrf_failure error page has no show-stoppers"""
         csrf_client = Client(enforce_csrf_checks=True)
-        response = csrf_client.post(reverse('misago:index'), data={'eric': 'fish'})
+        response = csrf_client.post(reverse("misago:index"), data={"eric": "fish"})
         self.assertContains(response, "Request blocked", status_code=403)
 
 
-@override_settings(ROOT_URLCONF='misago.core.testproject.urls')
+@override_settings(ROOT_URLCONF="misago.core.testproject.urls")
 class ErrorPageViewsTests(TestCase):
     def test_banned_returns_403(self):
         """banned error page has no show-stoppers"""
-        response = self.client.get(reverse('raise-misago-banned'))
+        response = self.client.get(reverse("raise-misago-banned"))
         self.assertContains(response, "misago:error-banned", status_code=403)
         self.assertContains(response, "<p>Banned for test!</p>", status_code=403)
-        self.assertContains(response, encode_json_html("<p>Banned for test!</p>"), status_code=403)
+        self.assertContains(
+            response, encode_json_html("<p>Banned for test!</p>"), status_code=403
+        )
 
     def test_permission_denied_returns_403(self):
         """permission_denied error page has no show-stoppers"""
-        response = self.client.get(reverse('raise-misago-403'))
+        response = self.client.get(reverse("raise-misago-403"))
         self.assertContains(response, "misago:error-403", status_code=403)
         self.assertContains(response, "Page not available", status_code=403)
 
     def test_page_not_found_returns_404(self):
         """page_not_found error page has no show-stoppers"""
-        response = self.client.get(reverse('raise-misago-404'))
+        response = self.client.get(reverse("raise-misago-404"))
         self.assertContains(response, "misago:error-404", status_code=404)
         self.assertContains(response, "Page not found", status_code=404)
 
     def test_not_allowed_returns_405(self):
         """not allowed error page has no showstoppers"""
-        response = self.client.get(reverse('raise-misago-405'))
+        response = self.client.get(reverse("raise-misago-405"))
         self.assertContains(response, "misago:error-405", status_code=405)
         self.assertContains(response, "Wrong way", status_code=405)
 
     def test_social_auth_failed_returns_403(self):
         """social auth's failed error returns 403"""
-        response = self.client.get(reverse('raise-social-auth-failed'))
+        response = self.client.get(reverse("raise-social-auth-failed"))
         self.assertContains(response, "page-error-social", status_code=403)
         self.assertContains(response, "GitHub", status_code=403)
 
     def test_social_wrong_backend_returns_403(self):
         """social auth's wrong backend error returns 403"""
-        response = self.client.get(reverse('raise-social-wrong-backend'))
+        response = self.client.get(reverse("raise-social-wrong-backend"))
         self.assertContains(response, "page-error-social", status_code=403)
 
     def test_social_not_allowed_to_disconnect_returns_403(self):
         """social auth's not allowed to disconnect error returns 403"""
-        response = self.client.get(reverse('raise-social-not-allowed-to-disconnect'))
+        response = self.client.get(reverse("raise-social-not-allowed-to-disconnect"))
         self.assertContains(response, "page-error-social", status_code=403)
 
     def test_social_failed_message(self):
         """misago-specific social auth failed exception error page returns 403 with message"""
-        response = self.client.get(reverse('raise-social-auth-failed-message'))
+        response = self.client.get(reverse("raise-social-auth-failed-message"))
         self.assertContains(response, "page-error-social", status_code=403)
-        self.assertContains(response, "This message will be shown to user!", status_code=403)
+        self.assertContains(
+            response, "This message will be shown to user!", status_code=403
+        )
 
     def test_social_auth_banned(self):
         """misago-specific social auth banned exception error page returns 403 with ban message"""
-        response = self.client.get(reverse('raise-social-auth-banned'))
+        response = self.client.get(reverse("raise-social-auth-banned"))
         self.assertContains(response, "page-error-social", status_code=403)
         self.assertContains(response, "Banned in auth!", status_code=403)
 
@@ -87,17 +94,17 @@ def create_request(url):
     return request
 
 
-@override_settings(ROOT_URLCONF='misago.core.testproject.urlswitherrorhandlers')
+@override_settings(ROOT_URLCONF="misago.core.testproject.urlswitherrorhandlers")
 class CustomErrorPagesTests(TestCase):
     def setUp(self):
-        self.misago_request = create_request(reverse('misago:index'))
-        self.site_request = create_request(reverse('raise-403'))
+        self.misago_request = create_request(reverse("misago:index"))
+        self.site_request = create_request(reverse("raise-403"))
 
     def test_shared_403_decorator(self):
         """shared_403_decorator calls correct error handler"""
-        response = self.client.get(reverse('raise-misago-403'))
+        response = self.client.get(reverse("raise-misago-403"))
         self.assertEqual(response.status_code, 403)
-        response = self.client.get(reverse('raise-403'))
+        response = self.client.get(reverse("raise-403"))
         self.assertContains(response, "Custom 403", status_code=403)
 
         response = mock_custom_403_error_page(self.misago_request, PermissionDenied())
@@ -107,9 +114,9 @@ class CustomErrorPagesTests(TestCase):
 
     def test_shared_404_decorator(self):
         """shared_404_decorator calls correct error handler"""
-        response = self.client.get(reverse('raise-misago-404'))
+        response = self.client.get(reverse("raise-misago-404"))
         self.assertEqual(response.status_code, 404)
-        response = self.client.get(reverse('raise-404'))
+        response = self.client.get(reverse("raise-404"))
         self.assertContains(response, "Custom 404", status_code=404)
 
         response = mock_custom_404_error_page(self.misago_request, Http404())

+ 1 - 1
misago/core/tests/test_exceptionhandler_middleware.py

@@ -13,7 +13,7 @@ from misago.core.middleware import ExceptionHandlerMiddleware
 
 
 def create_request():
-    request = RequestFactory().get(reverse('misago:index'))
+    request = RequestFactory().get(reverse("misago:index"))
     request.cache_versions = get_cache_versions()
     request.settings = DynamicSettings(request.cache_versions)
     request.user = AnonymousUser()

+ 8 - 5
misago/core/tests/test_exceptionhandlers.py

@@ -33,7 +33,8 @@ class GetExceptionHandlerTests(TestCase):
     def test_exception_handlers_list(self):
         """HANDLED_EXCEPTIONS length matches that of EXCEPTION_HANDLERS"""
         self.assertEqual(
-            len(exceptionhandler.HANDLED_EXCEPTIONS), len(exceptionhandler.EXCEPTION_HANDLERS)
+            len(exceptionhandler.HANDLED_EXCEPTIONS),
+            len(exceptionhandler.EXCEPTION_HANDLERS),
         )
 
     def test_get_exception_handler_for_handled_exceptions(self):
@@ -56,20 +57,22 @@ class HandleAPIExceptionTests(TestCase):
         response = exceptionhandler.handle_api_exception(Banned(ban), None)
 
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.data['ban']['message']['html'], "<p>This is test ban!</p>")
+        self.assertEqual(
+            response.data["ban"]["message"]["html"], "<p>This is test ban!</p>"
+        )
 
     def test_permission_denied(self):
         """permission denied exception is correctly handled"""
         response = exceptionhandler.handle_api_exception(PermissionDenied(), None)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.data['detail'], "Permission denied.")
+        self.assertEqual(response.data["detail"], "Permission denied.")
 
     def test_permission_message_denied(self):
         """permission denied with message is correctly handled"""
         exception = PermissionDenied("You shall not pass!")
         response = exceptionhandler.handle_api_exception(exception, None)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.data['detail'], "You shall not pass!")
+        self.assertEqual(response.data["detail"], "You shall not pass!")
 
     def test_unhandled_exception(self):
         """our exception handler is not interrupting other exceptions"""
@@ -79,4 +82,4 @@ class HandleAPIExceptionTests(TestCase):
 
         response = exceptionhandler.handle_api_exception(Http404(), None)
         self.assertEqual(response.status_code, 404)
-        self.assertEqual(response.data['detail'], "Not found.")
+        self.assertEqual(response.data["detail"], "Not found.")

+ 11 - 7
misago/core/tests/test_jsi18n.py

@@ -6,31 +6,35 @@ from django.urls import reverse
 from django.utils import translation
 
 
-MISAGO_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-LOCALES_DIR = os.path.join(MISAGO_DIR, 'locale')
+MISAGO_DIR = os.path.dirname(
+    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+)
+LOCALES_DIR = os.path.join(MISAGO_DIR, "locale")
 
 
 class JsI18nUrlTests(TestCase):
     def test_url_cache_buster(self):
         """js i18n catalog link has cachebuster with lang code"""
-        url = '%s?%s' % (reverse('django-i18n'), settings.LANGUAGE_CODE)
+        url = "%s?%s" % (reverse("django-i18n"), settings.LANGUAGE_CODE)
 
-        response = self.client.get(reverse('misago:index'))
+        response = self.client.get(reverse("misago:index"))
         self.assertContains(response, url)
 
     def test_js_catalogs_are_correct(self):
         """no JS catalogs have showstoppers"""
         failed_languages = []
         for language in os.listdir(LOCALES_DIR):
-            if '.' in language:
+            if "." in language:
                 continue
             try:
                 with translation.override(language):
-                    response = self.client.get(reverse('django-i18n'))
+                    response = self.client.get(reverse("django-i18n"))
                     if response.status_code != 200:
                         failed_languages.append(language)
             except:
                 failed_languages.append(language)
 
         if failed_languages:
-            self.fail("JS catalog failed for languages: %s" % (', '.join(failed_languages)))
+            self.fail(
+                "JS catalog failed for languages: %s" % (", ".join(failed_languages))
+            )

+ 15 - 11
misago/core/tests/test_mail.py

@@ -14,18 +14,20 @@ UserModel = get_user_model()
 
 class MailTests(TestCase):
     def test_building_mail_without_context_raises_value_error(self):
-        user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        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')
+        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": {}})
+            build_mail(
+                user, "Misago Test Mail", "misago/emails/base", context={"settings": {}}
+            )
 
     def test_mail_user(self):
         """mail_user sets message in backend"""
-        user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        user = UserModel.objects.create_user("Bob", "bob@bob.com", "pass123")
 
         cache_versions = get_cache_versions()
         settings = DynamicSettings(cache_versions)
@@ -41,7 +43,9 @@ class MailTests(TestCase):
 
         # assert that url to user's avatar is valid
         html_body = mail.outbox[0].alternatives[0][0]
-        user_avatar_url = reverse('misago:user-avatar', kwargs={'pk': user.pk, 'size': 32})
+        user_avatar_url = reverse(
+            "misago:user-avatar", kwargs={"pk": user.pk, "size": 32}
+        )
 
         self.assertIn(user_avatar_url, html_body)
 
@@ -51,11 +55,11 @@ class MailTests(TestCase):
         settings = DynamicSettings(cache_versions)
 
         test_users = [
-            UserModel.objects.create_user('Alpha', 'alpha@test.com', 'pass123'),
-            UserModel.objects.create_user('Beta', 'beta@test.com', 'pass123'),
-            UserModel.objects.create_user('Niner', 'niner@test.com', 'pass123'),
-            UserModel.objects.create_user('Foxtrot', 'foxtrot@test.com', 'pass123'),
-            UserModel.objects.create_user('Uniform', 'uniform@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("Niner", "niner@test.com", "pass123"),
+            UserModel.objects.create_user("Foxtrot", "foxtrot@test.com", "pass123"),
+            UserModel.objects.create_user("Uniform", "uniform@test.com", "pass123"),
         ]
 
         mail_users(
@@ -67,7 +71,7 @@ class MailTests(TestCase):
 
         spams_sent = 0
         for message in mail.outbox:
-            if message.subject == 'Misago Test Spam':
+            if message.subject == "Misago Test Spam":
                 spams_sent += 1
 
         self.assertEqual(spams_sent, len(test_users))

+ 18 - 9
misago/core/tests/test_momentjs.py

@@ -7,13 +7,13 @@ class MomentJSTests(TestCase):
     def test_clean_language_name(self):
         """clean_language_name returns valid name"""
         TEST_CASES = [
-            ('AF', 'af'),
-            ('ar-SA', 'ar-sa'),
-            ('ar_SA', 'ar-sa'),
-            ('de', 'de'),
-            ('de-NO', 'de'),
-            ('pl-pl', 'pl'),
-            ('zz', None),
+            ("AF", "af"),
+            ("ar-SA", "ar-sa"),
+            ("ar_SA", "ar-sa"),
+            ("de", "de"),
+            ("de-NO", "de"),
+            ("pl-pl", "pl"),
+            ("zz", None),
         ]
 
         for dirty, clean in TEST_CASES:
@@ -22,13 +22,22 @@ class MomentJSTests(TestCase):
     def test_get_locale_path(self):
         """get_locale_path returns path to locale or null if it doesnt exist"""
         EXISTING_LOCALES = (
-            'af', 'ar-sa', 'ar-sasa', 'de', 'et', 'pl', 'pl-pl', 'ru', 'pt_BR', 'zh_Hans'
+            "af",
+            "ar-sa",
+            "ar-sasa",
+            "de",
+            "et",
+            "pl",
+            "pl-pl",
+            "ru",
+            "pt_BR",
+            "zh_Hans",
         )
 
         for language in EXISTING_LOCALES:
             self.assertIsNotNone(get_locale_url(language))
 
-        NONEXISTING_LOCALES = ('ga', 'en', 'en-us', 'martian', )
+        NONEXISTING_LOCALES = ("ga", "en", "en-us", "martian")
 
         for language in NONEXISTING_LOCALES:
             self.assertIsNone(get_locale_url(language))

+ 8 - 15
misago/core/tests/test_page.py

@@ -5,32 +5,25 @@ from misago.core.page import Page
 
 class SiteTests(TestCase):
     def setUp(self):
-        self.page = Page('test')
+        self.page = Page("test")
 
     def test_pages(self):
         """add_section adds section to page"""
         self.page.add_section(
-            link='misago:user-posts',
-            name='Posts',
-            after='misago:user-threads',
+            link="misago:user-posts", name="Posts", after="misago:user-threads"
         )
 
-        self.page.add_section(
-            link='misago:user-threads',
-            name='Threads',
-        )
+        self.page.add_section(link="misago:user-threads", name="Threads")
 
         self.page.add_section(
-            link='misago:user-follows',
-            name='Follows',
-            before='misago:user-posts',
+            link="misago:user-follows", name="Follows", before="misago:user-posts"
         )
 
         self.page.assert_is_finalized()
 
         sorted_sections = self.page._sorted_list
-        self.assertEqual(sorted_sections[0]['name'], 'Threads')
-        self.assertEqual(sorted_sections[1]['name'], 'Follows')
-        self.assertEqual(sorted_sections[2]['name'], 'Posts')
+        self.assertEqual(sorted_sections[0]["name"], "Threads")
+        self.assertEqual(sorted_sections[1]["name"], "Follows")
+        self.assertEqual(sorted_sections[2]["name"], "Posts")
 
-        self.assertEqual(self.page.get_default_link(), 'misago:user-threads')
+        self.assertEqual(self.page.get_default_link(), "misago:user-threads")

+ 37 - 56
misago/core/tests/test_pgpartialindex.py

@@ -11,9 +11,9 @@ class PgPartialIndexTests(TestCase):
         """multiple fields are supported"""
         with connection.schema_editor() as editor:
             sql = PgPartialIndex(
-                fields=['has_events', 'is_hidden'],
-                name='test_partial',
-                where={'has_events': True},
+                fields=["has_events", "is_hidden"],
+                name="test_partial",
+                where={"has_events": True},
             ).create_sql(Thread, editor)
 
             self.assertIn('CREATE INDEX "test_partial" ON "misago_threads_thread"', sql)
@@ -23,52 +23,42 @@ class PgPartialIndexTests(TestCase):
         """where clauses generate correctly"""
         with connection.schema_editor() as editor:
             sql = PgPartialIndex(
-                fields=['has_events'],
-                name='test_partial',
-                where={'has_events': True},
+                fields=["has_events"], name="test_partial", where={"has_events": True}
             ).create_sql(Thread, editor)
 
             self.assertTrue(sql.endswith('WHERE "has_events" = true'))
 
             sql = PgPartialIndex(
-                fields=['has_events'],
-                name='test_partial',
-                where={'has_events': False},
+                fields=["has_events"], name="test_partial", where={"has_events": False}
             ).create_sql(Thread, editor)
             self.assertTrue(sql.endswith('WHERE "has_events" = false'))
 
             sql = PgPartialIndex(
-                fields=['has_events'],
-                name='test_partial',
-                where={'has_events': 42},
+                fields=["has_events"], name="test_partial", where={"has_events": 42}
             ).create_sql(Thread, editor)
             self.assertTrue(sql.endswith('WHERE "has_events" = 42'))
 
             sql = PgPartialIndex(
-                fields=['has_events'],
-                name='test_partial',
-                where={'has_events__lt': 42},
+                fields=["has_events"], name="test_partial", where={"has_events__lt": 42}
             ).create_sql(Thread, editor)
             self.assertTrue(sql.endswith('WHERE "has_events" < 42'))
 
             sql = PgPartialIndex(
-                fields=['has_events'],
-                name='test_partial',
-                where={'has_events__gt': 42},
+                fields=["has_events"], name="test_partial", where={"has_events__gt": 42}
             ).create_sql(Thread, editor)
             self.assertTrue(sql.endswith('WHERE "has_events" > 42'))
 
             sql = PgPartialIndex(
-                fields=['has_events'],
-                name='test_partial',
-                where={'has_events__lte': 42},
+                fields=["has_events"],
+                name="test_partial",
+                where={"has_events__lte": 42},
             ).create_sql(Thread, editor)
             self.assertTrue(sql.endswith('WHERE "has_events" <= 42'))
 
             sql = PgPartialIndex(
-                fields=['has_events'],
-                name='test_partial',
-                where={'has_events__gte': 42},
+                fields=["has_events"],
+                name="test_partial",
+                where={"has_events__gte": 42},
             ).create_sql(Thread, editor)
             self.assertTrue(sql.endswith('WHERE "has_events" >= 42'))
 
@@ -76,63 +66,54 @@ class PgPartialIndexTests(TestCase):
         """where clause with multiple conditions generates correctly"""
         with connection.schema_editor() as editor:
             sql = PgPartialIndex(
-                fields=['has_events'],
-                name='test_partial',
-                where={
-                    'has_events__gte': 42,
-                    'is_hidden': True,
-                },
+                fields=["has_events"],
+                name="test_partial",
+                where={"has_events__gte": 42, "is_hidden": True},
             ).create_sql(Thread, editor)
-            self.assertTrue(sql.endswith('WHERE "has_events" >= 42 AND "is_hidden" = true'))
+            self.assertTrue(
+                sql.endswith('WHERE "has_events" >= 42 AND "is_hidden" = true')
+            )
 
     def test_set_name_with_model(self):
         """valid index name is autogenerated"""
         index = PgPartialIndex(
-            fields=['has_events', 'is_hidden'],
-            where={'has_events': True},
+            fields=["has_events", "is_hidden"], where={"has_events": True}
         )
         index.set_name_with_model(Thread)
-        self.assertEqual(index.name, 'misago_thre_has_eve_1b05b8_part')
+        self.assertEqual(index.name, "misago_thre_has_eve_1b05b8_part")
 
         index = PgPartialIndex(
-            fields=['has_events', 'is_hidden', 'is_closed'],
-            where={'has_events': True},
+            fields=["has_events", "is_hidden", "is_closed"], where={"has_events": True}
         )
         index.set_name_with_model(Thread)
-        self.assertEqual(index.name, 'misago_thre_has_eve_eaab5e_part')
+        self.assertEqual(index.name, "misago_thre_has_eve_eaab5e_part")
 
         index = PgPartialIndex(
-            fields=['has_events', 'is_hidden', 'is_closed'],
-            where={
-                'has_events': True,
-                'is_closed': False,
-            },
+            fields=["has_events", "is_hidden", "is_closed"],
+            where={"has_events": True, "is_closed": False},
         )
         index.set_name_with_model(Thread)
-        self.assertEqual(index.name, 'misago_thre_has_eve_e738fe_part')
+        self.assertEqual(index.name, "misago_thre_has_eve_e738fe_part")
 
     def test_index_repr(self):
         """index creates descriptive representation string"""
-        index = PgPartialIndex(
-            fields=['has_events'],
-            where={'has_events': True},
+        index = PgPartialIndex(fields=["has_events"], where={"has_events": True})
+        self.assertEqual(
+            repr(index),
+            "<PgPartialIndex: fields='has_events', where='has_events=True'>",
         )
-        self.assertEqual(repr(index), "<PgPartialIndex: fields='has_events', where='has_events=True'>")
 
         index = PgPartialIndex(
-            fields=['has_events', 'is_hidden'],
-            where={'has_events': True},
+            fields=["has_events", "is_hidden"], where={"has_events": True}
         )
         self.assertIn("fields='has_events, is_hidden',", repr(index))
         self.assertIn(", where='has_events=True'", repr(index))
 
         index = PgPartialIndex(
-            fields=['has_events', 'is_hidden', 'is_closed'],
-            where={
-                'has_events': True,
-                'is_closed': False,
-                'replies__gte': 5,
-            },
+            fields=["has_events", "is_hidden", "is_closed"],
+            where={"has_events": True, "is_closed": False, "replies__gte": 5},
         )
         self.assertIn("fields='has_events, is_hidden, is_closed',", repr(index))
-        self.assertIn(", where='has_events=True, is_closed=False, replies__gte=5'", repr(index))
+        self.assertIn(
+            ", where='has_events=True, is_closed=False, replies__gte=5'", repr(index)
+        )

+ 33 - 33
misago/core/tests/test_serializers.py

@@ -11,59 +11,59 @@ from misago.threads.models import Thread
 class MutableFieldsSerializerTests(TestCase):
     def test_subset_fields(self):
         """classmethod subset_fields creates new serializer"""
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         thread = testutils.post_thread(category=category)
 
-        fields = ['id', 'title', 'replies', 'last_poster_name']
+        fields = ["id", "title", "replies", "last_poster_name"]
 
         serializer = TestSerializer.subset_fields(*fields)
-        self.assertEqual(serializer.__name__, 'TestSerializerIdTitleRepliesLastPosterNameSubset')
+        self.assertEqual(
+            serializer.__name__, "TestSerializerIdTitleRepliesLastPosterNameSubset"
+        )
         self.assertEqual(serializer.Meta.fields, fields)
 
         serialized_thread = serializer(thread).data
         self.assertEqual(
-            serialized_thread, {
-                'id': thread.id,
-                'title': thread.title,
-                'replies': thread.replies,
-                'last_poster_name': thread.last_poster_name,
-            }
+            serialized_thread,
+            {
+                "id": thread.id,
+                "title": thread.title,
+                "replies": thread.replies,
+                "last_poster_name": thread.last_poster_name,
+            },
         )
 
         self.assertFalse(TestSerializer.Meta.fields == serializer.Meta.fields)
 
     def test_exclude_fields(self):
         """classmethod exclude_fields creates new serializer"""
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         thread = testutils.post_thread(category=category)
 
-        kept_fields = ['id', 'title', 'weight']
+        kept_fields = ["id", "title", "weight"]
         removed_fields = list(set(TestSerializer.Meta.fields) - set(kept_fields))
 
         serializer = TestSerializer.exclude_fields(*removed_fields)
-        self.assertEqual(serializer.__name__, 'TestSerializerIdTitleWeightSubset')
+        self.assertEqual(serializer.__name__, "TestSerializerIdTitleWeightSubset")
         self.assertEqual(serializer.Meta.fields, kept_fields)
 
         serialized_thread = serializer(thread).data
         self.assertEqual(
-            serialized_thread, {
-                'id': thread.id,
-                'title': thread.title,
-                'weight': thread.weight,
-            }
+            serialized_thread,
+            {"id": thread.id, "title": thread.title, "weight": thread.weight},
         )
 
         self.assertFalse(TestSerializer.Meta.fields == serializer.Meta.fields)
 
     def test_extend_fields(self):
         """classmethod extend_fields creates new serializer"""
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         thread = testutils.post_thread(category=category)
 
-        serializer = TestSerializer.extend_fields('category')
+        serializer = TestSerializer.extend_fields("category")
 
         serialized_thread = serializer(thread).data
-        self.assertEqual(serialized_thread['category'], category.pk)
+        self.assertEqual(serialized_thread["category"], category.pk)
 
 
 class TestSerializer(serializers.ModelSerializer, MutableFields):
@@ -72,17 +72,17 @@ class TestSerializer(serializers.ModelSerializer, MutableFields):
     class Meta:
         model = Thread
         fields = [
-            'id',
-            'title',
-            'replies',
-            'has_unapproved_posts',
-            'started_on',
-            'last_post_on',
-            'last_post_is_event',
-            'last_post',
-            'last_poster_name',
-            'is_unapproved',
-            'is_hidden',
-            'is_closed',
-            'weight',
+            "id",
+            "title",
+            "replies",
+            "has_unapproved_posts",
+            "started_on",
+            "last_post_on",
+            "last_post_is_event",
+            "last_post",
+            "last_poster_name",
+            "is_unapproved",
+            "is_hidden",
+            "is_closed",
+            "weight",
         ]

+ 7 - 7
misago/core/tests/test_setup.py

@@ -17,24 +17,24 @@ class SetupTests(TestCase):
         mock_parser = MockParser()
 
         with self.assertRaises(ValueError):
-            setup.validate_project_name(mock_parser, '-lorem')
+            setup.validate_project_name(mock_parser, "-lorem")
 
         with self.assertRaises(ValueError):
-            setup.validate_project_name(mock_parser, 'django')
+            setup.validate_project_name(mock_parser, "django")
 
         with self.assertRaises(ValueError):
-            setup.validate_project_name(mock_parser, 'dja-ngo')
+            setup.validate_project_name(mock_parser, "dja-ngo")
 
         with self.assertRaises(ValueError):
-            setup.validate_project_name(mock_parser, '123')
+            setup.validate_project_name(mock_parser, "123")
 
-        self.assertTrue(setup.validate_project_name(mock_parser, 'myforum'))
-        self.assertTrue(setup.validate_project_name(mock_parser, 'myforum123'))
+        self.assertTrue(setup.validate_project_name(mock_parser, "myforum"))
+        self.assertTrue(setup.validate_project_name(mock_parser, "myforum123"))
 
     def test_get_misago_project_template(self):
         """get_misago_project_template returns correct path to template"""
         misago_path = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
-        test_project_path = os.path.join(misago_path, 'project_template')
+        test_project_path = os.path.join(misago_path, "project_template")
 
         self.assertEqual(
             smart_str(setup.get_misago_project_template()), smart_str(test_project_path)

+ 91 - 123
misago/core/tests/test_shortcuts.py

@@ -5,64 +5,53 @@ from django.urls import reverse
 from misago.core.shortcuts import get_int_or_404
 
 
-@override_settings(ROOT_URLCONF='misago.core.testproject.urls')
+@override_settings(ROOT_URLCONF="misago.core.testproject.urls")
 class PaginateTests(TestCase):
     def test_valid_page_handling(self):
         """Valid page number causes no errors"""
-        response = self.client.get(reverse('test-pagination', kwargs={'page': 2}))
+        response = self.client.get(reverse("test-pagination", kwargs={"page": 2}))
         self.assertEqual("5,6,7,8,9", response.content.decode())
 
     def test_invalid_page_handling(self):
         """Invalid page number results in 404 error"""
-        response = self.client.get(reverse('test-pagination', kwargs={'page': 42}))
+        response = self.client.get(reverse("test-pagination", kwargs={"page": 42}))
         self.assertEqual(response.status_code, 404)
 
     def test_implicit_page_handling(self):
         """Implicit page number causes no errors"""
-        response = self.client.get(reverse('test-pagination'))
+        response = self.client.get(reverse("test-pagination"))
         self.assertEqual("0,1,2,3,4", response.content.decode())
 
     def test_explicit_page_handling(self):
         """Explicit page number results in redirect"""
-        response = self.client.get(reverse('test-pagination', kwargs={'page': 1}))
+        response = self.client.get(reverse("test-pagination", kwargs={"page": 1}))
         valid_url = "/forum/test-pagination/"
-        self.assertEqual(response['Location'], valid_url)
+        self.assertEqual(response["Location"], valid_url)
 
 
-@override_settings(ROOT_URLCONF='misago.core.testproject.urls')
+@override_settings(ROOT_URLCONF="misago.core.testproject.urls")
 class ValidateSlugTests(TestCase):
     def test_valid_slug_handling(self):
         """Valid slug causes no interruption in view processing"""
         response = self.client.get(
-            reverse('validate-slug-view', kwargs={
-                'slug': 'eric-the-fish',
-                'pk': 1,
-            })
+            reverse("validate-slug-view", kwargs={"slug": "eric-the-fish", "pk": 1})
         )
         self.assertContains(response, "Allright")
 
     def test_invalid_slug_handling(self):
         """Invalid slug returns in redirect to valid page"""
         response = self.client.get(
-            reverse('validate-slug-view', kwargs={
-                'slug': 'lion-the-eric',
-                'pk': 1,
-            })
+            reverse("validate-slug-view", kwargs={"slug": "lion-the-eric", "pk": 1})
         )
 
         valid_url = "/forum/test-valid-slug/eric-the-fish-1/"
-        self.assertEqual(response['Location'], valid_url)
+        self.assertEqual(response["Location"], valid_url)
 
 
 class GetIntOr404Tests(TestCase):
     def test_valid_inputs(self):
         """get_int_or_404 returns int for valid values"""
-        VALID_VALUES = [
-            ('0', 0),
-            ('123', 123),
-            ('000123', 123),
-            ('1', 1),
-        ]
+        VALID_VALUES = [("0", 0), ("123", 123), ("000123", 123), ("1", 1)]
 
         for value, result in VALID_VALUES:
             self.assertEqual(get_int_or_404(value), result)
@@ -71,14 +60,14 @@ class GetIntOr404Tests(TestCase):
         """get_int_or_404 raises Http404 for invalid values"""
         INVALID_VALUES = [
             None,
-            '',
-            'bob',
-            '1bob',
-            'b0b',
-            'bob123',
-            '12.321',
-            '.4',
-            '5.',
+            "",
+            "bob",
+            "1bob",
+            "b0b",
+            "bob123",
+            "12.321",
+            ".4",
+            "5.",
         ]
 
         for value in INVALID_VALUES:
@@ -86,126 +75,105 @@ class GetIntOr404Tests(TestCase):
                 get_int_or_404(value)
 
 
-@override_settings(ROOT_URLCONF='misago.core.testproject.urls')
+@override_settings(ROOT_URLCONF="misago.core.testproject.urls")
 class PaginatedResponseTests(TestCase):
     def test_page_response(self):
         """utility returns response for only page arg"""
-        response = self.client.get(reverse('test-paginated-response'))
+        response = self.client.get(reverse("test-paginated-response"))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            response.json(), {
-                'results': [i + 10 for i in range(10)],
-                'page': 2,
-                'pages': 10,
-                'count': 100,
-                'first': 1,
-                'previous': 1,
-                'next': 3,
-                'last': 10,
-                'before': 10,
-                'more': 80,
-            }
+            response.json(),
+            {
+                "results": [i + 10 for i in range(10)],
+                "page": 2,
+                "pages": 10,
+                "count": 100,
+                "first": 1,
+                "previous": 1,
+                "next": 3,
+                "last": 10,
+                "before": 10,
+                "more": 80,
+            },
         )
 
     def test_explicit_data_response(self):
         """utility returns response with explicit data"""
-        response = self.client.get(reverse('test-paginated-response-data'))
+        response = self.client.get(reverse("test-paginated-response-data"))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            response.json(), {
-                'results': ['a', 'b', 'c', 'd', 'e'],
-                'page': 2,
-                'pages': 10,
-                'count': 100,
-                'first': 1,
-                'previous': 1,
-                'next': 3,
-                'last': 10,
-                'before': 10,
-                'more': 80,
-            }
+            response.json(),
+            {
+                "results": ["a", "b", "c", "d", "e"],
+                "page": 2,
+                "pages": 10,
+                "count": 100,
+                "first": 1,
+                "previous": 1,
+                "next": 3,
+                "last": 10,
+                "before": 10,
+                "more": 80,
+            },
         )
 
     def test_explicit_serializer_response(self):
         """utility returns response with data serialized via serializer"""
-        response = self.client.get(reverse('test-paginated-response-serializer'))
+        response = self.client.get(reverse("test-paginated-response-serializer"))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            response.json(), {
-                'results': [
-                    {
-                        'id': 0
-                    },
-                    {
-                        'id': 2
-                    },
-                    {
-                        'id': 4
-                    },
-                    {
-                        'id': 6
-                    },
-                ],
-                'page': 1,
-                'pages': 1,
-                'count': 4,
-                'first': None,
-                'previous': None,
-                'next': None,
-                'last': None,
-                'before': 0,
-                'more': 0,
-            }
+            response.json(),
+            {
+                "results": [{"id": 0}, {"id": 2}, {"id": 4}, {"id": 6}],
+                "page": 1,
+                "pages": 1,
+                "count": 4,
+                "first": None,
+                "previous": None,
+                "next": None,
+                "last": None,
+                "before": 0,
+                "more": 0,
+            },
         )
 
     def test_explicit_data_serializer_response(self):
         """utility returns response with explicit data serialized via serializer"""
-        response = self.client.get(reverse('test-paginated-response-data-serializer'))
+        response = self.client.get(reverse("test-paginated-response-data-serializer"))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            response.json(), {
-                'results': [
-                    {
-                        'id': 'aa'
-                    },
-                    {
-                        'id': 'bb'
-                    },
-                    {
-                        'id': 'cc'
-                    },
-                    {
-                        'id': 'dd'
-                    },
-                ],
-                'page': 1,
-                'pages': 1,
-                'count': 4,
-                'first': None,
-                'previous': None,
-                'next': None,
-                'last': None,
-                'before': 0,
-                'more': 0,
-            }
+            response.json(),
+            {
+                "results": [{"id": "aa"}, {"id": "bb"}, {"id": "cc"}, {"id": "dd"}],
+                "page": 1,
+                "pages": 1,
+                "count": 4,
+                "first": None,
+                "previous": None,
+                "next": None,
+                "last": None,
+                "before": 0,
+                "more": 0,
+            },
         )
 
     def test_explicit_data_extra_response(self):
         """utility returns response with explicit data and extra"""
-        response = self.client.get(reverse('test-paginated-response-data-extra'))
+        response = self.client.get(reverse("test-paginated-response-data-extra"))
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            response.json(), {
-                'results': ['a', 'b', 'c', 'd'],
-                'page': 1,
-                'pages': 1,
-                'count': 4,
-                'first': None,
-                'previous': None,
-                'next': 'EXTRA',
-                'last': None,
-                'before': 0,
-                'more': 0,
-                'lorem': 'ipsum'
-            }
+            response.json(),
+            {
+                "results": ["a", "b", "c", "d"],
+                "page": 1,
+                "pages": 1,
+                "count": 4,
+                "first": None,
+                "previous": None,
+                "next": "EXTRA",
+                "last": None,
+                "before": 0,
+                "more": 0,
+                "lorem": "ipsum",
+            },
         )

+ 39 - 45
misago/core/tests/test_templatetags.py

@@ -5,28 +5,26 @@ from misago.core.templatetags import misago_batch
 from misago.core.templatetags.misago_absoluteurl import absoluteurl
 
 
-TEST_ADDRESS = 'https://testsite.com/'
+TEST_ADDRESS = "https://testsite.com/"
 
 
 class AbsoluteUrlTests(TestCase):
     @override_settings(MISAGO_ADDRESS=None)
     def test_address_is_none(self):
         """template tag returns null if address setting is not filled"""
-        result = absoluteurl('misago:index')
+        result = absoluteurl("misago:index")
         self.assertIsNone(result)
 
-    
     @override_settings(MISAGO_ADDRESS=TEST_ADDRESS)
     def test_prefix_url(self):
         """template tag prefixes already reversed url"""
-        result = absoluteurl('/')
+        result = absoluteurl("/")
         self.assertEqual(result, TEST_ADDRESS)
 
-    
     @override_settings(MISAGO_ADDRESS=TEST_ADDRESS)
     def test_prefix_url_name(self):
         """template tag reverses url name and prefixes it"""
-        result = absoluteurl('misago:index')
+        result = absoluteurl("misago:index")
         self.assertEqual(result, TEST_ADDRESS)
 
     @override_settings(MISAGO_ADDRESS=TEST_ADDRESS)
@@ -39,7 +37,7 @@ class AbsoluteUrlTests(TestCase):
 
 class CaptureTests(TestCase):
     def setUp(self):
-        self.context = Context({'unsafe_name': 'The<hr>Html'})
+        self.context = Context({"unsafe_name": "The<hr>Html"})
 
     def test_capture(self):
         """capture content to variable"""
@@ -54,8 +52,8 @@ Hello, <b>{{ the_var|safe }}</b>
 
         tpl = Template(tpl_content)
         render = tpl.render(self.context).strip()
-        self.assertIn('The&lt;hr&gt;Html', render)
-        self.assertNotIn('<b>The&lt;hr&gt;Html</b>', render)
+        self.assertIn("The&lt;hr&gt;Html", render)
+        self.assertNotIn("<b>The&lt;hr&gt;Html</b>", render)
 
     def test_capture_trimmed(self):
         """capture trimmed content to variable"""
@@ -70,32 +68,22 @@ Hello, <b>{{ the_var|safe }}</b>
 
         tpl = Template(tpl_content)
         render = tpl.render(self.context).strip()
-        self.assertIn('<b>The&lt;hr&gt;Html</b>', render)
+        self.assertIn("<b>The&lt;hr&gt;Html</b>", render)
 
 
 class BatchTests(TestCase):
     def test_batch(self):
         """standard batch yields valid results"""
-        batch = 'loremipsum'
-        yields = [
-            ['l', 'o', 'r'],
-            ['e', 'm', 'i'],
-            ['p', 's', 'u'],
-            ['m'],
-        ]
+        batch = "loremipsum"
+        yields = [["l", "o", "r"], ["e", "m", "i"], ["p", "s", "u"], ["m"]]
 
         for i, test_yield in enumerate(misago_batch.batch(batch, 3)):
             self.assertEqual(test_yield, yields[i])
 
     def test_batchnonefilled(self):
         """none-filled batch yields valid results"""
-        batch = 'loremipsum'
-        yields = [
-            ['l', 'o', 'r'],
-            ['e', 'm', 'i'],
-            ['p', 's', 'u'],
-            ['m', None, None],
-        ]
+        batch = "loremipsum"
+        yields = [["l", "o", "r"], ["e", "m", "i"], ["p", "s", "u"], ["m", None, None]]
 
         for i, test_yield in enumerate(misago_batch.batchnonefilled(batch, 3)):
             self.assertEqual(test_yield, yields[i])
@@ -118,7 +106,9 @@ class ShorthandsTests(TestCase):
 """
 
         tpl = Template(tpl_content)
-        self.assertEqual(tpl.render(Context({'result': 'Ok!', 'value': True})).strip(), 'Ok!')
+        self.assertEqual(
+            tpl.render(Context({"result": "Ok!", "value": True})).strip(), "Ok!"
+        )
 
     def test_iftrue_for_false(self):
         """iftrue isnt rendering value for false"""
@@ -129,7 +119,9 @@ class ShorthandsTests(TestCase):
 """
 
         tpl = Template(tpl_content)
-        self.assertEqual(tpl.render(Context({'result': 'Ok!', 'value': False})).strip(), '')
+        self.assertEqual(
+            tpl.render(Context({"result": "Ok!", "value": False})).strip(), ""
+        )
 
     def test_iffalse_for_true(self):
         """iffalse isnt rendering value for true"""
@@ -140,7 +132,9 @@ class ShorthandsTests(TestCase):
 """
 
         tpl = Template(tpl_content)
-        self.assertEqual(tpl.render(Context({'result': 'Ok!', 'value': True})).strip(), '')
+        self.assertEqual(
+            tpl.render(Context({"result": "Ok!", "value": True})).strip(), ""
+        )
 
     def test_iffalse_for_false(self):
         """iffalse renders value for false"""
@@ -151,7 +145,9 @@ class ShorthandsTests(TestCase):
 """
 
         tpl = Template(tpl_content)
-        self.assertEqual(tpl.render(Context({'result': 'Ok!', 'value': False})).strip(), 'Ok!')
+        self.assertEqual(
+            tpl.render(Context({"result": "Ok!", "value": False})).strip(), "Ok!"
+        )
 
 
 class JSONTests(TestCase):
@@ -165,11 +161,8 @@ class JSONTests(TestCase):
 
         tpl = Template(tpl_content)
         self.assertEqual(
-            tpl.render(Context({
-                'value': {
-                    'he</script>llo': 'bo"b!'
-                }
-            })).strip(), r'{"he\u003C/script>llo": "bo\"b!"}'
+            tpl.render(Context({"value": {"he</script>llo": 'bo"b!'}})).strip(),
+            r'{"he\u003C/script>llo": "bo\"b!"}',
         )
 
 
@@ -183,7 +176,9 @@ class PageTitleTests(TestCase):
         """
 
         tpl = Template(tpl_content)
-        self.assertEqual(tpl.render(Context({'item': 'Lorem Ipsum'})).strip(), 'Lorem Ipsum')
+        self.assertEqual(
+            tpl.render(Context({"item": "Lorem Ipsum"})).strip(), "Lorem Ipsum"
+        )
 
     def test_parent_title(self):
         """tag builds full title from title and parent name"""
@@ -195,10 +190,10 @@ class PageTitleTests(TestCase):
 
         tpl = Template(tpl_content)
         self.assertEqual(
-            tpl.render(Context({
-                'item': 'Lorem Ipsum',
-                'parent': 'Some Thread',
-            })).strip(), 'Lorem Ipsum | Some Thread'
+            tpl.render(
+                Context({"item": "Lorem Ipsum", "parent": "Some Thread"})
+            ).strip(),
+            "Lorem Ipsum | Some Thread",
         )
 
     def test_paged_title(self):
@@ -211,9 +206,8 @@ class PageTitleTests(TestCase):
 
         tpl = Template(tpl_content)
         self.assertEqual(
-            tpl.render(Context({
-                'item': 'Lorem Ipsum',
-            })).strip(), 'Lorem Ipsum (page: 3)'
+            tpl.render(Context({"item": "Lorem Ipsum"})).strip(),
+            "Lorem Ipsum (page: 3)",
         )
 
     def test_kitchensink_title(self):
@@ -226,8 +220,8 @@ class PageTitleTests(TestCase):
 
         tpl = Template(tpl_content)
         self.assertEqual(
-            tpl.render(Context({
-                'item': 'Lorem Ipsum',
-                'parent': 'Some Thread',
-            })).strip(), 'Lorem Ipsum (page: 3) | Some Thread'
+            tpl.render(
+                Context({"item": "Lorem Ipsum", "parent": "Some Thread"})
+            ).strip(),
+            "Lorem Ipsum (page: 3) | Some Thread",
         )

+ 135 - 116
misago/core/tests/test_utils.py

@@ -4,8 +4,16 @@ from django.test.client import RequestFactory
 from django.urls import reverse
 
 from misago.core.utils import (
-    clean_return_path, format_plaintext_for_html, is_referer_local, is_request_to_misago,
-    parse_iso8601_string, slugify, get_exception_message, clean_ids_list, get_host_from_address)
+    clean_return_path,
+    format_plaintext_for_html,
+    is_referer_local,
+    is_request_to_misago,
+    parse_iso8601_string,
+    slugify,
+    get_exception_message,
+    clean_ids_list,
+    get_host_from_address,
+)
 
 
 class IsRequestToMisagoTests(TestCase):
@@ -13,25 +21,25 @@ class IsRequestToMisagoTests(TestCase):
         """
         is_request_to_misago correctly detects requests directed at Misago
         """
-        VALID_PATHS = ('/', '/threads/', )
-        INVALID_PATHS = ('', 'somewhere/', )
+        VALID_PATHS = ("/", "/threads/")
+        INVALID_PATHS = ("", "somewhere/")
 
-        misago_prefix = reverse('misago:index')
+        misago_prefix = reverse("misago:index")
 
         for path in VALID_PATHS:
-            request = RequestFactory().get('/')
+            request = RequestFactory().get("/")
             request.path = path
             self.assertTrue(
                 is_request_to_misago(request),
-                '"%s" is not overlapped by "%s"' % (path, misago_prefix)
+                '"%s" is not overlapped by "%s"' % (path, misago_prefix),
             )
 
         for path in INVALID_PATHS:
-            request = RequestFactory().get('/')
+            request = RequestFactory().get("/")
             request.path = path
             self.assertFalse(
                 is_request_to_misago(request),
-                '"%s" is overlapped by "%s"' % (path, misago_prefix)
+                '"%s" is overlapped by "%s"' % (path, misago_prefix),
             )
 
 
@@ -39,13 +47,13 @@ class SlugifyTests(TestCase):
     def test_valid_slugify_output(self):
         """Misago's slugify correctly slugifies string"""
         test_cases = [
-            ('Bob', 'bob'),
-            ('Eric The Fish', 'eric-the-fish'),
-            ('John   Snow', 'john-snow'),
-            ('J0n', 'j0n'),
-            ('An###ne', 'anne'),
-            ('S**t', 'st'),
-            ('Łók', 'lok'),
+            ("Bob", "bob"),
+            ("Eric The Fish", "eric-the-fish"),
+            ("John   Snow", "john-snow"),
+            ("J0n", "j0n"),
+            ("An###ne", "anne"),
+            ("S**t", "st"),
+            ("Łók", "lok"),
         ]
 
         for original, slug in test_cases:
@@ -56,10 +64,10 @@ class ParseIso8601StringTests(TestCase):
     def test_valid_input(self):
         """util parses iso 8601 strings"""
         INPUTS = [
-            '2016-10-22T20:55:39.185085Z',
-            '2016-10-22T20:55:39.185085-01:00',
-            '2016-10-22T20:55:39-01:00',
-            '2016-10-22T20:55:39.185085+01:00',
+            "2016-10-22T20:55:39.185085Z",
+            "2016-10-22T20:55:39.185085-01:00",
+            "2016-10-22T20:55:39-01:00",
+            "2016-10-22T20:55:39.185085+01:00",
         ]
 
         for test_input in INPUTS:
@@ -68,10 +76,10 @@ class ParseIso8601StringTests(TestCase):
     def test_invalid_input(self):
         """util throws ValueError on invalid input"""
         INPUTS = [
-            '',
-            '2016-10-22',
-            '2016-10-22T30:55:39.185085+11:00',
-            '2016-10-22T20:55:39.18SSSSS5085Z',
+            "",
+            "2016-10-22",
+            "2016-10-22T30:55:39.185085+11:00",
+            "2016-10-22T20:55:39.18SSSSS5085Z",
         ]
 
         for test_input in INPUTS:
@@ -80,14 +88,14 @@ class ParseIso8601StringTests(TestCase):
 
 
 PLAINTEXT_FORMAT_CASES = [
-    ('Lorem ipsum.', '<p>Lorem ipsum.</p>'),
-    ('Lorem <b>ipsum</b>.', '<p>Lorem &lt;b&gt;ipsum&lt;/b&gt;.</p>'),
-    ('Lorem "ipsum" dolor met.', '<p>Lorem &quot;ipsum&quot; dolor met.</p>'),
-    ('Lorem ipsum.\nDolor met.', '<p>Lorem ipsum.<br />Dolor met.</p>'),
-    ('Lorem ipsum.\n\nDolor met.', '<p>Lorem ipsum.</p>\n\n<p>Dolor met.</p>'),
+    ("Lorem ipsum.", "<p>Lorem ipsum.</p>"),
+    ("Lorem <b>ipsum</b>.", "<p>Lorem &lt;b&gt;ipsum&lt;/b&gt;.</p>"),
+    ('Lorem "ipsum" dolor met.', "<p>Lorem &quot;ipsum&quot; dolor met.</p>"),
+    ("Lorem ipsum.\nDolor met.", "<p>Lorem ipsum.<br />Dolor met.</p>"),
+    ("Lorem ipsum.\n\nDolor met.", "<p>Lorem ipsum.</p>\n\n<p>Dolor met.</p>"),
     (
-        'http://misago-project.org/login/',
-        '<p><a href="http://misago-project.org/login/">http://misago-project.org/login/</a></p>'
+        "http://misago-project.org/login/",
+        '<p><a href="http://misago-project.org/login/">http://misago-project.org/login/</a></p>',
     ),
 ]
 
@@ -103,12 +111,15 @@ format_plaintext_for_html failed to produce expected output:
 
 expected:   %s
 return:     %s
-""" % (html, output)
+""" % (
+                html,
+                output,
+            )
             self.assertEqual(output, html, assertion_message)
 
 
 class MockRequest(object):
-    scheme = 'http'
+    scheme = "http"
 
     def __init__(self, method, meta=None, post=None):
         self.method = method
@@ -120,118 +131,126 @@ class CleanReturnPathTests(TestCase):
     def test_get_request(self):
         """clean_return_path works for GET requests"""
         bad_request = MockRequest(
-            'GET', {
-                'HTTP_REFERER': 'http://cookies.com',
-                'HTTP_HOST': 'misago-project.org',
-            }
+            "GET",
+            {"HTTP_REFERER": "http://cookies.com", "HTTP_HOST": "misago-project.org"},
         )
         self.assertIsNone(clean_return_path(bad_request))
 
         bad_request = MockRequest(
-            'GET', {
-                'HTTP_REFERER': 'https://misago-project.org/',
-                'HTTP_HOST': 'misago-project.org/',
-            }
+            "GET",
+            {
+                "HTTP_REFERER": "https://misago-project.org/",
+                "HTTP_HOST": "misago-project.org/",
+            },
         )
         self.assertIsNone(clean_return_path(bad_request))
 
         bad_request = MockRequest(
-            'GET', {
-                'HTTP_REFERER': 'https://misago-project.org/',
-                'HTTP_HOST': 'misago-project.org/assadsa/',
-            }
+            "GET",
+            {
+                "HTTP_REFERER": "https://misago-project.org/",
+                "HTTP_HOST": "misago-project.org/assadsa/",
+            },
         )
         self.assertIsNone(clean_return_path(bad_request))
 
         ok_request = MockRequest(
-            'GET', {
-                'HTTP_REFERER': 'http://misago-project.org/',
-                'HTTP_HOST': 'misago-project.org/',
-            }
+            "GET",
+            {
+                "HTTP_REFERER": "http://misago-project.org/",
+                "HTTP_HOST": "misago-project.org/",
+            },
         )
-        self.assertEqual(clean_return_path(ok_request), '/')
+        self.assertEqual(clean_return_path(ok_request), "/")
 
         ok_request = MockRequest(
-            'GET', {
-                'HTTP_REFERER': 'http://misago-project.org/login/',
-                'HTTP_HOST': 'misago-project.org/',
-            }
+            "GET",
+            {
+                "HTTP_REFERER": "http://misago-project.org/login/",
+                "HTTP_HOST": "misago-project.org/",
+            },
         )
-        self.assertEqual(clean_return_path(ok_request), '/login/')
+        self.assertEqual(clean_return_path(ok_request), "/login/")
 
     def test_post_request(self):
         """clean_return_path works for POST requests"""
         bad_request = MockRequest(
-            'POST', {
-                'HTTP_REFERER': 'http://misago-project.org/',
-                'HTTP_HOST': 'misago-project.org/',
-            }, {
-                'return_path': '/sdasdsa/',
-            }
+            "POST",
+            {
+                "HTTP_REFERER": "http://misago-project.org/",
+                "HTTP_HOST": "misago-project.org/",
+            },
+            {"return_path": "/sdasdsa/"},
         )
         self.assertIsNone(clean_return_path(bad_request))
 
         ok_request = MockRequest(
-            'POST', {
-                'HTTP_REFERER': 'http://misago-project.org/',
-                'HTTP_HOST': 'misago-project.org/',
-            }, {
-                'return_path': '/login/',
-            }
+            "POST",
+            {
+                "HTTP_REFERER": "http://misago-project.org/",
+                "HTTP_HOST": "misago-project.org/",
+            },
+            {"return_path": "/login/"},
         )
-        self.assertEqual(clean_return_path(ok_request), '/login/')
+        self.assertEqual(clean_return_path(ok_request), "/login/")
 
 
 class IsRefererLocalTests(TestCase):
     def test_local_referers(self):
         """local referers return true"""
         ok_request = MockRequest(
-            'GET', {
-                'HTTP_REFERER': 'http://misago-project.org/',
-                'HTTP_HOST': 'misago-project.org/',
-            }
+            "GET",
+            {
+                "HTTP_REFERER": "http://misago-project.org/",
+                "HTTP_HOST": "misago-project.org/",
+            },
         )
         self.assertTrue(is_referer_local(ok_request))
 
         ok_request = MockRequest(
-            'GET', {
-                'HTTP_REFERER': 'http://misago-project.org/',
-                'HTTP_HOST': 'misago-project.org/',
-            }
+            "GET",
+            {
+                "HTTP_REFERER": "http://misago-project.org/",
+                "HTTP_HOST": "misago-project.org/",
+            },
         )
         self.assertTrue(is_referer_local(ok_request))
 
         ok_request = MockRequest(
-            'GET', {
-                'HTTP_REFERER': 'http://misago-project.org/login/',
-                'HTTP_HOST': 'misago-project.org/',
-            }
+            "GET",
+            {
+                "HTTP_REFERER": "http://misago-project.org/login/",
+                "HTTP_HOST": "misago-project.org/",
+            },
         )
         self.assertTrue(is_referer_local(ok_request))
 
     def test_foreign_referers(self):
         """non-local referers return false"""
         bad_request = MockRequest(
-            'GET', {
-                'HTTP_REFERER': 'http://else-project.org/',
-                'HTTP_HOST': 'misago-project.org/',
-            }
+            "GET",
+            {
+                "HTTP_REFERER": "http://else-project.org/",
+                "HTTP_HOST": "misago-project.org/",
+            },
         )
         self.assertFalse(is_referer_local(bad_request))
 
         bad_request = MockRequest(
-            'GET', {
-                'HTTP_REFERER': 'https://misago-project.org/',
-                'HTTP_HOST': 'misago-project.org/',
-            }
+            "GET",
+            {
+                "HTTP_REFERER": "https://misago-project.org/",
+                "HTTP_HOST": "misago-project.org/",
+            },
         )
         self.assertFalse(is_referer_local(bad_request))
 
         bad_request = MockRequest(
-            'GET', {
-                'HTTP_REFERER': 'http://misago-project.org/',
-                'HTTP_HOST': 'misago-project.org/assadsa/',
-            }
+            "GET",
+            {
+                "HTTP_REFERER": "http://misago-project.org/",
+                "HTTP_HOST": "misago-project.org/assadsa/",
+            },
         )
         self.assertFalse(is_referer_local(bad_request))
 
@@ -244,28 +263,28 @@ class GetExceptionMessageTests(TestCase):
 
     def test_no_default_message(self):
         """helper's default message arg is optional"""
-        message = get_exception_message(PermissionDenied('Lorem Ipsum'))
-        self.assertEqual(message, 'Lorem Ipsum')
+        message = get_exception_message(PermissionDenied("Lorem Ipsum"))
+        self.assertEqual(message, "Lorem Ipsum")
 
         message = get_exception_message(PermissionDenied())
         self.assertIsNone(message)
 
     def test_default_message(self):
         """helper's default message arg is used"""
-        message = get_exception_message(PermissionDenied('Lorem Ipsum'), 'Default')
-        self.assertEqual(message, 'Lorem Ipsum')
+        message = get_exception_message(PermissionDenied("Lorem Ipsum"), "Default")
+        self.assertEqual(message, "Lorem Ipsum")
 
-        message = get_exception_message(PermissionDenied(), 'Default')
-        self.assertEqual(message, 'Default')
+        message = get_exception_message(PermissionDenied(), "Default")
+        self.assertEqual(message, "Default")
 
-        message = get_exception_message(default_message='Lorem Ipsum')
-        self.assertEqual(message, 'Lorem Ipsum')
+        message = get_exception_message(default_message="Lorem Ipsum")
+        self.assertEqual(message, "Lorem Ipsum")
 
 
 class CleanIdsListTests(TestCase):
     def test_valid_list(self):
         """list of valid ids is cleaned"""
-        self.assertEqual(clean_ids_list(['1', 3, '42'], None), [1, 3, 42])
+        self.assertEqual(clean_ids_list(["1", 3, "42"], None), [1, 3, 42])
 
     def test_empty_list(self):
         """empty list passes validation"""
@@ -273,7 +292,7 @@ class CleanIdsListTests(TestCase):
 
     def test_string_list(self):
         """string list passes validation"""
-        self.assertEqual(clean_ids_list('1234', None), [1, 2, 3, 4])
+        self.assertEqual(clean_ids_list("1234", None), [1, 2, 3, 4])
 
     def test_message(self):
         """utility uses passed message for exception"""
@@ -284,11 +303,11 @@ class CleanIdsListTests(TestCase):
         """utility raises exception for invalid inputs"""
         INVALID_INPUTS = (
             None,
-            'abc',
+            "abc",
             [None],
-            [1, 2, 'a', 4],
+            [1, 2, "a", 4],
             [1, None, 3],
-            {1: 2, 'a': 4},
+            {1: 2, "a": 4},
         )
 
         for invalid_input in INVALID_INPUTS:
@@ -304,25 +323,25 @@ class GetHostFromAddressTests(TestCase):
 
     def test_empty_string(self):
         """get_host_from_address returns None for empty string"""
-        result = get_host_from_address('')
+        result = get_host_from_address("")
         self.assertIsNone(result)
 
     def test_hostname(self):
         """get_host_from_address returns hostname unchanged"""
-        result = get_host_from_address('hostname')
-        self.assertEqual(result, 'hostname')
-        
+        result = get_host_from_address("hostname")
+        self.assertEqual(result, "hostname")
+
     def test_hostname_with_trailing_slash(self):
         """get_host_from_address returns hostname for hostname with trailing slash"""
-        result = get_host_from_address('//hostname')
-        self.assertEqual(result, 'hostname')
+        result = get_host_from_address("//hostname")
+        self.assertEqual(result, "hostname")
 
     def test_hostname_with_port(self):
         """get_host_from_address returns hostname for hostname with port"""
-        result = get_host_from_address('hostname:8888')
-        self.assertEqual(result, 'hostname')
-        
+        result = get_host_from_address("hostname:8888")
+        self.assertEqual(result, "hostname")
+
     def test_hostname_with_port_path_and_protocol(self):
         """get_host_from_address returns hostname for hostname with port and path"""
-        result = get_host_from_address('https://hostname:8888/somewhere/else/')
-        self.assertEqual(result, 'hostname')
+        result = get_host_from_address("https://hostname:8888/somewhere/else/")
+        self.assertEqual(result, "hostname")

+ 8 - 8
misago/core/tests/test_validators.py

@@ -20,19 +20,19 @@ class ValidateSluggableTests(TestCase):
         validator = validate_sluggable()
 
         with self.assertRaises(ValidationError):
-            validator('!#@! !@#@')
+            validator("!#@! !@#@")
         with self.assertRaises(ValidationError):
             validator(
-                '!#@! !@#@ 1234567890 1234567890 1234567890 1234567890'
-                '1234567890 1234567890 1234567890 1234567890 1234567890'
-                '1234567890 1234567890 1234567890 1234567890 1234567890'
-                '1234567890 1234567890 1234567890 1234567890 1234567890'
-                '1234567890 1234567890 1234567890 1234567890 1234567890'
+                "!#@! !@#@ 1234567890 1234567890 1234567890 1234567890"
+                "1234567890 1234567890 1234567890 1234567890 1234567890"
+                "1234567890 1234567890 1234567890 1234567890 1234567890"
+                "1234567890 1234567890 1234567890 1234567890 1234567890"
+                "1234567890 1234567890 1234567890 1234567890 1234567890"
             )
 
     def test_valid_input_validation(self):
         """valid values don't raise errors"""
         validator = validate_sluggable()
 
-        validator('Bob')
-        validator('Lorem ipsum123!')
+        validator("Bob")
+        validator("Lorem ipsum123!")

+ 7 - 7
misago/core/tests/test_views.py

@@ -5,21 +5,21 @@ from django.urls import reverse
 class CoreViewsTests(TestCase):
     def test_js_catalog_view_returns_200(self):
         """js catalog view has no show-stoppers"""
-        response = self.client.get('/django-i18n.js')
+        response = self.client.get("/django-i18n.js")
         self.assertEqual(response.status_code, 200)
 
     def test_robots_txt_returns_200(self):
         """robots.txt has no showstoppers"""
-        response = self.client.get('/robots.txt')
-        self.assertEqual(response['Content-type'], 'text/plain')
-        self.assertContains(response, '/api/')
+        response = self.client.get("/robots.txt")
+        self.assertEqual(response["Content-type"], "text/plain")
+        self.assertContains(response, "/api/")
 
 
-@override_settings(ROOT_URLCONF='misago.core.testproject.urls')
+@override_settings(ROOT_URLCONF="misago.core.testproject.urls")
 class RedirectViewTests(TestCase):
     def test_redirect_view(self):
         """redirect view always redirects to home page"""
-        response = self.client.get(reverse('test-redirect'))
+        response = self.client.get(reverse("test-redirect"))
 
         self.assertEqual(response.status_code, 302)
-        self.assertTrue(response['location'].endswith(reverse('misago:index')))
+        self.assertTrue(response["location"].endswith(reverse("misago:index")))

+ 31 - 32
misago/core/utils.py

@@ -9,7 +9,7 @@ from django.utils.encoding import force_text
 from django.utils.module_loading import import_string
 
 
-MISAGO_SLUGIFY = getattr(settings, 'MISAGO_SLUGIFY', 'misago.core.slugify.default')
+MISAGO_SLUGIFY = getattr(settings, "MISAGO_SLUGIFY", "misago.core.slugify.default")
 
 slugify = import_string(MISAGO_SLUGIFY)
 
@@ -19,15 +19,15 @@ def format_plaintext_for_html(string):
 
 
 def encode_json_html(string):
-    return string.replace('<', r'\u003C')
+    return string.replace("<", r"\u003C")
 
 
-ISO8601_FORMATS = ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", )
+ISO8601_FORMATS = ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f")
 
 
 def parse_iso8601_string(value):
     """turns ISO 8601 string into datetime object"""
-    value = force_text(value, strings_only=True).rstrip('Z')
+    value = force_text(value, strings_only=True).rstrip("Z")
 
     for format in ISO8601_FORMATS:
         try:
@@ -40,13 +40,13 @@ def parse_iso8601_string(value):
             except ValueError:
                 pass
     else:
-        raise ValueError('failed to hydrate the %s timestamp' % value)
+        raise ValueError("failed to hydrate the %s timestamp" % value)
 
     offset_str = value[-6:]
-    if offset_str and offset_str[0] in ('-', '+'):
+    if offset_str and offset_str[0] in ("-", "+"):
         tz_offset = timedelta(hours=int(offset_str[1:3]), minutes=int(offset_str[4:6]))
         tz_offset = tz_offset.seconds // 60
-        if offset_str[0] == '-':
+        if offset_str[0] == "-":
             tz_offset *= -1
     else:
         tz_offset = 0
@@ -61,23 +61,23 @@ def hide_post_parameters(request):
     We can't use decorator because of DRF uses custom HttpRequest
     that is incompatibile with Django's decorator
     """
-    request.sensitive_post_parameters = '__ALL__'
+    request.sensitive_post_parameters = "__ALL__"
 
 
 def clean_return_path(request):
     """return path utility that returns return path from referer or POST"""
-    if request.method == 'POST' and 'return_path' in request.POST:
+    if request.method == "POST" and "return_path" in request.POST:
         return _get_return_path_from_post(request)
     else:
         return _get_return_path_from_referer(request)
 
 
 def _get_return_path_from_post(request):
-    return_path = request.POST.get('return_path')
+    return_path = request.POST.get("return_path")
     try:
         if not return_path:
             raise ValueError()
-        if not return_path.startswith('/'):
+        if not return_path.startswith("/"):
             raise ValueError()
         resolve(return_path)
         return return_path
@@ -86,17 +86,17 @@ def _get_return_path_from_post(request):
 
 
 def _get_return_path_from_referer(request):
-    referer = request.META.get('HTTP_REFERER')
+    referer = request.META.get("HTTP_REFERER")
     try:
         if not referer:
             raise ValueError()
         if not referer.startswith(request.scheme):
             raise ValueError()
-        referer = referer[len(request.scheme) + 3:]
-        if not referer.startswith(request.META['HTTP_HOST']):
+        referer = referer[len(request.scheme) + 3 :]
+        if not referer.startswith(request.META["HTTP_HOST"]):
             raise ValueError()
-        referer = referer[len(request.META['HTTP_HOST'].rstrip('/')):]
-        if not referer.startswith('/'):
+        referer = referer[len(request.META["HTTP_HOST"].rstrip("/")) :]
+        if not referer.startswith("/"):
             raise ValueError()
         resolve(referer)
         return referer
@@ -114,26 +114,26 @@ def is_request_to_misago(request):
 
 def _is_request_path_under_misago(request):
     # We are assuming that forum_index link is root of all Misago links
-    forum_index = reverse('misago:index')
+    forum_index = reverse("misago:index")
     path = request.path
 
     if len(forum_index) > len(path):
         return False
-    return path[:len(forum_index)] == forum_index
+    return path[: len(forum_index)] == forum_index
 
 
 def is_referer_local(request):
-    referer = request.META.get('HTTP_REFERER')
+    referer = request.META.get("HTTP_REFERER")
 
     if not referer:
         return False
     if not referer.startswith(request.scheme):
         return False
-    referer = referer[len(request.scheme) + 3:]
-    if not referer.startswith(request.META['HTTP_HOST']):
+    referer = referer[len(request.scheme) + 3 :]
+    if not referer.startswith(request.META["HTTP_HOST"]):
         return False
-    referer = referer[len(request.META['HTTP_HOST'].rstrip('/')):]
-    if not referer.startswith('/'):
+    referer = referer[len(request.META["HTTP_HOST"].rstrip("/")) :]
+    if not referer.startswith("/"):
         return False
 
     return True
@@ -160,16 +160,15 @@ def get_host_from_address(address):
     if not address:
         return None
 
-    if address.lower().startswith('https://'):
+    if address.lower().startswith("https://"):
         address = address[8:]
-    if address.lower().startswith('http://'):
+    if address.lower().startswith("http://"):
         address = address[7:]
-    if address[0] == '/':
-        address = address.lstrip('/')
-    if '/' in address:
-        address = address.split('/')[0] or address
-    if ':' in address:
-        address = address.split(':')[0] or address
+    if address[0] == "/":
+        address = address.lstrip("/")
+    if "/" in address:
+        address = address.split("/")[0] or address
+    if ":" in address:
+        address = address.split(":")[0] or address
 
     return address
-    

+ 4 - 2
misago/core/validators.py

@@ -6,13 +6,15 @@ from .utils import slugify
 
 class validate_sluggable(object):
     def __init__(self, error_short=None, error_long=None):
-        self.error_short = error_short or _("Value has to contain alpha-numerical characters.")
+        self.error_short = error_short or _(
+            "Value has to contain alpha-numerical characters."
+        )
         self.error_long = error_long or _("Value is too long.")
 
     def __call__(self, value):
         slug = slugify(value)
 
-        if not slug.replace('-', ''):
+        if not slug.replace("-", ""):
             raise ValidationError(self.error_short)
 
         if len(slug) > 255:

+ 1 - 1
misago/core/views.py

@@ -6,4 +6,4 @@ def forum_index(request):
 
 
 def home_redirect(*args, **kwargs):
-    return redirect('misago:index')
+    return redirect("misago:index")

+ 1 - 1
misago/faker/__init__.py

@@ -1 +1 @@
-default_app_config = 'misago.faker.apps.MisagoFakerConfig'
+default_app_config = "misago.faker.apps.MisagoFakerConfig"

+ 2 - 2
misago/faker/apps.py

@@ -2,6 +2,6 @@ from django.apps import AppConfig
 
 
 class MisagoFakerConfig(AppConfig):
-    name = 'misago.faker'
-    label = 'misago_faker'
+    name = "misago.faker"
+    label = "misago_faker"
     verbose_name = "Misago Test Data Generator"

+ 2 - 2
misago/faker/englishcorpus.py

@@ -2,7 +2,7 @@ import os
 import random
 
 
-PHRASES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'phrases.txt')
+PHRASES_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "phrases.txt")
 
 
 class EnglishCorpus(object):
@@ -47,7 +47,7 @@ class EnglishCorpus(object):
         max_no = len(self) - no - 1
         start = random.randint(0, max_no)
 
-        sentences = self.phrases[start:(start + no)]
+        sentences = self.phrases[start : (start + no)]
         random.shuffle(sentences)
 
         return sentences

+ 26 - 26
misago/faker/management/commands/createfakebans.py

@@ -15,20 +15,20 @@ def fake_username_ban(fake):
     fake_value = fake.first_name()
 
     if random.randint(0, 100) < 31:
-        fake_value = '%s*' % fake_value
+        fake_value = "%s*" % fake_value
     elif random.randint(0, 100) < 31:
-        fake_value = '*%s' % fake_value
+        fake_value = "*%s" % fake_value
     elif random.randint(0, 100) < 31:
         fake_value = list(fake_value)
-        fake_value.insert(random.randint(0, len(fake_value) - 1), '*')
-        fake_value = ''.join(fake_value)
+        fake_value.insert(random.randint(0, len(fake_value) - 1), "*")
+        fake_value = "".join(fake_value)
 
     return fake_value
 
 
 def fake_email_ban(fake):
     if random.randint(0, 100) < 35:
-        return '*@%s' % fake.domain_name()
+        return "*@%s" % fake.domain_name()
     else:
         return fake.email()
 
@@ -37,32 +37,32 @@ def fake_ip_ban(fake):
     if random.randint(0, 1):
         fake_value = fake.ipv4()
         if random.randint(0, 100) < 35:
-            fake_value = fake_value.split('.')
-            fake_value = '.'.join(fake_value[:random.randint(1, 3)])
-            fake_value = '%s.*' % fake_value
+            fake_value = fake_value.split(".")
+            fake_value = ".".join(fake_value[: random.randint(1, 3)])
+            fake_value = "%s.*" % fake_value
         elif random.randint(0, 100) < 35:
-            fake_value = fake_value.split('.')
-            fake_value = '.'.join(fake_value[random.randint(1, 3):])
-            fake_value = '*.%s' % fake_value
+            fake_value = fake_value.split(".")
+            fake_value = ".".join(fake_value[random.randint(1, 3) :])
+            fake_value = "*.%s" % fake_value
         elif random.randint(0, 100) < 35:
-            fake_value = fake_value.split('.')
-            fake_value[random.randint(0, 3)] = '*'
-            fake_value = '.'.join(fake_value)
+            fake_value = fake_value.split(".")
+            fake_value[random.randint(0, 3)] = "*"
+            fake_value = ".".join(fake_value)
     else:
         fake_value = fake.ipv6()
 
         if random.randint(0, 100) < 35:
-            fake_value = fake_value.split(':')
-            fake_value = ':'.join(fake_value[:random.randint(1, 7)])
-            fake_value = '%s:*' % fake_value
+            fake_value = fake_value.split(":")
+            fake_value = ":".join(fake_value[: random.randint(1, 7)])
+            fake_value = "%s:*" % fake_value
         elif random.randint(0, 100) < 35:
-            fake_value = fake_value.split(':')
-            fake_value = ':'.join(fake_value[:random.randint(1, 7)])
-            fake_value = '*:%s' % fake_value
+            fake_value = fake_value.split(":")
+            fake_value = ":".join(fake_value[: random.randint(1, 7)])
+            fake_value = "*:%s" % fake_value
         elif random.randint(0, 100) < 35:
-            fake_value = fake_value.split(':')
-            fake_value[random.randint(0, 7)] = '*'
-            fake_value = ':'.join(fake_value)
+            fake_value = fake_value.split(":")
+            fake_value[random.randint(0, 7)] = "*"
+            fake_value = ":".join(fake_value)
 
     return fake_value
 
@@ -77,7 +77,7 @@ def create_fake_test(fake, test_type):
 
 
 class Command(BaseCommand):
-    help = 'Creates random fakey bans for testing purposes'
+    help = "Creates random fakey bans for testing purposes"
 
     def handle(self, *args, **options):
         try:
@@ -90,7 +90,7 @@ class Command(BaseCommand):
 
         fake = Factory.create()
 
-        message = 'Creating %s fake bans...\n'
+        message = "Creating %s fake bans...\n"
         self.stdout.write(message % fake_bans_to_create)
 
         created_count = 0
@@ -118,5 +118,5 @@ class Command(BaseCommand):
             created_count += 1
             show_progress(self, created_count, fake_bans_to_create)
 
-        message = '\n\nSuccessfully created %s fake bans'
+        message = "\n\nSuccessfully created %s fake bans"
         self.stdout.write(message % created_count)

+ 11 - 15
misago/faker/management/commands/createfakecategories.py

@@ -15,24 +15,24 @@ class Command(BaseCommand):
 
     def add_arguments(self, parser):
         parser.add_argument(
-            'categories',
+            "categories",
             help="number of categories to create",
-            nargs='?',
+            nargs="?",
             type=int,
             default=5,
         )
 
         parser.add_argument(
-            'minlevel',
+            "minlevel",
             help="min. level of created categories",
-            nargs='?',
+            nargs="?",
             type=int,
             default=0,
         )
 
     def handle(self, *args, **options):
-        items_to_create = options['categories']
-        min_level = options['minlevel']
+        items_to_create = options["categories"]
+        min_level = options["minlevel"]
 
         categories = Category.objects.all_categories(True)
 
@@ -41,7 +41,7 @@ class Command(BaseCommand):
         categories = categories.filter(level__gte=min_level)
         fake = Factory.create()
 
-        message = 'Creating %s fake categories...\n'
+        message = "Creating %s fake categories...\n"
         self.stdout.write(message % items_to_create)
 
         created_count = 0
@@ -59,15 +59,11 @@ class Command(BaseCommand):
 
             if random.randint(1, 100) > 50:
                 if random.randint(1, 100) > 80:
-                    new_category.description = '\r\n'.join(fake.paragraphs())
+                    new_category.description = "\r\n".join(fake.paragraphs())
                 else:
                     new_category.description = fake.paragraph()
 
-            new_category.insert_at(
-                parent,
-                position='last-child',
-                save=True,
-            )
+            new_category.insert_at(parent, position="last-child", save=True)
 
             copied_acls = []
             for acl in copy_acl_from.category_role_set.all():
@@ -88,6 +84,6 @@ class Command(BaseCommand):
         clear_acl_cache()
 
         total_time = time.time() - start_time
-        total_humanized = time.strftime('%H:%M:%S', time.gmtime(total_time))
-        message = '\n\nSuccessfully created %s fake categories in %s'
+        total_humanized = time.strftime("%H:%M:%S", time.gmtime(total_time))
+        message = "\n\nSuccessfully created %s fake categories in %s"
         self.stdout.write(message % (created_count, total_humanized))

+ 7 - 7
misago/faker/management/commands/createfakefollowers.py

@@ -11,12 +11,12 @@ UserModel = get_user_model()
 
 
 class Command(BaseCommand):
-    help = 'Adds random followers for testing purposes'
+    help = "Adds random followers for testing purposes"
 
     def handle(self, *args, **options):
         total_users = UserModel.objects.count()
 
-        message = 'Adding fake followers to %s users...\n'
+        message = "Adding fake followers to %s users...\n"
         self.stdout.write(message % total_users)
 
         total_followers = 0
@@ -33,7 +33,7 @@ class Command(BaseCommand):
                 continue  # 10% active users
 
             users_to_add = random.randint(1, total_users / 5)
-            random_queryset = UserModel.objects.exclude(id=user.id).order_by('?')
+            random_queryset = UserModel.objects.exclude(id=user.id).order_by("?")
             while users_to_add > 0:
                 new_follower = random_queryset[:1][0]
                 if not new_follower.is_following(user):
@@ -44,13 +44,13 @@ class Command(BaseCommand):
             processed_count += 1
             show_progress(self, processed_count, total_users)
 
-        self.stdout.write('\nSyncing models...')
+        self.stdout.write("\nSyncing models...")
         for user in UserModel.objects.iterator():
             user.followers = user.followed_by.count()
             user.following = user.follows.count()
-            user.save(update_fields=['followers', 'following'])
+            user.save(update_fields=["followers", "following"])
 
         total_time = time.time() - start_time
-        total_humanized = time.strftime('%H:%M:%S', time.gmtime(total_time))
-        message = '\nSuccessfully added %s fake followers in %s'
+        total_humanized = time.strftime("%H:%M:%S", time.gmtime(total_time))
+        message = "\nSuccessfully added %s fake followers in %s"
         self.stdout.write(message % (total_followers, total_humanized))

+ 22 - 22
misago/faker/management/commands/createfakethreads.py

@@ -15,7 +15,7 @@ from misago.threads.checksums import update_post_checksum
 from misago.threads.models import Post, Thread
 
 
-PLACEKITTEN_URL = 'https://placekitten.com/g/%s/%s'
+PLACEKITTEN_URL = "https://placekitten.com/g/%s/%s"
 
 UserModel = get_user_model()
 
@@ -24,25 +24,25 @@ corpus_short = EnglishCorpus(max_length=150)
 
 
 class Command(BaseCommand):
-    help = 'Creates random threads for dev and testing purposes.'
+    help = "Creates random threads for dev and testing purposes."
 
     def add_arguments(self, parser):
         parser.add_argument(
-            'threads',
+            "threads",
             help="number of threads to create",
-            nargs='?',
+            nargs="?",
             type=int,
             default=5,
         )
 
     def handle(self, *args, **options):
-        items_to_create = options['threads']
+        items_to_create = options["threads"]
 
         categories = list(Category.objects.all_categories())
 
         fake = Factory.create()
 
-        message = 'Creating %s fake threads...\n'
+        message = "Creating %s fake threads...\n"
         self.stdout.write(message % items_to_create)
 
         created_threads = 0
@@ -53,7 +53,7 @@ class Command(BaseCommand):
             with atomic():
                 datetime = timezone.now()
                 category = random.choice(categories)
-                user = UserModel.objects.order_by('?')[:1][0]
+                user = UserModel.objects.order_by("?")[:1][0]
 
                 thread_is_unapproved = random.randint(0, 100) > 90
                 thread_is_hidden = random.randint(0, 100) > 90
@@ -62,11 +62,11 @@ class Command(BaseCommand):
                 thread = Thread(
                     category=category,
                     started_on=datetime,
-                    starter_name='-',
-                    starter_slug='-',
+                    starter_name="-",
+                    starter_slug="-",
                     last_post_on=datetime,
-                    last_poster_name='-',
-                    last_poster_slug='-',
+                    last_poster_name="-",
+                    last_poster_slug="-",
                     replies=0,
                     is_unapproved=thread_is_unapproved,
                     is_hidden=thread_is_hidden,
@@ -88,7 +88,7 @@ class Command(BaseCommand):
                     updated_on=datetime,
                 )
                 update_post_checksum(post)
-                post.save(update_fields=['checksum'])
+                post.save(update_fields=["checksum"])
 
                 thread.set_first_post(post)
                 thread.set_last_post(post)
@@ -108,7 +108,7 @@ class Command(BaseCommand):
 
                 for _ in range(thread_replies):
                     datetime = timezone.now()
-                    user = UserModel.objects.order_by('?')[:1][0]
+                    user = UserModel.objects.order_by("?")[:1][0]
 
                     original, parsed = self.fake_post_content()
 
@@ -135,7 +135,7 @@ class Command(BaseCommand):
                         post.is_hidden = True
 
                         if random.randint(0, 100) < 80:
-                            user = UserModel.objects.order_by('?')[:1][0]
+                            user = UserModel.objects.order_by("?")[:1][0]
                             post.hidden_by = user
                             post.hidden_by_name = user.username
                             post.hidden_by_slug = user.username
@@ -156,10 +156,10 @@ class Command(BaseCommand):
                 show_progress(self, created_threads, items_to_create, start_time)
 
         pinned_threads = random.randint(0, int(created_threads * 0.025)) or 1
-        self.stdout.write('\nPinning %s threads...' % pinned_threads)
-        
+        self.stdout.write("\nPinning %s threads..." % pinned_threads)
+
         for _ in range(0, pinned_threads):
-            thread = Thread.objects.order_by('?')[:1][0]
+            thread = Thread.objects.order_by("?")[:1][0]
             if random.randint(0, 100) > 75:
                 thread.weight = 2
             else:
@@ -171,8 +171,8 @@ class Command(BaseCommand):
             category.save()
 
         total_time = time.time() - start_time
-        total_humanized = time.strftime('%H:%M:%S', time.gmtime(total_time))
-        message = '\nSuccessfully created %s fake threads in %s'
+        total_humanized = time.strftime("%H:%M:%S", time.gmtime(total_time))
+        message = "\nSuccessfully created %s fake threads in %s"
         self.stdout.write(message % (created_threads, total_humanized))
 
     def fake_post_content(self):
@@ -191,14 +191,14 @@ class Command(BaseCommand):
 
                 cat_url = PLACEKITTEN_URL % (cat_width, cat_height)
 
-                raw.append('!(%s)' % cat_url)
+                raw.append("!(%s)" % cat_url)
                 parsed.append('<p><img src="%s" alt=""/></p>' % cat_url)
             else:
                 if random.randint(0, 100) > 95:
                     sentences_to_make = random.randint(1, 20)
                 else:
                     sentences_to_make = random.randint(1, 7)
-                raw.append(' '.join(corpus.random_sentences(sentences_to_make)))
-                parsed.append('<p>%s</p>' % raw[-1])
+                raw.append(" ".join(corpus.random_sentences(sentences_to_make)))
+                parsed.append("<p>%s</p>" % raw[-1])
 
         return "\n\n".join(raw), "\n".join(parsed)

+ 8 - 12
misago/faker/management/commands/createfakeusers.py

@@ -21,21 +21,17 @@ class Command(BaseCommand):
 
     def add_arguments(self, parser):
         parser.add_argument(
-            'users',
-            help="number of users to create",
-            nargs='?',
-            type=int,
-            default=5,
+            "users", help="number of users to create", nargs="?", type=int, default=5
         )
 
     def handle(self, *args, **options):
-        items_to_create = options['users']
+        items_to_create = options["users"]
 
         fake = Factory.create()
 
         ranks = [r for r in Rank.objects.all()]
 
-        message = 'Creating %s fake user accounts...\n'
+        message = "Creating %s fake user accounts...\n"
         self.stdout.write(message % items_to_create)
 
         created_count = 0
@@ -47,20 +43,20 @@ class Command(BaseCommand):
                 possible_usernames = [
                     fake.first_name(),
                     fake.last_name(),
-                    fake.name().replace(' ', ''),
+                    fake.name().replace(" ", ""),
                     fake.user_name(),
                 ]
 
                 user = UserModel.objects.create_user(
                     random.choice(possible_usernames),
                     fake.email(),
-                    'pass123',
+                    "pass123",
                     set_default_avatar=False,
                     rank=random.choice(ranks),
                 )
 
                 dynamic.set_avatar(user)
-                user.save(update_fields=['avatars'])
+                user.save(update_fields=["avatars"])
             except (ValidationError, IntegrityError):
                 pass
             else:
@@ -68,6 +64,6 @@ class Command(BaseCommand):
                 show_progress(self, created_count, items_to_create, start_time)
 
         total_time = time.time() - start_time
-        total_humanized = time.strftime('%H:%M:%S', time.gmtime(total_time))
-        message = '\n\nSuccessfully created %s fake user accounts in %s'
+        total_humanized = time.strftime("%H:%M:%S", time.gmtime(total_time))
+        message = "\n\nSuccessfully created %s fake user accounts in %s"
         self.stdout.write(message % (created_count, total_humanized))

+ 1 - 1
misago/legal/__init__.py

@@ -1 +1 @@
-default_app_config = 'misago.legal.apps.MisagoLegalConfig'
+default_app_config = "misago.legal.apps.MisagoLegalConfig"

+ 24 - 16
misago/legal/admin.py

@@ -2,30 +2,38 @@ from django.conf.urls import url
 from django.utils.translation import gettext_lazy as _
 
 from .views.admin import (
-    AgreementsList, DeleteAgreement, EditAgreement, NewAgreement, SetAgreementAsActive
+    AgreementsList,
+    DeleteAgreement,
+    EditAgreement,
+    NewAgreement,
+    SetAgreementAsActive,
 )
 
 
 class MisagoAdminExtension(object):
     def register_urlpatterns(self, urlpatterns):
         # Legal Agreements
-        urlpatterns.namespace(r'^agreements/', 'agreements', 'users')
+        urlpatterns.namespace(r"^agreements/", "agreements", "users")
         urlpatterns.patterns(
-            'users:agreements',
-            url(r'^$', AgreementsList.as_view(), name='index'),
-            url(r'^(?P<page>\d+)/$', AgreementsList.as_view(), name='index'),
-            url(r'^new/$', NewAgreement.as_view(), name='new'),
-            url(r'^edit/(?P<pk>\d+)/$', EditAgreement.as_view(), name='edit'),
-            url(r'^delete/(?P<pk>\d+)/$', DeleteAgreement.as_view(), name='delete'),
-            url(r'^set-as-active/(?P<pk>\d+)/$', SetAgreementAsActive.as_view(), name='set-as-active'),
+            "users:agreements",
+            url(r"^$", AgreementsList.as_view(), name="index"),
+            url(r"^(?P<page>\d+)/$", AgreementsList.as_view(), name="index"),
+            url(r"^new/$", NewAgreement.as_view(), name="new"),
+            url(r"^edit/(?P<pk>\d+)/$", EditAgreement.as_view(), name="edit"),
+            url(r"^delete/(?P<pk>\d+)/$", DeleteAgreement.as_view(), name="delete"),
+            url(
+                r"^set-as-active/(?P<pk>\d+)/$",
+                SetAgreementAsActive.as_view(),
+                name="set-as-active",
+            ),
         )
-        
+
     def register_navigation_nodes(self, site):
         site.add_node(
             name=_("Agreements"),
-            icon='fa fa-check-square-o',
-            parent='misago:admin:users',
-            after='misago:admin:users:data-downloads:index',
-            namespace='misago:admin:users:agreements',
-            link='misago:admin:users:agreements:index',
-        )
+            icon="fa fa-check-square-o",
+            parent="misago:admin:users",
+            after="misago:admin:users:data-downloads:index",
+            namespace="misago:admin:users:agreements",
+            link="misago:admin:users:agreements:index",
+        )

+ 4 - 4
misago/legal/api.py

@@ -10,20 +10,20 @@ from .models import Agreement
 from .utils import save_user_agreement_acceptance
 
 
-@api_view(['POST'])
+@api_view(["POST"])
 def submit_agreement(request, pk):
     agreement = get_object_or_404(Agreement, is_active=True, pk=pk)
 
     if agreement.id in request.user.agreements:
         raise PermissionDenied(_("You have already accepted this agreement."))
 
-    if request.data.get('accept') is True:
+    if request.data.get("accept") is True:
         save_user_agreement_acceptance(request.user, agreement, commit=True)
-    elif request.data.get('accept') is False:
+    elif request.data.get("accept") is False:
         if not request.user.is_staff:
             request.user.mark_for_delete()
             logout(request)
     else:
         raise PermissionDenied(_("You need to submit a valid choice."))
 
-    return Response({'detail': 'ok'})
+    return Response({"detail": "ok"})

+ 2 - 2
misago/legal/apps.py

@@ -2,8 +2,8 @@ from django.apps import AppConfig
 
 
 class MisagoLegalConfig(AppConfig):
-    name = 'misago.legal'
-    label = 'misago_legal'
+    name = "misago.legal"
+    label = "misago_legal"
     verbose_name = "Misago Legal"
 
     def ready(self):

+ 24 - 23
misago/legal/context_processors.py

@@ -9,42 +9,43 @@ def legal_links(request):
     agreements = Agreement.objects.get_agreements()
 
     legal_context = {
-        'TERMS_OF_SERVICE_ID': None,
-        'TERMS_OF_SERVICE_URL': None,
-        'PRIVACY_POLICY_ID': None,
-        'PRIVACY_POLICY_URL': None,
-        'misago_agreement': None,
+        "TERMS_OF_SERVICE_ID": None,
+        "TERMS_OF_SERVICE_URL": None,
+        "PRIVACY_POLICY_ID": None,
+        "PRIVACY_POLICY_URL": None,
+        "misago_agreement": None,
     }
 
     terms_of_service = agreements.get(Agreement.TYPE_TOS)
     if terms_of_service:
-        legal_context['TERMS_OF_SERVICE_ID'] = terms_of_service['id']
-        if terms_of_service['link']:
-            legal_context['TERMS_OF_SERVICE_URL'] = terms_of_service['link']
-        elif terms_of_service['text']:
-            legal_context['TERMS_OF_SERVICE_URL'] = reverse('misago:terms-of-service')
+        legal_context["TERMS_OF_SERVICE_ID"] = terms_of_service["id"]
+        if terms_of_service["link"]:
+            legal_context["TERMS_OF_SERVICE_URL"] = terms_of_service["link"]
+        elif terms_of_service["text"]:
+            legal_context["TERMS_OF_SERVICE_URL"] = reverse("misago:terms-of-service")
 
     privacy_policy = agreements.get(Agreement.TYPE_PRIVACY)
     if privacy_policy:
-        legal_context['PRIVACY_POLICY_ID'] = privacy_policy['id']
-        if privacy_policy['link']:
-            legal_context['PRIVACY_POLICY_URL'] = privacy_policy['link']
-        elif privacy_policy['text']:
-            legal_context['PRIVACY_POLICY_URL'] = reverse('misago:privacy-policy')
+        legal_context["PRIVACY_POLICY_ID"] = privacy_policy["id"]
+        if privacy_policy["link"]:
+            legal_context["PRIVACY_POLICY_URL"] = privacy_policy["link"]
+        elif privacy_policy["text"]:
+            legal_context["PRIVACY_POLICY_URL"] = reverse("misago:privacy-policy")
 
     if legal_context:
         request.frontend_context.update(legal_context)
 
     required_agreement = get_required_user_agreement(request.user, agreements)
     if required_agreement:
-        request.frontend_context['REQUIRED_AGREEMENT_API'] = reverse(
-            'misago:api:submit-agreement', kwargs={'pk': required_agreement.pk})
-
-        legal_context['misago_agreement'] = {
-            'type': required_agreement.get_type_display(),
-            'title': required_agreement.get_final_title(),
-            'link': required_agreement.link,
-            'text': get_parsed_agreement_text(request, required_agreement)
+        request.frontend_context["REQUIRED_AGREEMENT_API"] = reverse(
+            "misago:api:submit-agreement", kwargs={"pk": required_agreement.pk}
+        )
+
+        legal_context["misago_agreement"] = {
+            "type": required_agreement.get_type_display(),
+            "title": required_agreement.get_final_title(),
+            "link": required_agreement.link,
+            "text": get_parsed_agreement_text(request, required_agreement),
         }
 
     return legal_context

+ 12 - 15
misago/legal/forms.py

@@ -25,7 +25,9 @@ class AgreementForm(forms.ModelForm):
     )
     link = forms.URLField(
         label=_("Link"),
-        help_text=_("If your agreement is located on other page, enter here a link to it."),
+        help_text=_(
+            "If your agreement is located on other page, enter here a link to it."
+        ),
         required=False,
     )
     text = forms.CharField(
@@ -37,12 +39,12 @@ class AgreementForm(forms.ModelForm):
 
     class Meta:
         model = Agreement
-        fields = ['type', 'title', 'link', 'text', 'is_active']
+        fields = ["type", "title", "link", "text", "is_active"]
 
     def clean(self):
         data = super().clean()
 
-        if not data.get('link') and not data.get('text'):
+        if not data.get("link") and not data.get("text"):
             raise forms.ValidationError(_("Please fill in agreement link or text."))
 
         return data
@@ -57,23 +59,18 @@ class AgreementForm(forms.ModelForm):
 
 class SearchAgreementsForm(forms.Form):
     type = forms.MultipleChoiceField(
-        label=_("Type"),
-        required=False,
-        choices=Agreement.TYPE_CHOICES,
-    )
-    content = forms.CharField(
-        label=_("Content"),
-        required=False,
+        label=_("Type"), required=False, choices=Agreement.TYPE_CHOICES
     )
+    content = forms.CharField(label=_("Content"), required=False)
 
     def filter_queryset(self, search_criteria, queryset):
         criteria = search_criteria
-        if criteria.get('type') is not None:
-            queryset = queryset.filter(type__in=criteria['type'])
+        if criteria.get("type") is not None:
+            queryset = queryset.filter(type__in=criteria["type"])
 
-        if criteria.get('content'):
-            search_title = Q(title__icontains=criteria['content'])
-            search_text = Q(text__icontains=criteria['content'])
+        if criteria.get("content"):
+            search_title = Q(title__icontains=criteria["content"])
+            search_text = Q(text__icontains=criteria["content"])
             queryset = queryset.filter(search_title | search_text)
 
         return queryset

+ 63 - 82
misago/legal/migrations/0001_initial.py

@@ -8,107 +8,92 @@ _ = lambda s: s
 
 def create_legal_settings_group(apps, schema_editor):
     migrate_settings_group(
-        apps, {
-            'key': 'legal',
-            'name': _("Legal information"),
-            'description': _("Those settings allow you to set forum terms of service and privacy policy."),
-            'settings': [
+        apps,
+        {
+            "key": "legal",
+            "name": _("Legal information"),
+            "description": _(
+                "Those settings allow you to set forum terms of service and privacy policy."
+            ),
+            "settings": [
                 {
-                    'setting': 'terms_of_service_title',
-                    'name': _("Terms title"),
-                    'legend': _("Terms of Service"),
-                    'description': _("Leave this field empty to use default title."),
-                    'value': "",
-                    'field_extra': {
-                        'max_length': 255,
-                        'required': False,
-                    },
-                    'is_public': True,
+                    "setting": "terms_of_service_title",
+                    "name": _("Terms title"),
+                    "legend": _("Terms of Service"),
+                    "description": _("Leave this field empty to use default title."),
+                    "value": "",
+                    "field_extra": {"max_length": 255, "required": False},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'terms_of_service_link',
-                    'name': _("Terms link"),
-                    'description': _("If terms of service are located on other page, enter there its link."),
-                    'value': "",
-                    'field_extra': {
-                        'max_length': 255,
-                        'required': False,
-                    },
-                    'is_public': True,
+                    "setting": "terms_of_service_link",
+                    "name": _("Terms link"),
+                    "description": _(
+                        "If terms of service are located on other page, enter there its link."
+                    ),
+                    "value": "",
+                    "field_extra": {"max_length": 255, "required": False},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'terms_of_service',
-                    'name': _("Terms contents"),
-                    'description': _(
+                    "setting": "terms_of_service",
+                    "name": _("Terms contents"),
+                    "description": _(
                         "Your forums can have custom terms of "
                         "service page. To create it, write or "
                         "paste here its contents. Full Misago "
                         "markup is available for formatting."
                     ),
-                    'value': "",
-                    'form_field': 'textarea',
-                    'field_extra': {
-                        'max_length': 128000,
-                        'required': False,
-                        'rows': 8,
-                    },
-                    'is_public': True,
-                    'is_lazy': True,
+                    "value": "",
+                    "form_field": "textarea",
+                    "field_extra": {"max_length": 128000, "required": False, "rows": 8},
+                    "is_public": True,
+                    "is_lazy": True,
                 },
                 {
-                    'setting': 'privacy_policy_title',
-                    'name': _("Policy title"),
-                    'legend': _("Privacy policy"),
-                    'description': _("Leave this field empty to use default title."),
-                    'value': "",
-                    'field_extra': {
-                        'max_length': 255,
-                        'required': False,
-                    },
-                    'is_public': True,
+                    "setting": "privacy_policy_title",
+                    "name": _("Policy title"),
+                    "legend": _("Privacy policy"),
+                    "description": _("Leave this field empty to use default title."),
+                    "value": "",
+                    "field_extra": {"max_length": 255, "required": False},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'privacy_policy_link',
-                    'name': _("Policy link"),
-                    'description': _("If privacy policy is located on other page, enter there its link."),
-                    'value': "",
-                    'field_extra': {
-                        'max_length': 255,
-                        'required': False,
-                    },
-                    'is_public': True,
+                    "setting": "privacy_policy_link",
+                    "name": _("Policy link"),
+                    "description": _(
+                        "If privacy policy is located on other page, enter there its link."
+                    ),
+                    "value": "",
+                    "field_extra": {"max_length": 255, "required": False},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'privacy_policy',
-                    'name': _("Policy contents"),
-                    'description': _(
+                    "setting": "privacy_policy",
+                    "name": _("Policy contents"),
+                    "description": _(
                         "Your forums can have custom privacy "
                         "policy page. To create it, write or "
                         "paste here its contents. Full Misago "
                         "markup is available for formatting."
                     ),
-                    'value': "",
-                    'form_field': 'textarea',
-                    'field_extra': {
-                        'max_length': 128000,
-                        'required': False,
-                        'rows': 8,
-                    },
-                    'is_public': True,
-                    'is_lazy': True,
+                    "value": "",
+                    "form_field": "textarea",
+                    "field_extra": {"max_length": 128000, "required": False, "rows": 8},
+                    "is_public": True,
+                    "is_lazy": True,
                 },
                 {
-                    'setting': 'forum_footnote',
-                    'name': _("Footnote"),
-                    'description': _("Short message displayed in forum footer."),
-                    'legend': _("Forum footer"),
-                    'field_extra': {
-                        'max_length': 300,
-                    },
-                    'is_public': True,
+                    "setting": "forum_footnote",
+                    "name": _("Footnote"),
+                    "description": _("Short message displayed in forum footer."),
+                    "legend": _("Forum footer"),
+                    "field_extra": {"max_length": 300},
+                    "is_public": True,
                 },
             ],
-        }
+        },
     )
 
 
@@ -116,10 +101,6 @@ class Migration(migrations.Migration):
 
     initial = True
 
-    dependencies = [
-        ('misago_conf', '0001_initial'),
-    ]
+    dependencies = [("misago_conf", "0001_initial")]
 
-    operations = [
-        migrations.RunPython(create_legal_settings_group),
-    ]
+    operations = [migrations.RunPython(create_legal_settings_group)]

+ 87 - 22
misago/legal/migrations/0002_agreement_useragreement.py

@@ -11,37 +11,102 @@ class Migration(migrations.Migration):
 
     dependencies = [
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-        ('misago_legal', '0001_initial'),
+        ("misago_legal", "0001_initial"),
     ]
 
     operations = [
         migrations.CreateModel(
-            name='Agreement',
+            name="Agreement",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('type', models.CharField(choices=[('terms_of_service', 'Terms of service'), ('privacy_policy', 'Privacy policy')], db_index=True, default='terms_of_service', max_length=20)),
-                ('title', models.CharField(blank=True, max_length=255, null=True)),
-                ('link', models.URLField(blank=True, max_length=255, null=True)),
-                ('text', models.TextField(blank=True, null=True)),
-                ('is_active', models.BooleanField(default=False)),
-                ('created_on', models.DateTimeField(default=django.utils.timezone.now)),
-                ('created_by_name', models.CharField(blank=True, max_length=255, null=True)),
-                ('last_modified_on', models.DateTimeField(blank=True, null=True)),
-                ('last_modified_by_name', models.CharField(blank=True, max_length=255, null=True)),
-                ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
-                ('last_modified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "type",
+                    models.CharField(
+                        choices=[
+                            ("terms_of_service", "Terms of service"),
+                            ("privacy_policy", "Privacy policy"),
+                        ],
+                        db_index=True,
+                        default="terms_of_service",
+                        max_length=20,
+                    ),
+                ),
+                ("title", models.CharField(blank=True, max_length=255, null=True)),
+                ("link", models.URLField(blank=True, max_length=255, null=True)),
+                ("text", models.TextField(blank=True, null=True)),
+                ("is_active", models.BooleanField(default=False)),
+                ("created_on", models.DateTimeField(default=django.utils.timezone.now)),
+                (
+                    "created_by_name",
+                    models.CharField(blank=True, max_length=255, null=True),
+                ),
+                ("last_modified_on", models.DateTimeField(blank=True, null=True)),
+                (
+                    "last_modified_by_name",
+                    models.CharField(blank=True, max_length=255, null=True),
+                ),
+                (
+                    "created_by",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+                (
+                    "last_modified_by",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
             ],
         ),
         migrations.CreateModel(
-            name='UserAgreement',
+            name="UserAgreement",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('accepted_on', models.DateTimeField(default=django.utils.timezone.now)),
-                ('agreement', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accepted_by', to='misago_legal.Agreement')),
-                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "accepted_on",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                (
+                    "agreement",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name="accepted_by",
+                        to="misago_legal.Agreement",
+                    ),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
             ],
-            options={
-                'ordering': ['-pk'],
-            },
+            options={"ordering": ["-pk"]},
         ),
     ]

+ 34 - 35
misago/legal/migrations/0003_create_agreements_from_settings.py

@@ -9,38 +9,38 @@ _ = lambda s: s
 
 
 LEGAL_SETTINGS = [
-    'terms_of_service_title',
-    'terms_of_service_link',
-    'terms_of_service',
-    'privacy_policy_title',
-    'privacy_policy_link',
-    'privacy_policy',
+    "terms_of_service_title",
+    "terms_of_service_link",
+    "terms_of_service",
+    "privacy_policy_title",
+    "privacy_policy_link",
+    "privacy_policy",
 ]
 
 
 def create_legal_settings_group(apps, schema_editor):
-    Agreement = apps.get_model('misago_legal', 'Agreement')
-    Setting = apps.get_model('misago_conf', 'Setting')
-    
+    Agreement = apps.get_model("misago_legal", "Agreement")
+    Setting = apps.get_model("misago_conf", "Setting")
+
     legal_conf = {}
     for setting in Setting.objects.filter(setting__in=LEGAL_SETTINGS):
         legal_conf[setting.setting] = setting.dry_value
 
-    if legal_conf['terms_of_service'] or legal_conf['terms_of_service_link']:
+    if legal_conf["terms_of_service"] or legal_conf["terms_of_service_link"]:
         Agreement.objects.create(
             type=MisagoAgreement.TYPE_TOS,
-            title=legal_conf['terms_of_service_title'],
-            link=legal_conf['terms_of_service_link'],
-            text=legal_conf['terms_of_service'],
+            title=legal_conf["terms_of_service_title"],
+            link=legal_conf["terms_of_service_link"],
+            text=legal_conf["terms_of_service"],
             is_active=True,
         )
 
-    if legal_conf['privacy_policy'] or legal_conf['privacy_policy_link']:
+    if legal_conf["privacy_policy"] or legal_conf["privacy_policy_link"]:
         Agreement.objects.create(
             type=MisagoAgreement.TYPE_PRIVACY,
-            title=legal_conf['privacy_policy_title'],
-            link=legal_conf['privacy_policy_link'],
-            text=legal_conf['privacy_policy'],
+            title=legal_conf["privacy_policy_title"],
+            link=legal_conf["privacy_policy_link"],
+            text=legal_conf["privacy_policy"],
             is_active=True,
         )
 
@@ -49,31 +49,30 @@ def create_legal_settings_group(apps, schema_editor):
 
 def delete_deprecated_settings(apps, schema_editor):
     migrate_settings_group(
-        apps, {
-            'key': 'legal',
-            'name': _("Legal information"),
-            'description': _("Those settings allow you to set additional legal information for your forum."),
-            'settings': [
+        apps,
+        {
+            "key": "legal",
+            "name": _("Legal information"),
+            "description": _(
+                "Those settings allow you to set additional legal information for your forum."
+            ),
+            "settings": [
                 {
-                    'setting': 'forum_footnote',
-                    'name': _("Footnote"),
-                    'description': _("Short message displayed in forum footer."),
-                    'legend': _("Forum footer"),
-                    'field_extra': {
-                        'max_length': 300,
-                    },
-                    'is_public': True,
-                },
+                    "setting": "forum_footnote",
+                    "name": _("Footnote"),
+                    "description": _("Short message displayed in forum footer."),
+                    "legend": _("Forum footer"),
+                    "field_extra": {"max_length": 300},
+                    "is_public": True,
+                }
             ],
-        }
+        },
     )
 
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_legal', '0002_agreement_useragreement'),
-    ]
+    dependencies = [("misago_legal", "0002_agreement_useragreement")]
 
     operations = [
         migrations.RunPython(create_legal_settings_group),

+ 16 - 19
misago/legal/models.py

@@ -6,7 +6,7 @@ from misago.conf import settings
 from misago.core.cache import cache
 
 
-CACHE_KEY = 'misago_agreements'
+CACHE_KEY = "misago_agreements"
 
 
 class AgreementManager(models.Manager):
@@ -15,39 +15,36 @@ class AgreementManager(models.Manager):
 
     def get_agreements(self):
         agreements = self.get_agreements_from_cache()
-        if agreements == 'nada':
+        if agreements == "nada":
             agreements = self.get_agreements_from_db()
             cache.set(CACHE_KEY, agreements)
         return agreements
 
     def get_agreements_from_cache(self):
-        return cache.get(CACHE_KEY, 'nada')
+        return cache.get(CACHE_KEY, "nada")
 
     def get_agreements_from_db(self):
         agreements = {}
         for agreement in Agreement.objects.filter(is_active=True):
             agreements[agreement.type] = {
-                'id': agreement.id,
-                'title': agreement.get_final_title(),
-                'link': agreement.link,
-                'text': bool(agreement.text),
+                "id": agreement.id,
+                "title": agreement.get_final_title(),
+                "link": agreement.link,
+                "text": bool(agreement.text),
             }
         return agreements
 
 
 class Agreement(models.Model):
-    TYPE_TOS = 'terms_of_service'
-    TYPE_PRIVACY = 'privacy_policy'
+    TYPE_TOS = "terms_of_service"
+    TYPE_PRIVACY = "privacy_policy"
     TYPE_CHOICES = [
-        (TYPE_TOS, _('Terms of service')),
-        (TYPE_PRIVACY, _('Privacy policy')),
+        (TYPE_TOS, _("Terms of service")),
+        (TYPE_PRIVACY, _("Privacy policy")),
     ]
 
     type = models.CharField(
-        max_length=20,
-        default=TYPE_TOS,
-        choices=TYPE_CHOICES,
-        db_index=True,
+        max_length=20, default=TYPE_TOS, choices=TYPE_CHOICES, db_index=True
     )
     title = models.CharField(max_length=255, null=True, blank=True)
     link = models.URLField(max_length=255, null=True, blank=True)
@@ -59,7 +56,7 @@ class Agreement(models.Model):
         on_delete=models.SET_NULL,
         blank=True,
         null=True,
-        related_name='+',
+        related_name="+",
     )
     created_by_name = models.CharField(max_length=255, null=True, blank=True)
     last_modified_on = models.DateTimeField(null=True, blank=True)
@@ -68,7 +65,7 @@ class Agreement(models.Model):
         on_delete=models.SET_NULL,
         blank=True,
         null=True,
-        related_name='+',
+        related_name="+",
     )
     last_modified_by_name = models.CharField(max_length=255, null=True, blank=True)
 
@@ -88,8 +85,8 @@ class Agreement(models.Model):
 
 class UserAgreement(models.Model):
     user = models.ForeignKey(settings.AUTH_USER_MODEL)
-    agreement = models.ForeignKey(Agreement, related_name='accepted_by')
+    agreement = models.ForeignKey(Agreement, related_name="accepted_by")
     accepted_on = models.DateTimeField(default=timezone.now)
 
     class Meta:
-        ordering = ["-pk"]
+        ordering = ["-pk"]

+ 3 - 5
misago/legal/signals.py

@@ -7,10 +7,8 @@ from .models import Agreement
 
 @receiver([anonymize_user_data, username_changed])
 def update_usernames(sender, **kwargs):
-    Agreement.objects.filter(created_by=sender).update(
-        created_by_name=sender.username,
-    )
+    Agreement.objects.filter(created_by=sender).update(created_by_name=sender.username)
 
     Agreement.objects.filter(last_modified_by=sender).update(
-        last_modified_by_name=sender.username,
-    )
+        last_modified_by_name=sender.username
+    )

+ 102 - 121
misago/legal/tests/test_admin_views.py

@@ -7,28 +7,25 @@ from misago.legal.models import Agreement
 class AgreementAdminViewsTests(AdminTestCase):
     def test_link_registered(self):
         """admin nav contains agreements link"""
-        response = self.client.get(reverse('misago:admin:users:accounts:index'))
+        response = self.client.get(reverse("misago:admin:users:accounts:index"))
 
-        response = self.client.get(response['location'])
-        self.assertContains(response, reverse('misago:admin:users:agreements:index'))
+        response = self.client.get(response["location"])
+        self.assertContains(response, reverse("misago:admin:users:agreements:index"))
 
     def test_list_view(self):
         """agreements list view returns 200"""
-        response = self.client.get(reverse('misago:admin:users:agreements:index'))
+        response = self.client.get(reverse("misago:admin:users:agreements:index"))
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertEqual(response.status_code, 200)
 
     def test_mass_delete(self):
         """adminview deletes multiple agreements"""
         for i in range(10):
             response = self.client.post(
-                reverse('misago:admin:users:agreements:new'),
-                data={
-                    'type': Agreement.TYPE_TOS,
-                    'text': 'test agreement!',
-                },
+                reverse("misago:admin:users:agreements:new"),
+                data={"type": Agreement.TYPE_TOS, "text": "test agreement!"},
             )
             self.assertEqual(response.status_code, 302)
 
@@ -39,35 +36,32 @@ class AgreementAdminViewsTests(AdminTestCase):
             agreements_pks.append(agreement.pk)
 
         response = self.client.post(
-            reverse('misago:admin:users:agreements:index'),
-            data={
-                'action': 'delete',
-                'selected_items': agreements_pks,
-            },
+            reverse("misago:admin:users:agreements:index"),
+            data={"action": "delete", "selected_items": agreements_pks},
         )
         self.assertEqual(response.status_code, 302)
         self.assertEqual(Agreement.objects.count(), 0)
 
     def test_new_view(self):
         """new agreement view has no showstoppers"""
-        response = self.client.get(reverse('misago:admin:users:agreements:new'))
+        response = self.client.get(reverse("misago:admin:users:agreements:new"))
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
-            reverse('misago:admin:users:agreements:new'),
+            reverse("misago:admin:users:agreements:new"),
             data={
-                'type': Agreement.TYPE_TOS,
-                'title': 'Test Rules',
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'link': 'https://example.com/rules/',
+                "type": Agreement.TYPE_TOS,
+                "title": "Test Rules",
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "link": "https://example.com/rules/",
             },
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:users:agreements:index'))
-        response = self.client.get(response['location'])
+        response = self.client.get(reverse("misago:admin:users:agreements:index"))
+        response = self.client.get(response["location"])
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'Test Rules')
+        self.assertContains(response, "Test Rules")
 
         test_agreement = Agreement.objects.get(type=Agreement.TYPE_TOS)
         self.assertIsNone(test_agreement.last_modified_on)
@@ -76,71 +70,68 @@ class AgreementAdminViewsTests(AdminTestCase):
 
     def test_new_view_change_active(self):
         """new agreement view creates new active agreement"""
-        response = self.client.get(reverse('misago:admin:users:agreements:new'))
+        response = self.client.get(reverse("misago:admin:users:agreements:new"))
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
-            reverse('misago:admin:users:agreements:new'),
+            reverse("misago:admin:users:agreements:new"),
             data={
-                'type': Agreement.TYPE_TOS,
-                'title': 'Old Active',
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'link': 'https://example.com/rules/',
-                'is_active': True,
+                "type": Agreement.TYPE_TOS,
+                "title": "Old Active",
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "link": "https://example.com/rules/",
+                "is_active": True,
             },
         )
         self.assertEqual(response.status_code, 302)
 
         response = self.client.post(
-            reverse('misago:admin:users:agreements:new'),
+            reverse("misago:admin:users:agreements:new"),
             data={
-                'type': Agreement.TYPE_TOS,
-                'title': 'New Active',
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'link': 'https://example.com/rules/',
-                'is_active': True,
+                "type": Agreement.TYPE_TOS,
+                "title": "New Active",
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "link": "https://example.com/rules/",
+                "is_active": True,
             },
         )
         self.assertEqual(response.status_code, 302)
 
         test_agreement = Agreement.objects.get(is_active=True)
-        self.assertEqual(test_agreement.title, 'New Active')
+        self.assertEqual(test_agreement.title, "New Active")
 
     def test_edit_view(self):
         """edit agreement view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:users:agreements:new'),
+            reverse("misago:admin:users:agreements:new"),
             data={
-                'type': Agreement.TYPE_TOS,
-                'title': 'Test Rules',
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'link': 'https://example.com/rules/',
+                "type": Agreement.TYPE_TOS,
+                "title": "Test Rules",
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "link": "https://example.com/rules/",
             },
         )
 
         test_agreement = Agreement.objects.get(type=Agreement.TYPE_TOS)
         form_link = reverse(
-            'misago:admin:users:agreements:edit',
-            kwargs={
-                'pk': test_agreement.pk,
-            },
+            "misago:admin:users:agreements:edit", kwargs={"pk": test_agreement.pk}
         )
 
         response = self.client.post(
             form_link,
             data={
-                'type': Agreement.TYPE_PRIVACY,
-                'title': 'Test Privacy',
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'link': 'https://example.com/rules/',
+                "type": Agreement.TYPE_PRIVACY,
+                "title": "Test Privacy",
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "link": "https://example.com/rules/",
             },
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:users:agreements:index'))
-        response = self.client.get(response['location'])
+        response = self.client.get(reverse("misago:admin:users:agreements:index"))
+        response = self.client.get(response["location"])
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'Test Privacy')
+        self.assertContains(response, "Test Privacy")
 
         updated_agreement = Agreement.objects.get(type=Agreement.TYPE_PRIVACY)
         self.assertTrue(updated_agreement.last_modified_on)
@@ -150,58 +141,55 @@ class AgreementAdminViewsTests(AdminTestCase):
     def test_edit_view_change_active(self):
         """edit agreement view sets new active"""
         self.client.post(
-            reverse('misago:admin:users:agreements:new'),
+            reverse("misago:admin:users:agreements:new"),
             data={
-                'type': Agreement.TYPE_TOS,
-                'title': 'Old Active',
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'link': 'https://example.com/rules/',
-                'is_active': True
+                "type": Agreement.TYPE_TOS,
+                "title": "Old Active",
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "link": "https://example.com/rules/",
+                "is_active": True,
             },
         )
 
         self.client.post(
-            reverse('misago:admin:users:agreements:new'),
+            reverse("misago:admin:users:agreements:new"),
             data={
-                'type': Agreement.TYPE_TOS,
-                'title': 'New Active',
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'link': 'https://example.com/rules/',
+                "type": Agreement.TYPE_TOS,
+                "title": "New Active",
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "link": "https://example.com/rules/",
             },
         )
 
-        test_agreement = Agreement.objects.get(title='New Active')
+        test_agreement = Agreement.objects.get(title="New Active")
         form_link = reverse(
-            'misago:admin:users:agreements:edit',
-            kwargs={
-                'pk': test_agreement.pk,
-            },
+            "misago:admin:users:agreements:edit", kwargs={"pk": test_agreement.pk}
         )
 
         response = self.client.post(
             form_link,
             data={
-                'type': Agreement.TYPE_TOS,
-                'title': 'Updated Active',
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'link': 'https://example.com/rules/',
-                'is_active': True
+                "type": Agreement.TYPE_TOS,
+                "title": "Updated Active",
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "link": "https://example.com/rules/",
+                "is_active": True,
             },
         )
         self.assertEqual(response.status_code, 302)
 
         updated_agreement = Agreement.objects.get(is_active=True)
-        self.assertEqual(updated_agreement.title, 'Updated Active')
+        self.assertEqual(updated_agreement.title, "Updated Active")
 
     def test_delete_view(self):
         """delete agreement view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:users:agreements:new'),
+            reverse("misago:admin:users:agreements:new"),
             data={
-                'type': Agreement.TYPE_TOS,
-                'title': 'Test Rules',
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'link': 'https://example.com/rules/',
+                "type": Agreement.TYPE_TOS,
+                "title": "Test Rules",
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "link": "https://example.com/rules/",
             },
         )
 
@@ -209,17 +197,14 @@ class AgreementAdminViewsTests(AdminTestCase):
 
         response = self.client.post(
             reverse(
-                'misago:admin:users:agreements:delete',
-                kwargs={
-                    'pk': test_agreement.pk,
-                },
+                "misago:admin:users:agreements:delete", kwargs={"pk": test_agreement.pk}
             )
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:users:agreements:index'))
-        self.client.get(response['location'])
-        response = self.client.get(response['location'])
+        response = self.client.get(reverse("misago:admin:users:agreements:index"))
+        self.client.get(response["location"])
+        response = self.client.get(response["location"])
 
         self.assertEqual(response.status_code, 200)
         self.assertNotContains(response, test_agreement.title)
@@ -227,12 +212,12 @@ class AgreementAdminViewsTests(AdminTestCase):
     def test_set_as_active_view(self):
         """set agreement as active view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:users:agreements:new'),
+            reverse("misago:admin:users:agreements:new"),
             data={
-                'type': Agreement.TYPE_TOS,
-                'title': 'Test Rules',
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'link': 'https://example.com/rules/',
+                "type": Agreement.TYPE_TOS,
+                "title": "Test Rules",
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "link": "https://example.com/rules/",
             },
         )
 
@@ -240,10 +225,8 @@ class AgreementAdminViewsTests(AdminTestCase):
 
         response = self.client.post(
             reverse(
-                'misago:admin:users:agreements:set-as-active',
-                kwargs={
-                    'pk': test_agreement.pk,
-                },
+                "misago:admin:users:agreements:set-as-active",
+                kwargs={"pk": test_agreement.pk},
             )
         )
         self.assertEqual(response.status_code, 302)
@@ -254,34 +237,32 @@ class AgreementAdminViewsTests(AdminTestCase):
     def test_set_as_active_view_change_active(self):
         """set agreement as active view changes current active"""
         self.client.post(
-            reverse('misago:admin:users:agreements:new'),
+            reverse("misago:admin:users:agreements:new"),
             data={
-                'type': Agreement.TYPE_TOS,
-                'title': 'Old Active',
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'link': 'https://example.com/rules/',
-                'is_active': True,
+                "type": Agreement.TYPE_TOS,
+                "title": "Old Active",
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "link": "https://example.com/rules/",
+                "is_active": True,
             },
         )
 
         self.client.post(
-            reverse('misago:admin:users:agreements:new'),
+            reverse("misago:admin:users:agreements:new"),
             data={
-                'type': Agreement.TYPE_TOS,
-                'title': 'New Active',
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'link': 'https://example.com/rules/',
+                "type": Agreement.TYPE_TOS,
+                "title": "New Active",
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "link": "https://example.com/rules/",
             },
         )
 
-        test_agreement = Agreement.objects.get(title='New Active')
+        test_agreement = Agreement.objects.get(title="New Active")
 
         response = self.client.post(
             reverse(
-                'misago:admin:users:agreements:set-as-active',
-                kwargs={
-                    'pk': test_agreement.pk,
-                },
+                "misago:admin:users:agreements:set-as-active",
+                kwargs={"pk": test_agreement.pk},
             )
         )
         self.assertEqual(response.status_code, 302)
@@ -292,20 +273,20 @@ class AgreementAdminViewsTests(AdminTestCase):
     def test_is_active_type_separation(self):
         """is_active flag is per type"""
         self.client.post(
-            reverse('misago:admin:users:agreements:new'),
+            reverse("misago:admin:users:agreements:new"),
             data={
-                'type': Agreement.TYPE_TOS,
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'is_active': True,
+                "type": Agreement.TYPE_TOS,
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "is_active": True,
             },
         )
 
         self.client.post(
-            reverse('misago:admin:users:agreements:new'),
+            reverse("misago:admin:users:agreements:new"),
             data={
-                'type': Agreement.TYPE_PRIVACY,
-                'text': 'Lorem ipsum dolor met sit amet elit',
-                'is_active': True,
+                "type": Agreement.TYPE_PRIVACY,
+                "text": "Lorem ipsum dolor met sit amet elit",
+                "is_active": True,
             },
         )
 

+ 28 - 31
misago/legal/tests/test_api.py

@@ -11,43 +11,40 @@ class SubmitAgreementTests(AuthenticatedUserTestCase):
         super().setUp()
 
         self.agreement = Agreement.objects.create(
-            type=Agreement.TYPE_TOS,
-            text='Lorem ipsum',
-            is_active=True,
+            type=Agreement.TYPE_TOS, text="Lorem ipsum", is_active=True
         )
 
         self.api_link = reverse(
-            'misago:api:submit-agreement', kwargs={'pk': self.agreement.pk})
+            "misago:api:submit-agreement", kwargs={"pk": self.agreement.pk}
+        )
 
     def post_json(self, data):
         return self.client.post(
-            self.api_link, json.dumps(data), content_type='application/json')
+            self.api_link, json.dumps(data), content_type="application/json"
+        )
 
     def test_anonymous(self):
         self.logout_user()
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "This action is not available to guests.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
 
     def test_get_request(self):
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 405)
-        self.assertEqual(response.json(), {
-            'detail': 'Method "GET" not allowed.',
-        })
+        self.assertEqual(response.json(), {"detail": 'Method "GET" not allowed.'})
 
     def test_invalid_agreement_id(self):
         api_link = reverse(
-            'misago:api:submit-agreement', kwargs={'pk': self.agreement.pk + 1})
+            "misago:api:submit-agreement", kwargs={"pk": self.agreement.pk + 1}
+        )
 
         response = self.client.post(api_link)
         self.assertEqual(response.status_code, 404)
-        self.assertEqual(response.json(), {
-            'detail': "Not found.",
-        })
+        self.assertEqual(response.json(), {"detail": "Not found."})
 
     def test_agreement_already_accepted(self):
         self.user.agreements.append(self.agreement.id)
@@ -55,28 +52,28 @@ class SubmitAgreementTests(AuthenticatedUserTestCase):
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You have already accepted this agreement.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You have already accepted this agreement."}
+        )
 
     def test_no_accept_sent(self):
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You need to submit a valid choice.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You need to submit a valid choice."}
+        )
 
     def test_invalid_accept_sent(self):
-        response = self.post_json({'accept': 1})
+        response = self.post_json({"accept": 1})
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You need to submit a valid choice.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You need to submit a valid choice."}
+        )
 
     def test_accept_false(self):
-        response = self.post_json({'accept': False})
+        response = self.post_json({"accept": False})
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {'detail': 'ok'})
+        self.assertEqual(response.json(), {"detail": "ok"})
 
         self.user.refresh_from_db()
         self.assertTrue(self.user.is_deleting_account)
@@ -86,18 +83,18 @@ class SubmitAgreementTests(AuthenticatedUserTestCase):
         self.user.is_staff = True
         self.user.save()
 
-        response = self.post_json({'accept': False})
+        response = self.post_json({"accept": False})
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {'detail': 'ok'})
+        self.assertEqual(response.json(), {"detail": "ok"})
 
         self.user.refresh_from_db()
         self.assertFalse(self.user.is_deleting_account)
         self.assertTrue(self.user.is_active)
 
     def test_accept_true(self):
-        response = self.post_json({'accept': True})
+        response = self.post_json({"accept": True})
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {'detail': 'ok'})
+        self.assertEqual(response.json(), {"detail": "ok"})
 
         self.user.refresh_from_db()
         self.assertEqual(self.user.agreements, [self.agreement.id])

+ 115 - 99
misago/legal/tests/test_context_processors.py

@@ -11,7 +11,7 @@ class MockRequest(object):
         self.frontend_context = {}
 
     def get_host(self):
-        return 'testhost.com'
+        return "testhost.com"
 
 
 class PrivacyPolicyTests(AuthenticatedUserTestCase):
@@ -26,84 +26,92 @@ class PrivacyPolicyTests(AuthenticatedUserTestCase):
     def test_context_processor_no_policy(self):
         """context processor has no TOS link"""
         context_dict = legal_links(MockRequest(self.user))
-        self.assertEqual(context_dict, {
-            'TERMS_OF_SERVICE_ID': None,
-            'TERMS_OF_SERVICE_URL': None,
-            'PRIVACY_POLICY_ID': None,
-            'PRIVACY_POLICY_URL': None,
-            'misago_agreement': None,
-        })
+        self.assertEqual(
+            context_dict,
+            {
+                "TERMS_OF_SERVICE_ID": None,
+                "TERMS_OF_SERVICE_URL": None,
+                "PRIVACY_POLICY_ID": None,
+                "PRIVACY_POLICY_URL": None,
+                "misago_agreement": None,
+            },
+        )
 
     def test_context_processor_misago_policy(self):
         """context processor has TOS link to Misago view"""
         agreement = Agreement.objects.create(
-            type=Agreement.TYPE_PRIVACY,
-            text='Lorem ipsum',
-            is_active=True,
+            type=Agreement.TYPE_PRIVACY, text="Lorem ipsum", is_active=True
         )
 
         context_dict = legal_links(MockRequest(self.user))
 
-        self.assertEqual(context_dict, {
-            'TERMS_OF_SERVICE_ID': None,
-            'TERMS_OF_SERVICE_URL': None,
-            'PRIVACY_POLICY_ID': agreement.id,
-            'PRIVACY_POLICY_URL': reverse('misago:privacy-policy'),
-            'misago_agreement': {
-                'type': 'Privacy policy',
-                'title': 'Privacy policy',
-                'link': None,
-                'text': '<p>Lorem ipsum</p>',
+        self.assertEqual(
+            context_dict,
+            {
+                "TERMS_OF_SERVICE_ID": None,
+                "TERMS_OF_SERVICE_URL": None,
+                "PRIVACY_POLICY_ID": agreement.id,
+                "PRIVACY_POLICY_URL": reverse("misago:privacy-policy"),
+                "misago_agreement": {
+                    "type": "Privacy policy",
+                    "title": "Privacy policy",
+                    "link": None,
+                    "text": "<p>Lorem ipsum</p>",
+                },
             },
-        })
+        )
 
     def test_context_processor_remote_policy(self):
         """context processor has TOS link to remote url"""
         agreement = Agreement.objects.create(
-            type=Agreement.TYPE_PRIVACY,
-            link='http://test.com',
-            is_active=True,
+            type=Agreement.TYPE_PRIVACY, link="http://test.com", is_active=True
         )
 
         context_dict = legal_links(MockRequest(self.user))
 
-        self.assertEqual(context_dict, {
-            'TERMS_OF_SERVICE_ID': None,
-            'TERMS_OF_SERVICE_URL': None,
-            'PRIVACY_POLICY_ID': agreement.id,
-            'PRIVACY_POLICY_URL': 'http://test.com',
-            'misago_agreement': {
-                'type': 'Privacy policy',
-                'title': 'Privacy policy',
-                'link': 'http://test.com',
-                'text': None,
+        self.assertEqual(
+            context_dict,
+            {
+                "TERMS_OF_SERVICE_ID": None,
+                "TERMS_OF_SERVICE_URL": None,
+                "PRIVACY_POLICY_ID": agreement.id,
+                "PRIVACY_POLICY_URL": "http://test.com",
+                "misago_agreement": {
+                    "type": "Privacy policy",
+                    "title": "Privacy policy",
+                    "link": "http://test.com",
+                    "text": None,
+                },
             },
-        })
+        )
 
         # set misago view too
-        agreement.text = 'Lorem ipsum'
+        agreement.text = "Lorem ipsum"
         agreement.save()
-        
+
         context_dict = legal_links(MockRequest(self.user))
 
-        self.assertEqual(context_dict, {
-            'TERMS_OF_SERVICE_ID': None,
-            'TERMS_OF_SERVICE_URL': None,
-            'PRIVACY_POLICY_ID': agreement.id,
-            'PRIVACY_POLICY_URL': 'http://test.com',
-            'misago_agreement': {
-                'type': 'Privacy policy',
-                'title': 'Privacy policy',
-                'link': 'http://test.com',
-                'text': '<p>Lorem ipsum</p>',
+        self.assertEqual(
+            context_dict,
+            {
+                "TERMS_OF_SERVICE_ID": None,
+                "TERMS_OF_SERVICE_URL": None,
+                "PRIVACY_POLICY_ID": agreement.id,
+                "PRIVACY_POLICY_URL": "http://test.com",
+                "misago_agreement": {
+                    "type": "Privacy policy",
+                    "title": "Privacy policy",
+                    "link": "http://test.com",
+                    "text": "<p>Lorem ipsum</p>",
+                },
             },
-        })
+        )
 
 
 class TermsOfServiceTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
-        
+
         Agreement.objects.invalidate_cache()
 
     def tearDown(self):
@@ -112,75 +120,83 @@ class TermsOfServiceTests(AuthenticatedUserTestCase):
     def test_context_processor_no_tos(self):
         """context processor has no TOS link"""
         context_dict = legal_links(MockRequest(self.user))
-        self.assertEqual(context_dict, {
-            'TERMS_OF_SERVICE_ID': None,
-            'TERMS_OF_SERVICE_URL': None,
-            'PRIVACY_POLICY_ID': None,
-            'PRIVACY_POLICY_URL': None,
-            'misago_agreement': None,
-        })
+        self.assertEqual(
+            context_dict,
+            {
+                "TERMS_OF_SERVICE_ID": None,
+                "TERMS_OF_SERVICE_URL": None,
+                "PRIVACY_POLICY_ID": None,
+                "PRIVACY_POLICY_URL": None,
+                "misago_agreement": None,
+            },
+        )
 
     def test_context_processor_misago_tos(self):
         """context processor has TOS link to Misago view"""
         agreement = Agreement.objects.create(
-            type=Agreement.TYPE_TOS,
-            text='Lorem ipsum',
-            is_active=True,
+            type=Agreement.TYPE_TOS, text="Lorem ipsum", is_active=True
         )
 
         context_dict = legal_links(MockRequest(self.user))
 
-        self.assertEqual(context_dict, {
-            'TERMS_OF_SERVICE_ID': agreement.id,
-            'TERMS_OF_SERVICE_URL': reverse('misago:terms-of-service'),
-            'PRIVACY_POLICY_ID': None,
-            'PRIVACY_POLICY_URL': None,
-            'misago_agreement': {
-                'type': 'Terms of service',
-                'title': 'Terms of service',
-                'link': None,
-                'text': '<p>Lorem ipsum</p>',
-            }
-        })
+        self.assertEqual(
+            context_dict,
+            {
+                "TERMS_OF_SERVICE_ID": agreement.id,
+                "TERMS_OF_SERVICE_URL": reverse("misago:terms-of-service"),
+                "PRIVACY_POLICY_ID": None,
+                "PRIVACY_POLICY_URL": None,
+                "misago_agreement": {
+                    "type": "Terms of service",
+                    "title": "Terms of service",
+                    "link": None,
+                    "text": "<p>Lorem ipsum</p>",
+                },
+            },
+        )
 
     def test_context_processor_remote_tos(self):
         """context processor has TOS link to remote url"""
         agreement = Agreement.objects.create(
-            type=Agreement.TYPE_TOS,
-            link='http://test.com',
-            is_active=True,
+            type=Agreement.TYPE_TOS, link="http://test.com", is_active=True
         )
 
         context_dict = legal_links(MockRequest(self.user))
 
-        self.assertEqual(context_dict, {
-            'TERMS_OF_SERVICE_ID': agreement.id,
-            'TERMS_OF_SERVICE_URL': 'http://test.com',
-            'PRIVACY_POLICY_ID': None,
-            'PRIVACY_POLICY_URL': None,
-            'misago_agreement': {
-                'type': 'Terms of service',
-                'title': 'Terms of service',
-                'link': 'http://test.com',
-                'text': None,
-            }
-        })
+        self.assertEqual(
+            context_dict,
+            {
+                "TERMS_OF_SERVICE_ID": agreement.id,
+                "TERMS_OF_SERVICE_URL": "http://test.com",
+                "PRIVACY_POLICY_ID": None,
+                "PRIVACY_POLICY_URL": None,
+                "misago_agreement": {
+                    "type": "Terms of service",
+                    "title": "Terms of service",
+                    "link": "http://test.com",
+                    "text": None,
+                },
+            },
+        )
 
         # set misago view too
-        agreement.text = 'Lorem ipsum'
+        agreement.text = "Lorem ipsum"
         agreement.save()
 
         context_dict = legal_links(MockRequest(self.user))
 
-        self.assertEqual(context_dict, {
-            'TERMS_OF_SERVICE_ID': agreement.id,
-            'TERMS_OF_SERVICE_URL': 'http://test.com',
-            'PRIVACY_POLICY_ID': None,
-            'PRIVACY_POLICY_URL': None,
-            'misago_agreement': {
-                'type': 'Terms of service',
-                'title': 'Terms of service',
-                'link': 'http://test.com',
-                'text': '<p>Lorem ipsum</p>',
+        self.assertEqual(
+            context_dict,
+            {
+                "TERMS_OF_SERVICE_ID": agreement.id,
+                "TERMS_OF_SERVICE_URL": "http://test.com",
+                "PRIVACY_POLICY_ID": None,
+                "PRIVACY_POLICY_URL": None,
+                "misago_agreement": {
+                    "type": "Terms of service",
+                    "title": "Terms of service",
+                    "link": "http://test.com",
+                    "text": "<p>Lorem ipsum</p>",
+                },
             },
-        })
+        )

+ 13 - 19
misago/legal/tests/test_required_agreement.py

@@ -8,7 +8,7 @@ class RequiredAgreementTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.test_link = reverse('misago:index')
+        self.test_link = reverse("misago:index")
 
         Agreement.objects.invalidate_cache()
 
@@ -17,9 +17,7 @@ class RequiredAgreementTests(AuthenticatedUserTestCase):
 
     def test_tos_link(self):
         Agreement.objects.create(
-            type=Agreement.TYPE_TOS,
-            link='https://test-agreement.com',
-            is_active=True,
+            type=Agreement.TYPE_TOS, link="https://test-agreement.com", is_active=True
         )
 
         response = self.client.get(self.test_link)
@@ -27,9 +25,7 @@ class RequiredAgreementTests(AuthenticatedUserTestCase):
 
     def test_tos_text(self):
         Agreement.objects.create(
-            type=Agreement.TYPE_TOS,
-            text='Lorem ipsum',
-            is_active=True,
+            type=Agreement.TYPE_TOS, text="Lorem ipsum", is_active=True
         )
 
         response = self.client.get(self.test_link)
@@ -38,8 +34,8 @@ class RequiredAgreementTests(AuthenticatedUserTestCase):
     def test_tos_text_and_link(self):
         Agreement.objects.create(
             type=Agreement.TYPE_TOS,
-            link='https://test-agreement.com',
-            text='Lorem ipsum',
+            link="https://test-agreement.com",
+            text="Lorem ipsum",
             is_active=True,
         )
 
@@ -49,7 +45,7 @@ class RequiredAgreementTests(AuthenticatedUserTestCase):
     def test_privacy_link(self):
         Agreement.objects.create(
             type=Agreement.TYPE_PRIVACY,
-            link='https://test-agreement.com',
+            link="https://test-agreement.com",
             is_active=True,
         )
 
@@ -58,9 +54,7 @@ class RequiredAgreementTests(AuthenticatedUserTestCase):
 
     def test_privacy_text(self):
         Agreement.objects.create(
-            type=Agreement.TYPE_PRIVACY,
-            text='Lorem ipsum',
-            is_active=True,
+            type=Agreement.TYPE_PRIVACY, text="Lorem ipsum", is_active=True
         )
 
         response = self.client.get(self.test_link)
@@ -69,8 +63,8 @@ class RequiredAgreementTests(AuthenticatedUserTestCase):
     def test_privacy_text_and_link(self):
         Agreement.objects.create(
             type=Agreement.TYPE_PRIVACY,
-            link='https://test-agreement.com',
-            text='Lorem ipsum',
+            link="https://test-agreement.com",
+            text="Lorem ipsum",
             is_active=True,
         )
 
@@ -80,15 +74,15 @@ class RequiredAgreementTests(AuthenticatedUserTestCase):
     def test_both(self):
         Agreement.objects.create(
             type=Agreement.TYPE_TOS,
-            link='https://test-agreement.com',
-            text='Lorem ipsum',
+            link="https://test-agreement.com",
+            text="Lorem ipsum",
             is_active=True,
         )
 
         Agreement.objects.create(
             type=Agreement.TYPE_PRIVACY,
-            link='https://test-agreement.com',
-            text='Lorem ipsum',
+            link="https://test-agreement.com",
+            text="Lorem ipsum",
             is_active=True,
         )
 

+ 31 - 41
misago/legal/tests/test_utils.py

@@ -2,8 +2,10 @@ from django.test import TestCase
 
 from misago.legal.models import Agreement, UserAgreement
 from misago.legal.utils import (
-    get_parsed_agreement_text, get_required_user_agreement, save_user_agreement_acceptance,
-    set_agreement_as_active
+    get_parsed_agreement_text,
+    get_required_user_agreement,
+    save_user_agreement_acceptance,
+    set_agreement_as_active,
 )
 from misago.users.testutils import UserTestCase
 
@@ -14,15 +16,13 @@ class MockRequest(object):
         self.frontend_context = {}
 
     def get_host(self):
-        return 'testhost.com'
+        return "testhost.com"
 
 
 class GetParsedAgreementTextTests(TestCase):
     def test_agreement_no_text(self):
         agreement = Agreement.objects.create(
-            type=Agreement.TYPE_PRIVACY,
-            link='https://somewhre.com',
-            is_active=True,
+            type=Agreement.TYPE_PRIVACY, link="https://somewhre.com", is_active=True
         )
 
         result = get_parsed_agreement_text(MockRequest(), agreement)
@@ -31,31 +31,29 @@ class GetParsedAgreementTextTests(TestCase):
     def test_agreement_link_and_text(self):
         agreement = Agreement.objects.create(
             type=Agreement.TYPE_PRIVACY,
-            link='https://somewhre.com',
-            text='Lorem ipsum',
+            link="https://somewhre.com",
+            text="Lorem ipsum",
             is_active=True,
         )
 
         result = get_parsed_agreement_text(MockRequest(), agreement)
-        self.assertEqual(result, '<p>Lorem ipsum</p>')
+        self.assertEqual(result, "<p>Lorem ipsum</p>")
 
     def test_agreement_text(self):
         agreement = Agreement.objects.create(
-            type=Agreement.TYPE_PRIVACY,
-            text='Lorem ipsum',
-            is_active=True,
+            type=Agreement.TYPE_PRIVACY, text="Lorem ipsum", is_active=True
         )
 
         result = get_parsed_agreement_text(MockRequest(), agreement)
-        self.assertEqual(result, '<p>Lorem ipsum</p>')
+        self.assertEqual(result, "<p>Lorem ipsum</p>")
 
 
 class GetRequiredUserAgreementTests(UserTestCase):
     def setUp(self):
         self.agreement = Agreement.objects.create(
             type=Agreement.TYPE_PRIVACY,
-            link='https://somewhre.com',
-            text='Lorem ipsum',
+            link="https://somewhre.com",
+            text="Lorem ipsum",
             is_active=True,
         )
 
@@ -86,8 +84,8 @@ class GetRequiredUserAgreementTests(UserTestCase):
     def test_prioritize_terms_of_service(self):
         terms_of_service = Agreement.objects.create(
             type=Agreement.TYPE_TOS,
-            link='https://somewhre.com',
-            text='Lorem ipsum',
+            link="https://somewhre.com",
+            text="Lorem ipsum",
             is_active=True,
         )
 
@@ -98,7 +96,9 @@ class GetRequiredUserAgreementTests(UserTestCase):
         }
 
         authenticated_user = self.get_authenticated_user()
-        result = get_required_user_agreement(authenticated_user, agreements_in_wrong_order)
+        result = get_required_user_agreement(
+            authenticated_user, agreements_in_wrong_order
+        )
         self.assertEqual(result, terms_of_service)
 
 
@@ -107,9 +107,7 @@ class SaveUserAgreementAcceptance(UserTestCase):
         user = self.get_authenticated_user()
 
         agreement = Agreement.objects.create(
-            type=Agreement.TYPE_PRIVACY,
-            link='https://somewhre.com',
-            text='Lorem ipsum',
+            type=Agreement.TYPE_PRIVACY, link="https://somewhre.com", text="Lorem ipsum"
         )
 
         save_user_agreement_acceptance(user, agreement)
@@ -125,9 +123,7 @@ class SaveUserAgreementAcceptance(UserTestCase):
         user = self.get_authenticated_user()
 
         agreement = Agreement.objects.create(
-            type=Agreement.TYPE_PRIVACY,
-            link='https://somewhre.com',
-            text='Lorem ipsum',
+            type=Agreement.TYPE_PRIVACY, link="https://somewhre.com", text="Lorem ipsum"
         )
 
         save_user_agreement_acceptance(user, agreement, commit=True)
@@ -141,11 +137,9 @@ class SaveUserAgreementAcceptance(UserTestCase):
 
 
 class SetAgreementAsActiveTests(TestCase):
-     def test_inactive_agreement(self):
+    def test_inactive_agreement(self):
         agreement = Agreement.objects.create(
-            type=Agreement.TYPE_PRIVACY,
-            link='https://somewhre.com',
-            text='Lorem ipsum',
+            type=Agreement.TYPE_PRIVACY, link="https://somewhre.com", text="Lorem ipsum"
         )
 
         set_agreement_as_active(agreement)
@@ -154,11 +148,9 @@ class SetAgreementAsActiveTests(TestCase):
         agreement.refresh_from_db()
         self.assertFalse(agreement.is_active)
 
-     def test_inactive_agreement_commit(self):
+    def test_inactive_agreement_commit(self):
         agreement = Agreement.objects.create(
-            type=Agreement.TYPE_PRIVACY,
-            link='https://somewhre.com',
-            text='Lorem ipsum',
+            type=Agreement.TYPE_PRIVACY, link="https://somewhre.com", text="Lorem ipsum"
         )
 
         set_agreement_as_active(agreement, commit=True)
@@ -166,25 +158,23 @@ class SetAgreementAsActiveTests(TestCase):
 
         agreement.refresh_from_db()
         self.assertTrue(agreement.is_active)
-        
-     def test_change_active_agreement(self):
+
+    def test_change_active_agreement(self):
         old_agreement = Agreement.objects.create(
             type=Agreement.TYPE_PRIVACY,
-            link='https://somewhre.com',
-            text='Lorem ipsum',
+            link="https://somewhre.com",
+            text="Lorem ipsum",
             is_active=True,
         )
 
         new_agreement = Agreement.objects.create(
-            type=Agreement.TYPE_PRIVACY,
-            link='https://somewhre.com',
-            text='Lorem ipsum',
+            type=Agreement.TYPE_PRIVACY, link="https://somewhre.com", text="Lorem ipsum"
         )
 
         other_type_agreement = Agreement.objects.create(
             type=Agreement.TYPE_TOS,
-            link='https://somewhre.com',
-            text='Lorem ipsum',
+            link="https://somewhre.com",
+            text="Lorem ipsum",
             is_active=True,
         )
 

+ 20 - 20
misago/legal/tests/test_views.py

@@ -13,35 +13,35 @@ class PrivacyPolicyTests(TestCase):
 
     def test_404_on_no_policy(self):
         """policy view returns 404 when no policy is set"""
-        response = self.client.get(reverse('misago:privacy-policy'))
+        response = self.client.get(reverse("misago:privacy-policy"))
         self.assertEqual(response.status_code, 404)
 
     def test_301_on_link_policy(self):
         """policy view returns 302 redirect when link is set"""
         Agreement.objects.create(
             type=Agreement.TYPE_PRIVACY,
-            link='http://test.com',
-            text='Lorem ipsum',
+            link="http://test.com",
+            text="Lorem ipsum",
             is_active=True,
         )
 
-        response = self.client.get(reverse('misago:privacy-policy'))
+        response = self.client.get(reverse("misago:privacy-policy"))
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response['location'], 'http://test.com')
+        self.assertEqual(response["location"], "http://test.com")
 
     def test_200_on_link_policy(self):
         """policy view returns 200 when custom tos content is set"""
         Agreement.objects.create(
             type=Agreement.TYPE_PRIVACY,
-            title='Test Policy',
-            text='Lorem ipsum dolor',
+            title="Test Policy",
+            text="Lorem ipsum dolor",
             is_active=True,
         )
 
-        response = self.client.get(reverse('misago:privacy-policy'))
+        response = self.client.get(reverse("misago:privacy-policy"))
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'Test Policy')
-        self.assertContains(response, 'Lorem ipsum dolor')
+        self.assertContains(response, "Test Policy")
+        self.assertContains(response, "Lorem ipsum dolor")
 
 
 class TermsOfServiceTests(TestCase):
@@ -53,32 +53,32 @@ class TermsOfServiceTests(TestCase):
 
     def test_404_on_no_tos(self):
         """TOS view returns 404 when no TOS is set"""
-        response = self.client.get(reverse('misago:terms-of-service'))
+        response = self.client.get(reverse("misago:terms-of-service"))
         self.assertEqual(response.status_code, 404)
 
     def test_301_on_link_tos(self):
         """TOS view returns 302 redirect when link is set"""
         Agreement.objects.create(
             type=Agreement.TYPE_TOS,
-            link='http://test.com',
-            text='Lorem ipsum',
+            link="http://test.com",
+            text="Lorem ipsum",
             is_active=True,
         )
 
-        response = self.client.get(reverse('misago:terms-of-service'))
+        response = self.client.get(reverse("misago:terms-of-service"))
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response['location'], 'http://test.com')
+        self.assertEqual(response["location"], "http://test.com")
 
     def test_200_on_link_tos(self):
         """TOS view returns 200 when custom tos content is set"""
         Agreement.objects.create(
             type=Agreement.TYPE_TOS,
-            title='Test ToS',
-            text='Lorem ipsum dolor',
+            title="Test ToS",
+            text="Lorem ipsum dolor",
             is_active=True,
         )
 
-        response = self.client.get(reverse('misago:terms-of-service'))
+        response = self.client.get(reverse("misago:terms-of-service"))
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'Test ToS')
-        self.assertContains(response, 'Lorem ipsum dolor')
+        self.assertContains(response, "Test ToS")
+        self.assertContains(response, "Lorem ipsum dolor")

+ 3 - 3
misago/legal/urls/__init__.py

@@ -4,6 +4,6 @@ from misago.legal.views import privacy_policy, terms_of_service
 
 
 urlpatterns = [
-    url(r'^privacy-policy/$', privacy_policy, name='privacy-policy'),
-    url(r'^terms-of-service/$', terms_of_service, name='terms-of-service'),
-]
+    url(r"^privacy-policy/$", privacy_policy, name="privacy-policy"),
+    url(r"^terms-of-service/$", terms_of_service, name="terms-of-service"),
+]

+ 1 - 1
misago/legal/urls/api.py

@@ -4,5 +4,5 @@ from misago.legal.api import submit_agreement
 
 
 urlpatterns = [
-    url(r'^submit-agreement/(?P<pk>\d+)/$', submit_agreement, name='submit-agreement'),
+    url(r"^submit-agreement/(?P<pk>\d+)/$", submit_agreement, name="submit-agreement")
 ]

+ 12 - 15
misago/legal/utils.py

@@ -15,7 +15,7 @@ def set_agreement_as_active(agreement, commit=False):
     queryset.update(is_active=False)
 
     if commit:
-        agreement.save(update_fields=['is_active'])
+        agreement.save(update_fields=["is_active"])
         Agreement.objects.invalidate_cache()
 
 
@@ -25,13 +25,13 @@ def get_required_user_agreement(user, agreements):
 
     for agreement_type, _ in Agreement.TYPE_CHOICES:
         agreement = agreements.get(agreement_type)
-        if agreement and agreement['id'] not in user.agreements:
+        if agreement and agreement["id"] not in user.agreements:
             try:
-                return Agreement.objects.get(id=agreement['id'])
+                return Agreement.objects.get(id=agreement["id"])
             except Agreement.DoesNotExist:
                 # possible stale cache
                 Agreement.invalidate_cache()
-    
+
     return None
 
 
@@ -39,24 +39,21 @@ def get_parsed_agreement_text(request, agreement):
     if not agreement.text:
         return None
 
-    cache_name = 'misago_legal_%s_%s' % (agreement.pk, agreement.last_modified_on or '')
+    cache_name = "misago_legal_%s_%s" % (agreement.pk, agreement.last_modified_on or "")
     cached_content = cache.get(cache_name)
 
     unparsed_content = agreement.text
 
-    checksum_source = force_bytes('%s:%s' % (unparsed_content, settings.SECRET_KEY))
+    checksum_source = force_bytes("%s:%s" % (unparsed_content, settings.SECRET_KEY))
     unparsed_checksum = md5(checksum_source).hexdigest()
 
-    if cached_content and cached_content.get('checksum') == unparsed_checksum:
-        return cached_content['parsed']
+    if cached_content and cached_content.get("checksum") == unparsed_checksum:
+        return cached_content["parsed"]
     else:
-        parsed = common_flavour(request, None, unparsed_content)['parsed_text']
-        cached_content = {
-            'checksum': unparsed_checksum,
-            'parsed': parsed,
-        }
+        parsed = common_flavour(request, None, unparsed_content)["parsed_text"]
+        cached_content = {"checksum": unparsed_checksum, "parsed": parsed}
         cache.set(cache_name, cached_content)
-        return cached_content['parsed']
+        return cached_content["parsed"]
 
 
 def save_user_agreement_acceptance(user, agreement, commit=False):
@@ -64,4 +61,4 @@ def save_user_agreement_acceptance(user, agreement, commit=False):
     UserAgreement.objects.create(agreement=agreement, user=user)
 
     if commit:
-        user.save(update_fields=['agreements'])
+        user.save(update_fields=["agreements"])

+ 1 - 1
misago/legal/views/__init__.py

@@ -1 +1 @@
-from .legal import privacy_policy, terms_of_service
+from .legal import privacy_policy, terms_of_service

+ 24 - 20
misago/legal/views/admin.py

@@ -10,34 +10,35 @@ from misago.legal.utils import set_agreement_as_active
 
 
 class AgreementAdmin(generic.AdminBaseMixin):
-    root_link = 'misago:admin:users:agreements:index'
+    root_link = "misago:admin:users:agreements:index"
     model = Agreement
     form = AgreementForm
-    templates_dir = 'misago/admin/agreements'
+    templates_dir = "misago/admin/agreements"
     message_404 = _("Requested agreement does not exist.")
 
     def handle_form(self, form, request, target):
         form.save()
-        
+
         if self.message_submit:
-            messages.success(request, self.message_submit % {'title': target.get_final_title()})
+            messages.success(
+                request, self.message_submit % {"title": target.get_final_title()}
+            )
 
 
 class AgreementsList(AgreementAdmin, generic.ListView):
     items_per_page = 30
-    ordering = [
-        ('-id', _("From newest")),
-        ('id', _("From oldest")),
-    ]
+    ordering = [("-id", _("From newest")), ("id", _("From oldest"))]
     search_form = SearchAgreementsForm
-    selection_label = _('With agreements: 0')
-    empty_selection_label = _('Select agreements')
-    mass_actions = ({
-        'action': 'delete',
-        'icon': 'fa fa-times',
-        'name': _('Delete agreements'),
-        'confirmation': _('Are you sure you want to delete those agreements?')
-    }, )
+    selection_label = _("With agreements: 0")
+    empty_selection_label = _("Select agreements")
+    mass_actions = (
+        {
+            "action": "delete",
+            "icon": "fa fa-times",
+            "name": _("Delete agreements"),
+            "confirmation": _("Are you sure you want to delete those agreements?"),
+        },
+    )
 
     def get_queryset(self):
         qs = super().get_queryset()
@@ -51,7 +52,7 @@ class AgreementsList(AgreementAdmin, generic.ListView):
 
 class NewAgreement(AgreementAdmin, generic.ModelFormView):
     message_submit = _('New agreement "%(title)s" has been saved.')
-    
+
     def handle_form(self, form, request, target):
         super().handle_form(form, request, target)
 
@@ -75,7 +76,7 @@ class DeleteAgreement(AgreementAdmin, generic.ButtonView):
         target.delete()
         Agreement.objects.invalidate_cache()
         message = _('Agreement "%(title)s" has been deleted.')
-        messages.success(request, message % {'title': target.get_final_title()})
+        messages.success(request, message % {"title": target.get_final_title()})
 
 
 class SetAgreementAsActive(AgreementAdmin, generic.ButtonView):
@@ -83,5 +84,8 @@ class SetAgreementAsActive(AgreementAdmin, generic.ButtonView):
         set_agreement_as_active(target, commit=True)
 
         message = _('Agreement "%(title)s" has been set as active for type "%(type)s".')
-        targets_names = {'title': target.get_final_title(), 'type': target.get_type_display()}
-        messages.success(request, message % targets_names)
+        targets_names = {
+            "title": target.get_final_title(),
+            "type": target.get_type_display(),
+        }
+        messages.success(request, message % targets_names)

+ 6 - 8
misago/legal/views/legal.py

@@ -5,24 +5,22 @@ from misago.legal.utils import get_parsed_agreement_text
 
 
 def legal_view(request, agreement_type):
-    agreement = get_object_or_404(
-        Agreement, type=agreement_type, is_active=True
-    )
+    agreement = get_object_or_404(Agreement, type=agreement_type, is_active=True)
 
     if agreement.link:
         return redirect(agreement.link)
 
-    template_name = 'misago/%s.html' % agreement_type
+    template_name = "misago/%s.html" % agreement_type
     agreement_text = get_parsed_agreement_text(request, agreement)
 
     return render(
         request,
         template_name,
         {
-            'title': agreement.get_final_title(),
-            'link': agreement.link,
-            'text': agreement_text,
-        }
+            "title": agreement.get_final_title(),
+            "link": agreement.link,
+            "text": agreement_text,
+        },
     )
 
 

+ 1 - 1
misago/markup/__init__.py

@@ -2,4 +2,4 @@ from .finalise import finalise_markup
 from .flavours import common as common_flavour, signature as signature_flavour
 from .parser import parse
 
-default_app_config = 'misago.markup.apps.MisagoMarkupConfig'
+default_app_config = "misago.markup.apps.MisagoMarkupConfig"

+ 5 - 11
misago/markup/api.py

@@ -6,24 +6,18 @@ from . import common_flavour, finalise_markup
 from .serializers import MarkupSerializer
 
 
-@api_view(['POST'])
+@api_view(["POST"])
 def parse_markup(request):
     serializer = MarkupSerializer(
         data=request.data, context={"settings": request.settings}
     )
     if not serializer.is_valid():
         errors_list = list(serializer.errors.values())[0]
-        return Response(
-            {'detail': errors_list[0]},
-            status=status.HTTP_400_BAD_REQUEST,
-        )
+        return Response({"detail": errors_list[0]}, status=status.HTTP_400_BAD_REQUEST)
 
     parsing_result = common_flavour(
-        request,
-        request.user,
-        serializer.data['post'],
-        force_shva=True,
+        request, request.user, serializer.data["post"], force_shva=True
     )
-    finalised = finalise_markup(parsing_result['parsed_text'])
+    finalised = finalise_markup(parsing_result["parsed_text"])
 
-    return Response({'parsed': finalised})
+    return Response({"parsed": finalised})

+ 2 - 2
misago/markup/apps.py

@@ -2,6 +2,6 @@ from django.apps import AppConfig
 
 
 class MisagoMarkupConfig(AppConfig):
-    name = 'misago.markup'
-    label = 'misago_markup'
+    name = "misago.markup"
+    label = "misago_markup"
     verbose_name = "Misago Markup"

+ 28 - 25
misago/markup/bbcode/blocks.py

@@ -14,7 +14,7 @@ QUOTE_END = get_random_string(32)
 
 
 class BBCodeHRProcessor(HRProcessor):
-    RE = r'^\[hr\]*'
+    RE = r"^\[hr\]*"
 
     # Detect hr on any line of a block.
     SEARCH_RE = re.compile(RE, re.MULTILINE | re.IGNORECASE)
@@ -24,44 +24,46 @@ class QuoteExtension(markdown.Extension):
     def extendMarkdown(self, md):
         md.registerExtension(self)
 
-        md.preprocessors.add('misago_bbcode_quote', QuotePreprocessor(md), '_end')
+        md.preprocessors.add("misago_bbcode_quote", QuotePreprocessor(md), "_end")
         md.parser.blockprocessors.add(
-            'misago_bbcode_quote', QuoteBlockProcessor(md.parser), '>code'
+            "misago_bbcode_quote", QuoteBlockProcessor(md.parser), ">code"
         )
 
 
 class QuotePreprocessor(Preprocessor):
     QUOTE_BLOCK_RE = re.compile(
-        r'''
+        r"""
 \[quote\](?P<text>.*?)\[/quote\]
-'''.strip(), re.IGNORECASE | re.MULTILINE | re.DOTALL
+""".strip(),
+        re.IGNORECASE | re.MULTILINE | re.DOTALL,
     )
     QUOTE_BLOCK_TITLE_RE = re.compile(
-        r'''
+        r"""
 \[quote=("?)(?P<title>.*?)("?)](?P<text>.*?)\[/quote\]
-'''.strip(), re.IGNORECASE | re.MULTILINE | re.DOTALL
+""".strip(),
+        re.IGNORECASE | re.MULTILINE | re.DOTALL,
     )
 
     def run(self, lines):
-        text = '\n'.join(lines)
+        text = "\n".join(lines)
         while self.QUOTE_BLOCK_RE.search(text):
             text = self.QUOTE_BLOCK_RE.sub(self.replace, text)
         while self.QUOTE_BLOCK_TITLE_RE.search(text):
             text = self.QUOTE_BLOCK_TITLE_RE.sub(self.replace_titled, text)
-        return text.split('\n')
+        return text.split("\n")
 
     def replace(self, matchobj):
-        text = matchobj.group('text')
-        return '\n\n%s\n\n%s\n\n%s\n\n' % (QUOTE_START, text, QUOTE_END)
+        text = matchobj.group("text")
+        return "\n\n%s\n\n%s\n\n%s\n\n" % (QUOTE_START, text, QUOTE_END)
 
     def replace_titled(self, matchobj):
-        title = matchobj.group('title').strip()
-        text = matchobj.group('text')
+        title = matchobj.group("title").strip()
+        text = matchobj.group("text")
 
         if title:
-            return '\n\n%s%s\n\n%s\n\n%s\n\n' % (QUOTE_START, title, text, QUOTE_END)
+            return "\n\n%s%s\n\n%s\n\n%s\n\n" % (QUOTE_START, title, text, QUOTE_END)
         else:
-            return '\n\n%s\n\n%s\n\n%s\n\n' % (QUOTE_START, text, QUOTE_END)
+            return "\n\n%s\n\n%s\n\n%s\n\n" % (QUOTE_START, text, QUOTE_END)
 
 
 class QuoteBlockProcessor(BlockProcessor):
@@ -79,7 +81,7 @@ class QuoteBlockProcessor(BlockProcessor):
         if block.strip().startswith(QUOTE_START):
             self._quote += 1
             if self._quote == 1:
-                self._title = block[len(QUOTE_START):].strip() or None
+                self._title = block[len(QUOTE_START) :].strip() or None
 
         self._children.append(block)
 
@@ -90,14 +92,14 @@ class QuoteBlockProcessor(BlockProcessor):
             children, self._children = self._children[1:-1], []
             title, self._title = self._title, None
 
-            aside = etree.SubElement(parent, 'aside')
-            aside.set('class', 'quote-block')
+            aside = etree.SubElement(parent, "aside")
+            aside.set("class", "quote-block")
 
-            heading = etree.SubElement(aside, 'div')
-            heading.set('class', 'quote-heading')
+            heading = etree.SubElement(aside, "div")
+            heading.set("class", "quote-heading")
 
-            blockquote = etree.SubElement(aside, 'blockquote')
-            blockquote.set('class', 'quote-body')
+            blockquote = etree.SubElement(aside, "blockquote")
+            blockquote.set("class", "quote-body")
 
             if title:
                 heading.text = title
@@ -110,13 +112,14 @@ class CodeBlockExtension(markdown.Extension):
         md.registerExtension(self)
 
         md.preprocessors.add(
-            'misago_code_bbcode', CodeBlockPreprocessor(md), ">normalize_whitespace"
+            "misago_code_bbcode", CodeBlockPreprocessor(md), ">normalize_whitespace"
         )
 
 
 class CodeBlockPreprocessor(FencedBlockPreprocessor):
     FENCED_BLOCK_RE = re.compile(
-        r'''
+        r"""
 \[code(=("?)(?P<lang>.*?)("?))?](([ ]*\n)+)?(?P<code>.*?)((\s|\n)+)?\[/code\]
-''', re.IGNORECASE | re.MULTILINE | re.DOTALL | re.VERBOSE
+""",
+        re.IGNORECASE | re.MULTILINE | re.DOTALL | re.VERBOSE,
     )

+ 16 - 10
misago/markup/bbcode/inline.py

@@ -4,7 +4,13 @@ Supported inline BBCodes: b, u, i
 import re
 
 from markdown.inlinepatterns import (
-    ImagePattern, LinkPattern, SimpleTagPattern, dequote, handleAttributes, util)
+    ImagePattern,
+    LinkPattern,
+    SimpleTagPattern,
+    dequote,
+    handleAttributes,
+    util,
+)
 
 
 class SimpleBBCodePattern(SimpleTagPattern):
@@ -13,7 +19,7 @@ class SimpleBBCodePattern(SimpleTagPattern):
     """
 
     def __init__(self, bbcode, tag=None):
-        self.pattern = r'(\[%s\](.*?)\[/%s\])' % (bbcode, bbcode)
+        self.pattern = r"(\[%s\](.*?)\[/%s\])" % (bbcode, bbcode)
         self.compiled_re = re.compile(
             "^(.*?)%s(.*?)$" % self.pattern, re.DOTALL | re.UNICODE | re.IGNORECASE
         )
@@ -25,9 +31,9 @@ class SimpleBBCodePattern(SimpleTagPattern):
         self.tag = tag or bbcode.lower()
 
 
-bold = SimpleBBCodePattern('b')
-italics = SimpleBBCodePattern('i')
-underline = SimpleBBCodePattern('u')
+bold = SimpleBBCodePattern("b")
+italics = SimpleBBCodePattern("i")
+underline = SimpleBBCodePattern("u")
 
 
 class BBcodePattern(object):
@@ -50,22 +56,22 @@ class BBCodeImagePattern(BBcodePattern, ImagePattern):
             src = src_parts[0]
             if src[0] == "<" and src[-1] == ">":
                 src = src[1:-1]
-            el.set('src', self.sanitize_url(self.unescape(src)))
+            el.set("src", self.sanitize_url(self.unescape(src)))
         else:
-            el.set('src', "")
+            el.set("src", "")
         if len(src_parts) > 1:
-            el.set('title', dequote(self.unescape(" ".join(src_parts[1:]))))
+            el.set("title", dequote(self.unescape(" ".join(src_parts[1:]))))
 
         if self.markdown.enable_attributes:
             truealt = handleAttributes(m.group(2), el)
         else:
             truealt = m.group(2)
 
-        el.set('alt', self.unescape(truealt))
+        el.set("alt", self.unescape(truealt))
         return el
 
 
-IMAGE_PATTERN = r'\[img\](.*?)\[/img\]'
+IMAGE_PATTERN = r"\[img\](.*?)\[/img\]"
 
 
 def image(md):

+ 1 - 1
misago/markup/checksums.py

@@ -27,7 +27,7 @@ def make_checksum(parsed, unique_values=None):
     unique_values = unique_values or []
     seeds = [parsed] + [str(v) for v in unique_values]
 
-    return sha256('+'.join(seeds).encode("utf-8")).hexdigest()
+    return sha256("+".join(seeds).encode("utf-8")).hexdigest()
 
 
 def is_checksum_valid(parsed, checksum, unique_values=None):

+ 3 - 3
misago/markup/context_processors.py

@@ -2,8 +2,8 @@ from django.urls import reverse
 
 
 def preload_api_url(request):
-    request.frontend_context.update({
-        'PARSE_MARKUP_API': reverse('misago:api:parse-markup'),
-    })
+    request.frontend_context.update(
+        {"PARSE_MARKUP_API": reverse("misago:api:parse-markup")}
+    )
 
     return {}

+ 5 - 4
misago/markup/finalise.py

@@ -4,9 +4,10 @@ from django.utils.translation import gettext as _
 
 
 HEADER_RE = re.compile(
-    r'''
+    r"""
 <div class="quote-heading">(?P<title>.*?)</div>
-'''.strip(), re.IGNORECASE | re.MULTILINE | re.DOTALL
+""".strip(),
+    re.IGNORECASE | re.MULTILINE | re.DOTALL,
 )
 
 
@@ -15,9 +16,9 @@ def finalise_markup(post):
 
 
 def replace_headers(matchobj):
-    title = matchobj.group('title')
+    title = matchobj.group("title")
     if title:
-        quote_title = _("%(title)s has written:") % {'title': title}
+        quote_title = _("%(title)s has written:") % {"title": title}
     else:
         quote_title = _("Quoted message:")
     return '<div class="quote-heading">%s</div>' % quote_title

+ 6 - 10
misago/markup/flavours.py

@@ -13,11 +13,7 @@ def common(request, poster, text, allow_mentions=True, force_shva=False):
     Returns dict object
     """
     return parse(
-        text,
-        request,
-        poster,
-        allow_mentions=allow_mentions,
-        force_shva=force_shva,
+        text, request, poster, allow_mentions=allow_mentions, force_shva=force_shva
     )
 
 
@@ -40,7 +36,7 @@ def limited(request, text):
         allow_blocks=False,
     )
 
-    return result['parsed_text']
+    return result["parsed_text"]
 
 
 def signature(request, owner, user_acl, text):
@@ -49,9 +45,9 @@ def signature(request, owner, user_acl, text):
         request,
         owner,
         allow_mentions=False,
-        allow_blocks=user_acl['allow_signature_blocks'],
-        allow_links=user_acl['allow_signature_links'],
-        allow_images=user_acl['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"]

+ 6 - 4
misago/markup/md/shortimgs.py

@@ -3,13 +3,15 @@ from markdown.inlinepatterns import LinkPattern
 from markdown.util import etree
 
 
-IMAGES_RE = r'\!(\s?)\((<.*?>|([^\)]*))\)'
+IMAGES_RE = r"\!(\s?)\((<.*?>|([^\)]*))\)"
 
 
 class ShortImagesExtension(markdown.Extension):
     def extendMarkdown(self, md):
         md.registerExtension(self)
-        md.inlinePatterns.add('misago_short_images', ShortImagePattern(IMAGES_RE, md), '_end')
+        md.inlinePatterns.add(
+            "misago_short_images", ShortImagePattern(IMAGES_RE, md), "_end"
+        )
 
 
 class ShortImagePattern(LinkPattern):
@@ -17,6 +19,6 @@ class ShortImagePattern(LinkPattern):
         img_src = m.groups()[2].strip()
         if img_src:
             img = etree.Element("img")
-            img.set('src', img_src)
-            img.set('alt', img_src)
+            img.set("src", img_src)
+            img.set("alt", img_src)
             return img

+ 2 - 2
misago/markup/md/striketrough.py

@@ -2,12 +2,12 @@ import markdown
 from markdown.inlinepatterns import SimpleTagPattern
 
 
-STRIKETROUGH_RE = r'(~{2})(.+?)\2'
+STRIKETROUGH_RE = r"(~{2})(.+?)\2"
 
 
 class StriketroughExtension(markdown.Extension):
     def extendMarkdown(self, md):
         md.registerExtension(self)
         md.inlinePatterns.add(
-            'misago_striketrough', SimpleTagPattern(STRIKETROUGH_RE, 'del'), '_end'
+            "misago_striketrough", SimpleTagPattern(STRIKETROUGH_RE, "del"), "_end"
         )

+ 10 - 10
misago/markup/mentions.py

@@ -5,36 +5,36 @@ from bs4 import BeautifulSoup
 from django.contrib.auth import get_user_model
 
 
-SUPPORTED_TAGS = ('h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'p')
-USERNAME_RE = re.compile(r'@[0-9a-z]+', re.IGNORECASE)
+SUPPORTED_TAGS = ("h1", "h2", "h3", "h4", "h5", "h6", "div", "p")
+USERNAME_RE = re.compile(r"@[0-9a-z]+", re.IGNORECASE)
 MENTIONS_LIMIT = 24
 
 
 def add_mentions(request, result):
-    if '@' not in result['parsed_text']:
+    if "@" not in result["parsed_text"]:
         return
 
     mentions_dict = {}
 
-    soup = BeautifulSoup(result['parsed_text'], 'html5lib')
+    soup = BeautifulSoup(result["parsed_text"], "html5lib")
 
     elements = []
     for tagname in SUPPORTED_TAGS:
-        if tagname in result['parsed_text']:
+        if tagname in result["parsed_text"]:
             elements += soup.find_all(tagname)
     for element in elements:
         add_mentions_to_element(request, element, mentions_dict)
 
-    result['parsed_text'] = str(soup.body)[6:-7].strip()
-    result['mentions'] = list(filter(bool, mentions_dict.values()))
+    result["parsed_text"] = str(soup.body)[6:-7].strip()
+    result["mentions"] = list(filter(bool, mentions_dict.values()))
 
 
 def add_mentions_to_element(request, element, mentions_dict):
     for item in element.contents:
         if item.name:
-            if item.name != 'a':
+            if item.name != "a":
                 add_mentions_to_element(request, item, mentions_dict)
-        elif '@' in item.string:
+        elif "@" in item.string:
             parse_string(request, item, mentions_dict)
 
 
@@ -64,4 +64,4 @@ def parse_string(request, element, mentions_dict):
             return matchobj.group(0)
 
     replaced_string = USERNAME_RE.sub(replace_mentions, element.string)
-    element.replace_with(BeautifulSoup(replaced_string, 'html.parser'))
+    element.replace_with(BeautifulSoup(replaced_string, "html.parser"))

+ 86 - 88
misago/markup/parser.py

@@ -18,19 +18,19 @@ from .mentions import add_mentions
 from .pipeline import pipeline
 
 
-MISAGO_ATTACHMENT_VIEWS = ('misago:attachment', 'misago:attachment-thumbnail')
+MISAGO_ATTACHMENT_VIEWS = ("misago:attachment", "misago:attachment-thumbnail")
 
 
 def parse(
-        text,
-        request,
-        poster,
-        allow_mentions=True,
-        allow_links=True,
-        allow_images=True,
-        allow_blocks=True,
-        force_shva=False,
-        minify=True
+    text,
+    request,
+    poster,
+    allow_mentions=True,
+    allow_links=True,
+    allow_images=True,
+    allow_blocks=True,
+    force_shva=False,
+    minify=True,
 ):
     """
     Message parser
@@ -43,26 +43,24 @@ def parse(
     Returns dict object
     """
     md = md_factory(
-        allow_links=allow_links,
-        allow_images=allow_images,
-        allow_blocks=allow_blocks,
+        allow_links=allow_links, allow_images=allow_images, allow_blocks=allow_blocks
     )
 
     parsing_result = {
-        'original_text': text,
-        'parsed_text': '',
-        'markdown': md,
-        'mentions': [],
-        'images': [],
-        'internal_links': [],
-        'outgoing_links': [],
+        "original_text": text,
+        "parsed_text": "",
+        "markdown": md,
+        "mentions": [],
+        "images": [],
+        "internal_links": [],
+        "outgoing_links": [],
     }
 
     # Parse text
     parsed_text = md.convert(text)
 
     # Clean and store parsed text
-    parsing_result['parsed_text'] = parsed_text.strip()
+    parsing_result["parsed_text"] = parsed_text.strip()
 
     if allow_links:
         linkify_paragraphs(parsing_result)
@@ -82,24 +80,22 @@ def parse(
 
 def md_factory(allow_links=True, allow_images=True, allow_blocks=True):
     """creates and configures markdown object"""
-    md = markdown.Markdown(extensions=[
-        'markdown.extensions.nl2br',
-    ])
+    md = markdown.Markdown(extensions=["markdown.extensions.nl2br"])
 
     # Remove HTML allowances
-    del md.preprocessors['html_block']
-    del md.inlinePatterns['html']
+    del md.preprocessors["html_block"]
+    del md.inlinePatterns["html"]
 
     # Remove references
-    del md.preprocessors['reference']
-    del md.inlinePatterns['reference']
-    del md.inlinePatterns['image_reference']
-    del md.inlinePatterns['short_reference']
+    del md.preprocessors["reference"]
+    del md.inlinePatterns["reference"]
+    del md.inlinePatterns["image_reference"]
+    del md.inlinePatterns["short_reference"]
 
     # Add [b], [i], [u]
-    md.inlinePatterns.add('bb_b', inline.bold, '<strong')
-    md.inlinePatterns.add('bb_i', inline.italics, '<emphasis')
-    md.inlinePatterns.add('bb_u', inline.underline, '<emphasis2')
+    md.inlinePatterns.add("bb_b", inline.bold, "<strong")
+    md.inlinePatterns.add("bb_i", inline.italics, "<emphasis")
+    md.inlinePatterns.add("bb_u", inline.underline, "<emphasis2")
 
     # Add ~~deleted~~
     striketrough_md = StriketroughExtension()
@@ -107,25 +103,27 @@ def md_factory(allow_links=True, allow_images=True, allow_blocks=True):
 
     if allow_links:
         # Add [url]
-        md.inlinePatterns.add('bb_url', inline.url(md), '<link')
+        md.inlinePatterns.add("bb_url", inline.url(md), "<link")
     else:
         # Remove links
-        del md.inlinePatterns['link']
-        del md.inlinePatterns['autolink']
-        del md.inlinePatterns['automail']
+        del md.inlinePatterns["link"]
+        del md.inlinePatterns["autolink"]
+        del md.inlinePatterns["automail"]
 
     if allow_images:
         # Add [img]
-        md.inlinePatterns.add('bb_img', inline.image(md), '<image_link')
+        md.inlinePatterns.add("bb_img", inline.image(md), "<image_link")
         short_images_md = ShortImagesExtension()
         short_images_md.extendMarkdown(md)
     else:
         # Remove images
-        del md.inlinePatterns['image_link']
+        del md.inlinePatterns["image_link"]
 
     if allow_blocks:
         # Add [hr] and [quote] blocks
-        md.parser.blockprocessors.add('bb_hr', blocks.BBCodeHRProcessor(md.parser), '>hr')
+        md.parser.blockprocessors.add(
+            "bb_hr", blocks.BBCodeHRProcessor(md.parser), ">hr"
+        )
 
         fenced_code = FencedCodeExtension()
         fenced_code.extendMarkdown(md, None)
@@ -137,22 +135,22 @@ def md_factory(allow_links=True, allow_images=True, allow_blocks=True):
         quote_bbcode.extendMarkdown(md)
     else:
         # Remove blocks
-        del md.parser.blockprocessors['hashheader']
-        del md.parser.blockprocessors['setextheader']
-        del md.parser.blockprocessors['code']
-        del md.parser.blockprocessors['quote']
-        del md.parser.blockprocessors['hr']
-        del md.parser.blockprocessors['olist']
-        del md.parser.blockprocessors['ulist']
+        del md.parser.blockprocessors["hashheader"]
+        del md.parser.blockprocessors["setextheader"]
+        del md.parser.blockprocessors["code"]
+        del md.parser.blockprocessors["quote"]
+        del md.parser.blockprocessors["hr"]
+        del md.parser.blockprocessors["olist"]
+        del md.parser.blockprocessors["ulist"]
 
     return pipeline.extend_markdown(md)
 
 
 def linkify_paragraphs(result):
-    result['parsed_text'] = bleach.linkify(
-        result['parsed_text'],
+    result["parsed_text"] = bleach.linkify(
+        result["parsed_text"],
         callbacks=settings.MISAGO_BLEACH_CALLBACKS,
-        skip_tags=['a', 'code', 'pre'],
+        skip_tags=["a", "code", "pre"],
         parse_email=True,
     )
 
@@ -160,95 +158,95 @@ def linkify_paragraphs(result):
 def clean_links(request, result, force_shva=False):
     host = request.get_host()
 
-    soup = BeautifulSoup(result['parsed_text'], 'html5lib')
-    for link in soup.find_all('a'):
-        if is_internal_link(link['href'], host):
-            link['href'] = clean_internal_link(link['href'], host)
-            result['internal_links'].append(link['href'])
-            link['href'] = clean_attachment_link(link['href'], force_shva)
+    soup = BeautifulSoup(result["parsed_text"], "html5lib")
+    for link in soup.find_all("a"):
+        if is_internal_link(link["href"], host):
+            link["href"] = clean_internal_link(link["href"], host)
+            result["internal_links"].append(link["href"])
+            link["href"] = clean_attachment_link(link["href"], force_shva)
         else:
-            result['outgoing_links'].append(clean_link_prefix(link['href']))
-            link['href'] = assert_link_prefix(link['href'])
-            link['rel'] = 'nofollow noopener'
+            result["outgoing_links"].append(clean_link_prefix(link["href"]))
+            link["href"] = assert_link_prefix(link["href"])
+            link["rel"] = "nofollow noopener"
 
         if link.string:
             link.string = clean_link_prefix(link.string)
 
-    for img in soup.find_all('img'):
-        img['alt'] = clean_link_prefix(img['alt'])
-        if is_internal_link(img['src'], host):
-            img['src'] = clean_internal_link(img['src'], host)
-            result['images'].append(img['src'])
-            img['src'] = clean_attachment_link(img['src'], force_shva)
+    for img in soup.find_all("img"):
+        img["alt"] = clean_link_prefix(img["alt"])
+        if is_internal_link(img["src"], host):
+            img["src"] = clean_internal_link(img["src"], host)
+            result["images"].append(img["src"])
+            img["src"] = clean_attachment_link(img["src"], force_shva)
         else:
-            result['images'].append(clean_link_prefix(img['src']))
-            img['src'] = assert_link_prefix(img['src'])
+            result["images"].append(clean_link_prefix(img["src"]))
+            img["src"] = assert_link_prefix(img["src"])
 
     # [6:-7] trims <body></body> wrap
-    result['parsed_text'] = str(soup.body)[6:-7]
+    result["parsed_text"] = str(soup.body)[6:-7]
 
 
 def is_internal_link(link, host):
-    if link.startswith('/') and not link.startswith('//'):
+    if link.startswith("/") and not link.startswith("//"):
         return True
 
-    link = clean_link_prefix(link).lstrip('www.').lower()
-    return link.lower().startswith(host.lstrip('www.'))
+    link = clean_link_prefix(link).lstrip("www.").lower()
+    return link.lower().startswith(host.lstrip("www."))
 
 
 def clean_link_prefix(link):
-    if link.lower().startswith('https:'):
+    if link.lower().startswith("https:"):
         link = link[6:]
-    if link.lower().startswith('http:'):
+    if link.lower().startswith("http:"):
         link = link[5:]
-    if link.startswith('//'):
+    if link.startswith("//"):
         link = link[2:]
     return link
 
 
 def assert_link_prefix(link):
-    if link.lower().startswith('https:'):
+    if link.lower().startswith("https:"):
         return link
-    if link.lower().startswith('http:'):
+    if link.lower().startswith("http:"):
         return link
-    if link.startswith('//'):
-        return 'http:%s' % link
+    if link.startswith("//"):
+        return "http:%s" % link
 
-    return 'http://%s' % link
+    return "http://%s" % link
 
 
 def clean_internal_link(link, host):
     link = clean_link_prefix(link)
 
-    if link.lower().startswith('www.'):
+    if link.lower().startswith("www."):
         link = link[4:]
-    if host.lower().startswith('www.'):
+    if host.lower().startswith("www."):
         host = host[4:]
 
     if link.lower().startswith(host):
-        link = link[len(host):]
+        link = link[len(host) :]
 
-    return link or '/'
+    return link or "/"
 
 
 def clean_attachment_link(link, force_shva=False):
     try:
         resolution = resolve(link)
-        url_name = ':'.join(resolution.namespaces + [resolution.url_name])
+        url_name = ":".join(resolution.namespaces + [resolution.url_name])
     except (Http404, ValueError):
         return link
 
     if url_name in MISAGO_ATTACHMENT_VIEWS:
         if force_shva:
-            link = '%s?shva=1' % link
-        elif link.endswith('?shva=1'):
+            link = "%s?shva=1" % link
+        elif link.endswith("?shva=1"):
             link = link[:-7]
     return link
 
 
 def minify_result(result):
-    result['parsed_text'] = html_minify(result['parsed_text'])
-    result['parsed_text'] = strip_html_head_body(result['parsed_text'])
+    result["parsed_text"] = html_minify(result["parsed_text"])
+    result["parsed_text"] = strip_html_head_body(result["parsed_text"])
 
 
 def strip_html_head_body(parsed_text):

+ 6 - 6
misago/markup/pipeline.py

@@ -11,21 +11,21 @@ class MarkupPipeline(object):
     def extend_markdown(self, md):
         for extension in settings.MISAGO_MARKUP_EXTENSIONS:
             module = import_module(extension)
-            if hasattr(module, 'extend_markdown'):
-                hook = getattr(module, 'extend_markdown')
+            if hasattr(module, "extend_markdown"):
+                hook = getattr(module, "extend_markdown")
                 hook.extend_markdown(md)
         return md
 
     def process_result(self, result):
-        soup = BeautifulSoup(result['parsed_text'], 'html5lib')
+        soup = BeautifulSoup(result["parsed_text"], "html5lib")
         for extension in settings.MISAGO_MARKUP_EXTENSIONS:
             module = import_module(extension)
-            if hasattr(module, 'clean_parsed'):
-                hook = getattr(module, 'clean_parsed')
+            if hasattr(module, "clean_parsed"):
+                hook = getattr(module, "clean_parsed")
                 hook.process_result(result, soup)
 
         souped_text = str(soup.body).strip()[6:-7]
-        result['parsed_text'] = souped_text.strip()
+        result["parsed_text"] = souped_text.strip()
         return result
 
 

+ 1 - 1
misago/markup/templatetags/misago_editor.py

@@ -8,7 +8,7 @@ register = template.Library()
 
 def _render_editor_template(context, editor, tpl):
     c = Context(context)
-    c['editor'] = editor
+    c["editor"] = editor
 
     return get_template(tpl).render(c)
 

+ 46 - 39
misago/markup/tests/test_api.py

@@ -7,7 +7,7 @@ class ParseMarkupApiTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.api_link = reverse('misago:api:parse-markup')
+        self.api_link = reverse("misago:api:parse-markup")
 
     def test_is_anonymous(self):
         """api requires authentication"""
@@ -15,71 +15,78 @@ class ParseMarkupApiTests(AuthenticatedUserTestCase):
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This action is not available to guests.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
 
     def test_no_data(self):
         """api handles no data"""
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to enter a message.",
-        })
+        self.assertEqual(response.json(), {"detail": "You have to enter a message."})
 
     def test_invalid_data(self):
         """api handles post that is invalid type"""
-        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.json(), {
-            "detail": "Invalid data. Expected a dictionary, but got list.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Invalid data. Expected a dictionary, but got list."},
+        )
 
-        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.json(), {
-            "detail": "Invalid data. Expected a dictionary, but got int.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Invalid data. Expected a dictionary, but got int."},
+        )
 
-        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.json(), {
-            "detail": "Invalid data. Expected a dictionary, but got str.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Invalid data. Expected a dictionary, but got str."},
+        )
 
-        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.json(), {
-            "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)"},
+        )
 
     def test_empty_post(self):
         """api handles empty post"""
-        response = self.client.post(self.api_link, {'post': ''})
+        response = self.client.post(self.api_link, {"post": ""})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to enter a message.",
-        })
+        self.assertEqual(response.json(), {"detail": "You have to enter a message."})
 
         # regression test for #929
-        response = self.client.post(self.api_link, {'post': '\n'})
+        response = self.client.post(self.api_link, {"post": "\n"})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to enter a message.",
-        })
+        self.assertEqual(response.json(), {"detail": "You have to enter a message."})
 
     def test_invalid_post(self):
         """api handles invalid post type"""
-        response = self.client.post(self.api_link, {'post': 123})
+        response = self.client.post(self.api_link, {"post": 123})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Posted message should be at least 5 characters long (it has 3).",
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "Posted message should be at least 5 characters long (it has 3)."
+            },
+        )
 
     def test_valid_post(self):
         """api returns parsed markup for valid post"""
-        response = self.client.post(self.api_link, {'post': 'Lorem ipsum dolor met!'})
+        response = self.client.post(self.api_link, {"post": "Lorem ipsum dolor met!"})
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            "parsed": "<p>Lorem ipsum dolor met!</p>",
-        })
+        self.assertEqual(response.json(), {"parsed": "<p>Lorem ipsum dolor met!</p>"})

+ 8 - 8
misago/markup/tests/test_finalise.py

@@ -6,7 +6,7 @@ from misago.markup.finalise import finalise_markup
 class QuoteTests(TestCase):
     def test_finalise_markup(self):
         """quote header is replaced"""
-        test_text = '''
+        test_text = """
 <p>Lorem ipsum.</p>
 <aside class="quote-block">
 <div class="quote-heading"></div>
@@ -21,9 +21,9 @@ class QuoteTests(TestCase):
 </blockquote>
 </aside>
 <p>Lorem ipsum dolor.</p>
-'''.strip()
+""".strip()
 
-        expected_result = '''
+        expected_result = """
 <p>Lorem ipsum.</p>
 <aside class="quote-block">
 <div class="quote-heading">Quoted message:</div>
@@ -38,18 +38,18 @@ class QuoteTests(TestCase):
 </blockquote>
 </aside>
 <p>Lorem ipsum dolor.</p>
-'''.strip()
+""".strip()
 
         self.assertEqual(expected_result, finalise_markup(test_text))
 
     def test_finalise_minified_markup(self):
         """header is replaced in minified post"""
-        test_text = '''
+        test_text = """
 <p>Lorem ipsum.</p><aside class="quote-block"><div class="quote-heading"></div><blockquote class="quote-body"><p>Dolor met</p><aside class="quote-block"><div class="quote-heading"><a href="/users/bob-1/">@Bob</a></div><blockquote class="quote-body"><p>Dolor met</p></blockquote></aside></blockquote></aside><p>Lorem ipsum dolor.</p>
-'''.strip()
+""".strip()
 
-        expected_result = '''
+        expected_result = """
 <p>Lorem ipsum.</p><aside class="quote-block"><div class="quote-heading">Quoted message:</div><blockquote class="quote-body"><p>Dolor met</p><aside class="quote-block"><div class="quote-heading"><a href="/users/bob-1/">@Bob</a> has written:</div><blockquote class="quote-body"><p>Dolor met</p></blockquote></aside></blockquote></aside><p>Lorem ipsum dolor.</p>
-'''.strip()
+""".strip()
 
         self.assertEqual(expected_result, finalise_markup(test_text))

+ 31 - 24
misago/markup/tests/test_mentions.py

@@ -11,69 +11,76 @@ class MentionsTests(AuthenticatedUserTestCase):
     def test_single_mention(self):
         """markup extension parses single mention"""
         TEST_CASES = [
-            ('<p>Hello, @%s!</p>', '<p>Hello, <a href="%s">@%s</a>!</p>'),
-            ('<h1>Hello, @%s!</h1>', '<h1>Hello, <a href="%s">@%s</a>!</h1>'),
-            ('<div>Hello, @%s!</div>', '<div>Hello, <a href="%s">@%s</a>!</div>'),
+            ("<p>Hello, @%s!</p>", '<p>Hello, <a href="%s">@%s</a>!</p>'),
+            ("<h1>Hello, @%s!</h1>", '<h1>Hello, <a href="%s">@%s</a>!</h1>'),
+            ("<div>Hello, @%s!</div>", '<div>Hello, <a href="%s">@%s</a>!</div>'),
             (
-                '<h1>Hello, <strong>@%s!</strong></h1>',
-                '<h1>Hello, <strong><a href="%s">@%s</a>!</strong></h1>'
+                "<h1>Hello, <strong>@%s!</strong></h1>",
+                '<h1>Hello, <strong><a href="%s">@%s</a>!</strong></h1>',
             ),
             (
-                '<h1>Hello, <strong>@%s</strong>!</h1>',
-                '<h1>Hello, <strong><a href="%s">@%s</a></strong>!</h1>'
+                "<h1>Hello, <strong>@%s</strong>!</h1>",
+                '<h1>Hello, <strong><a href="%s">@%s</a></strong>!</h1>',
             ),
         ]
 
         for before, after in TEST_CASES:
-            result = {'parsed_text': before % self.user.username, 'mentions': []}
+            result = {"parsed_text": before % self.user.username, "mentions": []}
 
             add_mentions(MockRequest(self.user), result)
 
-            expected_outcome = after % (self.user.get_absolute_url(), self.user.username)
-            self.assertEqual(result['parsed_text'], expected_outcome)
-            self.assertEqual(result['mentions'], [self.user])
+            expected_outcome = after % (
+                self.user.get_absolute_url(),
+                self.user.username,
+            )
+            self.assertEqual(result["parsed_text"], expected_outcome)
+            self.assertEqual(result["mentions"], [self.user])
 
     def test_invalid_mentions(self):
         """markup extension leaves invalid mentions alone"""
         TEST_CASES = [
-            '<p>Hello, Bob!</p>',
-            '<p>Hello, @Bob!</p>',
+            "<p>Hello, Bob!</p>",
+            "<p>Hello, @Bob!</p>",
             '<p>Hello, <a href="/">@%s</a>!</p>' % self.user.username,
             '<p>Hello, <a href="/"><b>@%s</b></a>!</p>' % self.user.username,
         ]
 
         for markup in TEST_CASES:
-            result = {'parsed_text': markup, 'mentions': []}
+            result = {"parsed_text": markup, "mentions": []}
 
             add_mentions(MockRequest(self.user), result)
 
-            self.assertEqual(result['parsed_text'], markup)
-            self.assertFalse(result['mentions'])
+            self.assertEqual(result["parsed_text"], markup)
+            self.assertFalse(result["mentions"])
 
     def test_multiple_mentions(self):
         """markup extension handles multiple mentions"""
-        before = '<p>Hello @{0} and @{0}, how is it going?</p>'.format(self.user.username)
+        before = "<p>Hello @{0} and @{0}, how is it going?</p>".format(
+            self.user.username
+        )
 
         after = '<p>Hello <a href="{0}">@{1}</a> and <a href="{0}">@{1}</a>, how is it going?</p>'.format(
             self.user.get_absolute_url(), self.user.username
         )
 
-        result = {'parsed_text': before, 'mentions': []}
+        result = {"parsed_text": before, "mentions": []}
 
         add_mentions(MockRequest(self.user), result)
-        self.assertEqual(result['parsed_text'], after)
-        self.assertEqual(result['mentions'], [self.user])
+        self.assertEqual(result["parsed_text"], after)
+        self.assertEqual(result["mentions"], [self.user])
 
     def test_repeated_mention(self):
         """markup extension handles mentions across document"""
-        before = '<p>Hello @{0}</p><p>@{0}, how is it going?</p>'.format(self.user.username)
+        before = "<p>Hello @{0}</p><p>@{0}, how is it going?</p>".format(
+            self.user.username
+        )
 
         after = '<p>Hello <a href="{0}">@{1}</a></p><p><a href="{0}">@{1}</a>, how is it going?</p>'.format(
             self.user.get_absolute_url(), self.user.username
         )
 
-        result = {'parsed_text': before, 'mentions': []}
+        result = {"parsed_text": before, "mentions": []}
 
         add_mentions(MockRequest(self.user), result)
-        self.assertEqual(result['parsed_text'], after)
-        self.assertEqual(result['mentions'], [self.user])
+        self.assertEqual(result["parsed_text"], after)
+        self.assertEqual(result["mentions"], [self.user])

+ 85 - 75
misago/markup/tests/test_parser.py

@@ -8,18 +8,18 @@ UserModel = get_user_model()
 
 
 class MockRequest(object):
-    scheme = 'http'
+    scheme = "http"
 
     def __init__(self, user=None):
         self.user = user
 
     def get_host(self):
-        return 'test.com'
+        return "test.com"
 
 
 class MockPoster(object):
-    username = 'LoremIpsum'
-    slug = 'loremipsum'
+    username = "LoremIpsum"
+    slug = "loremipsum"
 
 
 class HTMLTests(TestCase):
@@ -34,10 +34,10 @@ Lorem <strong>ipsum!</strong>
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
-        self.assertEqual(result['internal_links'], [])
-        self.assertEqual(result['images'], [])
-        self.assertEqual(result['outgoing_links'], [])
+        self.assertEqual(expected_result, result["parsed_text"])
+        self.assertEqual(result["internal_links"], [])
+        self.assertEqual(result["images"], [])
+        self.assertEqual(result["outgoing_links"], [])
 
 
 class BBCodeTests(TestCase):
@@ -73,7 +73,7 @@ Lorem [b]ipsum[/B].
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
     def test_hr(self):
         """hr bbcode is correctly parsed"""
@@ -90,7 +90,7 @@ Dolor met.
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
     def test_img(self):
         """img bbcode is correctly parsed"""
@@ -109,7 +109,7 @@ Lorem ipsum !(https://placekitten.com/g/1200/500)
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
     def test_url(self):
         """url bbcode is correctly parsed"""
@@ -131,7 +131,7 @@ Lorem ipsum [Lorem ipsum](https://placekitten.com/g/1200/500)
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
 
 class MinifyTests(TestCase):
@@ -148,7 +148,7 @@ Lorem ipsum.
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
     def test_minified_unicode_text(self):
         """parser minifies unicode text successfully"""
@@ -163,22 +163,28 @@ Lorem ipsum.
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
     def test_complex_paragraph(self):
         """parser minifies complex paragraph"""
-        user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass123')
+        user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass123")
 
-        test_text = """
+        test_text = (
+            """
 Hey there @%s, how's going?
-""".strip() % user
+""".strip()
+            % user
+        )
 
         expected_result = """
 <p>Hey there <a href="%s">@%s</a>, how's going?</p>
-""".strip() % (user.get_absolute_url(), user)
+""".strip() % (
+            user.get_absolute_url(),
+            user,
+        )
 
         result = parse(test_text, MockRequest(user), user, minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
 
 class CleanLinksTests(TestCase):
@@ -193,10 +199,10 @@ Lorem ipsum: http://test.com
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
-        self.assertEqual(result['internal_links'], ['/'])
-        self.assertEqual(result['images'], [])
-        self.assertEqual(result['outgoing_links'], [])
+        self.assertEqual(expected_result, result["parsed_text"])
+        self.assertEqual(result["internal_links"], ["/"])
+        self.assertEqual(result["images"], [])
+        self.assertEqual(result["outgoing_links"], [])
 
     def test_clean_schemaless_link(self):
         """clean_links step cleans test.com"""
@@ -209,10 +215,10 @@ Lorem ipsum: test.com
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
-        self.assertEqual(result['internal_links'], ['/'])
-        self.assertEqual(result['images'], [])
-        self.assertEqual(result['outgoing_links'], [])
+        self.assertEqual(expected_result, result["parsed_text"])
+        self.assertEqual(result["internal_links"], ["/"])
+        self.assertEqual(result["images"], [])
+        self.assertEqual(result["outgoing_links"], [])
 
     def test_trim_current_path(self):
         """clean_links step leaves http://test.com path"""
@@ -225,10 +231,10 @@ Lorem ipsum: http://test.com/somewhere-something/
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
-        self.assertEqual(result['internal_links'], ['/somewhere-something/'])
-        self.assertEqual(result['images'], [])
-        self.assertEqual(result['outgoing_links'], [])
+        self.assertEqual(expected_result, result["parsed_text"])
+        self.assertEqual(result["internal_links"], ["/somewhere-something/"])
+        self.assertEqual(result["images"], [])
+        self.assertEqual(result["outgoing_links"], [])
 
     def test_clean_outgoing_link_domain(self):
         """clean_links step leaves outgoing domain link"""
@@ -241,10 +247,10 @@ Lorem ipsum: http://somewhere.com
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
-        self.assertEqual(result['outgoing_links'], ['somewhere.com'])
-        self.assertEqual(result['images'], [])
-        self.assertEqual(result['internal_links'], [])
+        self.assertEqual(expected_result, result["parsed_text"])
+        self.assertEqual(result["outgoing_links"], ["somewhere.com"])
+        self.assertEqual(result["images"], [])
+        self.assertEqual(result["internal_links"], [])
 
     def test_trim_outgoing_path(self):
         """clean_links step leaves outgoing link domain and path"""
@@ -257,10 +263,12 @@ Lorem ipsum: http://somewhere.com/somewhere-something/
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
-        self.assertEqual(result['outgoing_links'], ['somewhere.com/somewhere-something/'])
-        self.assertEqual(result['images'], [])
-        self.assertEqual(result['internal_links'], [])
+        self.assertEqual(expected_result, result["parsed_text"])
+        self.assertEqual(
+            result["outgoing_links"], ["somewhere.com/somewhere-something/"]
+        )
+        self.assertEqual(result["images"], [])
+        self.assertEqual(result["internal_links"], [])
 
     def test_clean_local_image_src(self):
         """clean_links step cleans local image src"""
@@ -273,10 +281,10 @@ Lorem ipsum: http://somewhere.com/somewhere-something/
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
-        self.assertEqual(result['images'], ['/image.jpg'])
-        self.assertEqual(result['internal_links'], [])
-        self.assertEqual(result['outgoing_links'], [])
+        self.assertEqual(expected_result, result["parsed_text"])
+        self.assertEqual(result["images"], ["/image.jpg"])
+        self.assertEqual(result["internal_links"], [])
+        self.assertEqual(result["outgoing_links"], [])
 
     def test_clean_remote_image_src(self):
         """clean_links step cleans remote image src"""
@@ -289,10 +297,10 @@ Lorem ipsum: http://somewhere.com/somewhere-something/
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
-        self.assertEqual(result['images'], ['somewhere.com/image.jpg'])
-        self.assertEqual(result['internal_links'], [])
-        self.assertEqual(result['outgoing_links'], [])
+        self.assertEqual(expected_result, result["parsed_text"])
+        self.assertEqual(result["images"], ["somewhere.com/image.jpg"])
+        self.assertEqual(result["internal_links"], [])
+        self.assertEqual(result["outgoing_links"], [])
 
     def test_clean_linked_image(self):
         """parser handles image element nested in link"""
@@ -305,10 +313,10 @@ Lorem ipsum: http://somewhere.com/somewhere-something/
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
-        self.assertEqual(result['images'], ['/a/thumb/test/43/'])
-        self.assertEqual(result['internal_links'], ['/a/test/43/'])
-        self.assertEqual(result['outgoing_links'], [])
+        self.assertEqual(expected_result, result["parsed_text"])
+        self.assertEqual(result["images"], ["/a/thumb/test/43/"])
+        self.assertEqual(result["internal_links"], ["/a/test/43/"])
+        self.assertEqual(result["outgoing_links"], [])
 
     def test_force_shva(self):
         """parser appends ?shva=1 bit to attachment links if flag is present"""
@@ -320,11 +328,13 @@ Lorem ipsum: http://somewhere.com/somewhere-something/
 <p><img alt="3.png" src="/a/thumb/test/43/?shva=1"/></p>
 """.strip()
 
-        result = parse(test_text, MockRequest(), MockPoster(), minify=True, force_shva=True)
-        self.assertEqual(expected_result, result['parsed_text'])
-        self.assertEqual(result['images'], ['/a/thumb/test/43/'])
-        self.assertEqual(result['internal_links'], [])
-        self.assertEqual(result['outgoing_links'], [])
+        result = parse(
+            test_text, MockRequest(), MockPoster(), minify=True, force_shva=True
+        )
+        self.assertEqual(expected_result, result["parsed_text"])
+        self.assertEqual(result["images"], ["/a/thumb/test/43/"])
+        self.assertEqual(result["internal_links"], [])
+        self.assertEqual(result["outgoing_links"], [])
 
     def test_remove_shva(self):
         """parser removes ?shva=1 bit from attachment links if flag is absent"""
@@ -337,10 +347,10 @@ Lorem ipsum: http://somewhere.com/somewhere-something/
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
-        self.assertEqual(result['images'], ['/a/thumb/test/43/?shva=1'])
-        self.assertEqual(result['internal_links'], [])
-        self.assertEqual(result['outgoing_links'], [])
+        self.assertEqual(expected_result, result["parsed_text"])
+        self.assertEqual(result["images"], ["/a/thumb/test/43/?shva=1"])
+        self.assertEqual(result["internal_links"], [])
+        self.assertEqual(result["outgoing_links"], [])
 
 
 class LinkifyTests(TestCase):
@@ -355,10 +365,10 @@ Lorem ipsum: `<http://test.com>`
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=True)
-        self.assertEqual(expected_result, result['parsed_text'])
-        self.assertEqual(result['internal_links'], [])
-        self.assertEqual(result['images'], [])
-        self.assertEqual(result['outgoing_links'], [])
+        self.assertEqual(expected_result, result["parsed_text"])
+        self.assertEqual(result["internal_links"], [])
+        self.assertEqual(result["images"], [])
+        self.assertEqual(result["outgoing_links"], [])
 
 
 class StriketroughTests(TestCase):
@@ -373,7 +383,7 @@ Lorem ~~ipsum, dolor~~ met.
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
 
 class QuoteTests(TestCase):
@@ -415,7 +425,7 @@ Lorem ipsum.
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
     def test_authored_quotes(self):
         """bbcode for authored quote is supported and handles mentions as well"""
@@ -456,7 +466,7 @@ Lorem ipsum.
 
         request = MockRequest(user=MockPoster())
         result = parse(test_text, request, MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
     def test_custom_quote_title(self):
         """parser handles custom quotetitle"""
@@ -476,7 +486,7 @@ Lorem ipsum.
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
     def test_hr_edge_case(self):
         """test for weird edge case in which hr gets moved outside of quote"""
@@ -502,7 +512,7 @@ Amet elit
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
 
 class CodeTests(TestCase):
@@ -521,7 +531,7 @@ Dolor [b]met.[/b]
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
     def test_inline_code(self):
         """inline code bbcode is correctly parsed"""
@@ -537,7 +547,7 @@ Lorem ipsum.
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
     def test_code_strip(self):
         """code bbcode trims its content"""
@@ -557,7 +567,7 @@ Lorem ipsum.
 <pre><code>   Dolor [b]met.[/b]</code></pre>
 """.strip()
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
     def test_code_language(self):
         """code bbcode with language is correctly parsed"""
@@ -575,7 +585,7 @@ Dolor [b]met.[/b]
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
     def test_code_language_optional_quotes(self):
         """code quotes around language name are optional"""
@@ -593,7 +603,7 @@ Dolor [b]met.[/b]
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])
 
         test_text = """
 Lorem ipsum.
@@ -609,4 +619,4 @@ Dolor [b]met.[/b]
 """.strip()
 
         result = parse(test_text, MockRequest(), MockPoster(), minify=False)
-        self.assertEqual(expected_result, result['parsed_text'])
+        self.assertEqual(expected_result, result["parsed_text"])

+ 1 - 1
misago/markup/urls.py

@@ -3,4 +3,4 @@ from django.conf.urls import url
 from .api import parse_markup
 
 
-urlpatterns = [url(r'^parse-markup/$', parse_markup, name='parse-markup')]
+urlpatterns = [url(r"^parse-markup/$", parse_markup, name="parse-markup")]

+ 1 - 1
misago/readtracker/__init__.py

@@ -1 +1 @@
-default_app_config = 'misago.readtracker.apps.MisagoReadTrackerConfig'
+default_app_config = "misago.readtracker.apps.MisagoReadTrackerConfig"

+ 2 - 2
misago/readtracker/apps.py

@@ -2,8 +2,8 @@ from django.apps import AppConfig
 
 
 class MisagoReadTrackerConfig(AppConfig):
-    name = 'misago.readtracker'
-    label = 'misago_readtracker'
+    name = "misago.readtracker"
+    label = "misago_readtracker"
     verbose_name = "Misago Read Tracker"
 
     def ready(self):

+ 16 - 9
misago/readtracker/categoriestracker.py

@@ -1,5 +1,8 @@
 from misago.threads.models import Post, Thread
-from misago.threads.permissions import exclude_invisible_posts, exclude_invisible_threads
+from misago.threads.permissions import (
+    exclude_invisible_posts,
+    exclude_invisible_threads,
+)
 
 from .dates import get_cutoff_date
 
@@ -8,7 +11,7 @@ def make_read_aware(user, user_acl, categories):
     if not categories:
         return
 
-    if not hasattr(categories, '__iter__'):
+    if not hasattr(categories, "__iter__"):
         categories = [categories]
 
     make_read(categories)
@@ -19,13 +22,17 @@ def make_read_aware(user, user_acl, categories):
     threads = Thread.objects.filter(category__in=categories)
     threads = exclude_invisible_threads(user_acl, categories, threads)
 
-    queryset = Post.objects.filter(
-        category__in=categories,
-        thread__in=threads,
-        posted_on__gt=get_cutoff_date(user),
-    ).values_list('category', flat=True).distinct()
-
-    queryset = queryset.exclude(id__in=user.postread_set.values('post'))
+    queryset = (
+        Post.objects.filter(
+            category__in=categories,
+            thread__in=threads,
+            posted_on__gt=get_cutoff_date(user),
+        )
+        .values_list("category", flat=True)
+        .distinct()
+    )
+
+    queryset = queryset.exclude(id__in=user.postread_set.values("post"))
     queryset = exclude_invisible_posts(user_acl, categories, queryset)
 
     unread_categories = list(queryset)

+ 1 - 3
misago/readtracker/dates.py

@@ -6,9 +6,7 @@ from misago.conf import settings
 
 
 def get_cutoff_date(user=None):
-    cutoff_date = timezone.now() - timedelta(
-        days=settings.MISAGO_READTRACKER_CUTOFF,
-    )
+    cutoff_date = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
 
     if user and user.is_authenticated and user.joined_on > cutoff_date:
         return user.joined_on

+ 39 - 26
misago/readtracker/migrations/0001_initial.py

@@ -9,64 +9,77 @@ class Migration(migrations.Migration):
 
     dependencies = [
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-        ('misago_threads', '0001_initial'),
+        ("misago_threads", "0001_initial"),
     ]
 
     operations = [
         migrations.CreateModel(
-            name='CategoryRead',
+            name="CategoryRead",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
                 ),
-                ('last_read_on', models.DateTimeField()),
+                ("last_read_on", models.DateTimeField()),
                 (
-                    'category', models.ForeignKey(
+                    "category",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_categories.Category',
-                    )
+                        to="misago_categories.Category",
+                    ),
                 ),
                 (
-                    'user', models.ForeignKey(
+                    "user",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
                         to=settings.AUTH_USER_MODEL,
-                    )
+                    ),
                 ),
             ],
             options={},
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
         migrations.CreateModel(
-            name='ThreadRead',
+            name="ThreadRead",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
                 ),
-                ('last_read_on', models.DateTimeField()),
+                ("last_read_on", models.DateTimeField()),
                 (
-                    'category', models.ForeignKey(
+                    "category",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_categories.Category',
-                    )
+                        to="misago_categories.Category",
+                    ),
                 ),
                 (
-                    'thread', models.ForeignKey(
+                    "thread",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_threads.Thread',
-                    )
+                        to="misago_threads.Thread",
+                    ),
                 ),
                 (
-                    'user', models.ForeignKey(
+                    "user",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
                         to=settings.AUTH_USER_MODEL,
-                    )
+                    ),
                 ),
             ],
             options={},
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
     ]

+ 46 - 11
misago/readtracker/migrations/0002_postread.py

@@ -8,22 +8,57 @@ import django.utils.timezone
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('misago_categories', '0006_moderation_queue_roles'),
+        ("misago_categories", "0006_moderation_queue_roles"),
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-        ('misago_threads', '0006_redo_partial_indexes'),
-        ('misago_readtracker', '0001_initial'),
+        ("misago_threads", "0006_redo_partial_indexes"),
+        ("misago_readtracker", "0001_initial"),
     ]
 
     operations = [
         migrations.CreateModel(
-            name='PostRead',
+            name="PostRead",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('last_read_on', models.DateTimeField(default=django.utils.timezone.now)),
-                ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_categories.Category')),
-                ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Post')),
-                ('thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread')),
-                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "last_read_on",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                (
+                    "category",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="misago_categories.Category",
+                    ),
+                ),
+                (
+                    "post",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="misago_threads.Post",
+                    ),
+                ),
+                (
+                    "thread",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="misago_threads.Thread",
+                    ),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
             ],
-        ),
+        )
     ]

+ 9 - 15
misago/readtracker/migrations/0003_migrate_reads_to_posts.py

@@ -17,11 +17,11 @@ except AttributeError:
 def populate_posts_reads(apps, schema_editor):
     reads_cutoff = timezone.now() - timedelta(days=READS_CUTOFF)
 
-    Post = apps.get_model('misago_threads', 'Post')
+    Post = apps.get_model("misago_threads", "Post")
 
-    CategoryRead = apps.get_model('misago_readtracker', 'CategoryRead')
-    ThreadRead = apps.get_model('misago_readtracker', 'ThreadRead')
-    PostRead = apps.get_model('misago_readtracker', 'PostRead')
+    CategoryRead = apps.get_model("misago_readtracker", "CategoryRead")
+    ThreadRead = apps.get_model("misago_readtracker", "ThreadRead")
+    PostRead = apps.get_model("misago_readtracker", "PostRead")
 
     migrated_reads = {}
 
@@ -29,8 +29,7 @@ def populate_posts_reads(apps, schema_editor):
     queryset = CategoryRead.objects.select_related().iterator()
     for category_read in queryset:
         posts_queryset = Post.objects.filter(
-            category=category_read.category,
-            posted_on__gte=reads_cutoff,
+            category=category_read.category, posted_on__gte=reads_cutoff
         )
 
         for post in posts_queryset.iterator():
@@ -51,8 +50,7 @@ def populate_posts_reads(apps, schema_editor):
     queryset = ThreadRead.objects.select_related().iterator()
     for thread_read in queryset:
         posts_queryset = Post.objects.filter(
-            thread=thread_read.thread,
-            posted_on__gte=reads_cutoff,
+            thread=thread_read.thread, posted_on__gte=reads_cutoff
         )
 
         for post in posts_queryset.iterator():
@@ -69,16 +67,12 @@ def populate_posts_reads(apps, schema_editor):
 
 
 def noop_reverse(apps, schema_editor):
-    PostRead = apps.get_model('misago_readtracker', 'PostRead')
+    PostRead = apps.get_model("misago_readtracker", "PostRead")
     PostRead.objects.all().delete()
 
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_readtracker', '0002_postread'),
-    ]
+    dependencies = [("misago_readtracker", "0002_postread")]
 
-    operations = [
-        migrations.RunPython(populate_posts_reads, noop_reverse),
-    ]
+    operations = [migrations.RunPython(populate_posts_reads, noop_reverse)]

+ 8 - 29
misago/readtracker/migrations/0004_auto_20171015_2010.py

@@ -4,35 +4,14 @@ from django.db import migrations
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_readtracker', '0003_migrate_reads_to_posts'),
-    ]
+    dependencies = [("misago_readtracker", "0003_migrate_reads_to_posts")]
 
     operations = [
-        migrations.RemoveField(
-            model_name='categoryread',
-            name='category',
-        ),
-        migrations.RemoveField(
-            model_name='categoryread',
-            name='user',
-        ),
-        migrations.RemoveField(
-            model_name='threadread',
-            name='category',
-        ),
-        migrations.RemoveField(
-            model_name='threadread',
-            name='thread',
-        ),
-        migrations.RemoveField(
-            model_name='threadread',
-            name='user',
-        ),
-        migrations.DeleteModel(
-            name='CategoryRead',
-        ),
-        migrations.DeleteModel(
-            name='ThreadRead',
-        ),
+        migrations.RemoveField(model_name="categoryread", name="category"),
+        migrations.RemoveField(model_name="categoryread", name="user"),
+        migrations.RemoveField(model_name="threadread", name="category"),
+        migrations.RemoveField(model_name="threadread", name="thread"),
+        migrations.RemoveField(model_name="threadread", name="user"),
+        migrations.DeleteModel(name="CategoryRead"),
+        migrations.DeleteModel(name="ThreadRead"),
     ]

+ 4 - 16
misago/readtracker/models.py

@@ -4,20 +4,8 @@ from django.utils import timezone
 
 
 class PostRead(models.Model):
-    user = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        on_delete=models.CASCADE,
-    )
-    category = models.ForeignKey(
-        'misago_categories.Category',
-        on_delete=models.CASCADE,
-    )
-    thread = models.ForeignKey(
-        'misago_threads.Thread',
-        on_delete=models.CASCADE,
-    )
-    post = models.ForeignKey(
-        'misago_threads.Post',
-        on_delete=models.CASCADE,
-    )
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+    category = models.ForeignKey("misago_categories.Category", on_delete=models.CASCADE)
+    thread = models.ForeignKey("misago_threads.Thread", on_delete=models.CASCADE)
+    post = models.ForeignKey("misago_threads.Post", on_delete=models.CASCADE)
     last_read_on = models.DateTimeField(default=timezone.now)

+ 3 - 7
misago/readtracker/poststracker.py

@@ -5,7 +5,7 @@ def make_read_aware(user, posts):
     if not posts:
         return
 
-    if not hasattr(posts, '__iter__'):
+    if not hasattr(posts, "__iter__"):
         posts = [posts]
 
     make_read(posts)
@@ -24,7 +24,7 @@ def make_read_aware(user, posts):
 
     if unresolved_posts:
         queryset = user.postread_set.filter(post__in=unresolved_posts)
-        for post_id in queryset.values_list('post_id', flat=True):
+        for post_id in queryset.values_list("post_id", flat=True):
             unresolved_posts[post_id].is_read = True
             unresolved_posts[post_id].is_new = False
 
@@ -36,8 +36,4 @@ def make_read(posts):
 
 
 def save_read(user, post):
-    user.postread_set.create(
-        category=post.category,
-        thread=post.thread,
-        post=post,
-    )
+    user.postread_set.create(category=post.category, thread=post.thread, post=post)

+ 6 - 12
misago/readtracker/signals.py

@@ -15,24 +15,18 @@ def delete_category_threads(sender, **kwargs):
 
 @receiver(move_category_content)
 def move_category_tracker(sender, **kwargs):
-    sender.postread_set.update(category=kwargs['new_category'])
+    sender.postread_set.update(category=kwargs["new_category"])
 
 
 @receiver(merge_thread)
 def merge_thread_tracker(sender, **kwargs):
-    other_thread = kwargs['other_thread']
-    other_thread.postread_set.update(
-        category=sender.category,
-        thread=sender,
-    )
+    other_thread = kwargs["other_thread"]
+    other_thread.postread_set.update(category=sender.category, thread=sender)
 
 
 @receiver(move_thread)
 def move_thread_tracker(sender, **kwargs):
-    sender.postread_set.update(
-        category=sender.category,
-        thread=sender,
-    )
+    sender.postread_set.update(category=sender.category, thread=sender)
 
 
 @receiver(merge_post)
@@ -48,11 +42,11 @@ def move_post_delete_tracker(sender, **kwargs):
 @receiver(thread_read)
 def decrease_unread_private_count(sender, **kwargs):
     user = sender
-    thread = kwargs['thread']
+    thread = kwargs["thread"]
 
     if thread.category.thread_type.root_name != PRIVATE_THREADS_ROOT_NAME:
         return
 
     if user.unread_private_threads:
         user.unread_private_threads -= 1
-        user.save(update_fields=['unread_private_threads'])
+        user.save(update_fields=["unread_private_threads"])

+ 9 - 29
misago/readtracker/tests/test_categoriestracker.py

@@ -24,9 +24,9 @@ class AnonymousUser(object):
 
 class CategoriesTrackerTests(TestCase):
     def setUp(self):
-        self.user = User.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):
         """passing falsy value to readtracker causes no errors"""
@@ -114,10 +114,7 @@ class CategoriesTrackerTests(TestCase):
         thread = testutils.post_thread(self.category, started_on=timezone.now())
 
         testutils.reply_thread(
-            thread,
-            posted_on=timezone.now(),
-            is_event=True,
-            is_hidden=True,
+            thread, posted_on=timezone.now(), is_event=True, is_hidden=True
         )
 
         categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
@@ -130,10 +127,7 @@ class CategoriesTrackerTests(TestCase):
         poststracker.save_read(self.user, thread.first_post)
 
         testutils.reply_thread(
-            thread,
-            posted_on=timezone.now(),
-            is_event=True,
-            is_hidden=True,
+            thread, posted_on=timezone.now(), is_event=True, is_hidden=True
         )
 
         categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
@@ -154,11 +148,7 @@ class CategoriesTrackerTests(TestCase):
         thread = testutils.post_thread(self.category, started_on=timezone.now())
         poststracker.save_read(self.user, thread.first_post)
 
-        testutils.reply_thread(
-            thread,
-            posted_on=timezone.now(),
-            is_unapproved=True,
-        )
+        testutils.reply_thread(thread, posted_on=timezone.now(), is_unapproved=True)
 
         categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
         self.assertTrue(self.category.is_read)
@@ -170,10 +160,7 @@ class CategoriesTrackerTests(TestCase):
         poststracker.save_read(self.user, thread.first_post)
 
         testutils.reply_thread(
-            thread,
-            posted_on=timezone.now(),
-            poster=self.user,
-            is_unapproved=True,
+            thread, posted_on=timezone.now(), poster=self.user, is_unapproved=True
         )
 
         categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
@@ -186,10 +173,7 @@ class CategoriesTrackerTests(TestCase):
         poststracker.save_read(self.user, thread.first_post)
 
         testutils.reply_thread(
-            thread,
-            posted_on=timezone.now(),
-            poster=self.user,
-            is_unapproved=True,
+            thread, posted_on=timezone.now(), poster=self.user, is_unapproved=True
         )
 
         categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
@@ -199,9 +183,7 @@ class CategoriesTrackerTests(TestCase):
     def test_user_unapproved_thread_unread_post(self):
         """tracked unapproved thread"""
         thread = testutils.post_thread(
-            self.category,
-            started_on=timezone.now(),
-            is_unapproved=True,
+            self.category, started_on=timezone.now(), is_unapproved=True
         )
 
         categoriestracker.make_read_aware(self.user, self.user_acl, self.category)
@@ -224,9 +206,7 @@ class CategoriesTrackerTests(TestCase):
     def test_user_hidden_thread_unread_post(self):
         """tracked hidden thread"""
         thread = testutils.post_thread(
-            self.category,
-            started_on=timezone.now(),
-            is_hidden=True,
+            self.category, started_on=timezone.now(), is_hidden=True
         )
 
         categoriestracker.make_read_aware(self.user, self.user_acl, self.category)

+ 11 - 5
misago/readtracker/tests/test_clearreadtracker.py

@@ -18,10 +18,14 @@ UserModel = get_user_model()
 
 class ClearReadTrackerTests(TestCase):
     def setUp(self):
-        self.user_a = UserModel.objects.create_user("UserA", "testa@user.com", 'Pass.123')
-        self.user_b = UserModel.objects.create_user("UserB", "testb@user.com", 'Pass.123')
+        self.user_a = UserModel.objects.create_user(
+            "UserA", "testa@user.com", "Pass.123"
+        )
+        self.user_b = UserModel.objects.create_user(
+            "UserB", "testb@user.com", "Pass.123"
+        )
 
-        self.category = Category.objects.get(slug='first-category')
+        self.category = Category.objects.get(slug="first-category")
 
     def test_no_deleted(self):
         """command works when there are no attachments"""
@@ -42,14 +46,16 @@ class ClearReadTrackerTests(TestCase):
             category=self.category,
             thread=thread,
             post=thread.first_post,
-            last_read_on=timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF / 4)
+            last_read_on=timezone.now()
+            - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF / 4),
         )
         deleted = PostRead.objects.create(
             user=self.user_b,
             category=self.category,
             thread=thread,
             post=thread.first_post,
-            last_read_on=timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF * 2)
+            last_read_on=timezone.now()
+            - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF * 2),
         )
 
         command = clearreadtracker.Command()

+ 9 - 3
misago/readtracker/tests/test_dates.py

@@ -21,7 +21,9 @@ class MockAnonymousUser(object):
 class ReadTrackerDatesTests(TestCase):
     def test_get_cutoff_date_no_user(self):
         """get_cutoff_date utility works without user argument"""
-        valid_cutoff_date = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
+        valid_cutoff_date = timezone.now() - timedelta(
+            days=settings.MISAGO_READTRACKER_CUTOFF
+        )
         returned_cutoff_date = get_cutoff_date()
 
         self.assertTrue(returned_cutoff_date > valid_cutoff_date)
@@ -30,7 +32,9 @@ class ReadTrackerDatesTests(TestCase):
         """get_cutoff_date utility works with user argument"""
         user = MockUser()
 
-        valid_cutoff_date = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
+        valid_cutoff_date = timezone.now() - timedelta(
+            days=settings.MISAGO_READTRACKER_CUTOFF
+        )
         returned_cutoff_date = get_cutoff_date(user)
 
         self.assertTrue(returned_cutoff_date > valid_cutoff_date)
@@ -40,7 +44,9 @@ class ReadTrackerDatesTests(TestCase):
         """passing anonymous user to get_cutoff_date has no effect"""
         user = MockAnonymousUser()
 
-        valid_cutoff_date = timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF)
+        valid_cutoff_date = timezone.now() - timedelta(
+            days=settings.MISAGO_READTRACKER_CUTOFF
+        )
         returned_cutoff_date = get_cutoff_date(user)
 
         self.assertTrue(returned_cutoff_date > valid_cutoff_date)

+ 2 - 2
misago/readtracker/tests/test_poststracker.py

@@ -21,8 +21,8 @@ class AnonymousUser(object):
 
 class PostsTrackerTests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user("UserA", "testa@user.com", 'Pass.123')
-        self.category = Category.objects.get(slug='first-category')
+        self.user = UserModel.objects.create_user("UserA", "testa@user.com", "Pass.123")
+        self.category = Category.objects.get(slug="first-category")
         self.thread = testutils.post_thread(self.category)
 
     def test_falsy_value(self):

+ 6 - 19
misago/readtracker/tests/test_threadstracker.py

@@ -25,9 +25,9 @@ class AnonymousUser(object):
 
 class ThreadsTrackerTests(TestCase):
     def setUp(self):
-        self.user = User.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_to_obj(self.user_acl, self.category)
 
@@ -117,10 +117,7 @@ class ThreadsTrackerTests(TestCase):
         thread = testutils.post_thread(self.category, started_on=timezone.now())
 
         testutils.reply_thread(
-            thread,
-            posted_on=timezone.now(),
-            is_event=True,
-            is_hidden=True,
+            thread, posted_on=timezone.now(), is_event=True, is_hidden=True
         )
 
         threadstracker.make_read_aware(self.user, self.user_acl, thread)
@@ -133,10 +130,7 @@ class ThreadsTrackerTests(TestCase):
         poststracker.save_read(self.user, thread.first_post)
 
         testutils.reply_thread(
-            thread,
-            posted_on=timezone.now(),
-            is_event=True,
-            is_hidden=True,
+            thread, posted_on=timezone.now(), is_event=True, is_hidden=True
         )
 
         threadstracker.make_read_aware(self.user, self.user_acl, thread)
@@ -157,11 +151,7 @@ class ThreadsTrackerTests(TestCase):
         thread = testutils.post_thread(self.category, started_on=timezone.now())
         poststracker.save_read(self.user, thread.first_post)
 
-        testutils.reply_thread(
-            thread,
-            posted_on=timezone.now(),
-            is_unapproved=True,
-        )
+        testutils.reply_thread(thread, posted_on=timezone.now(), is_unapproved=True)
 
         threadstracker.make_read_aware(self.user, self.user_acl, thread)
         self.assertTrue(thread.is_read)
@@ -173,10 +163,7 @@ class ThreadsTrackerTests(TestCase):
         poststracker.save_read(self.user, thread.first_post)
 
         testutils.reply_thread(
-            thread,
-            posted_on=timezone.now(),
-            poster=self.user,
-            is_unapproved=True,
+            thread, posted_on=timezone.now(), poster=self.user, is_unapproved=True
         )
 
         threadstracker.make_read_aware(self.user, self.user_acl, thread)

+ 7 - 6
misago/readtracker/threadstracker.py

@@ -8,7 +8,7 @@ def make_read_aware(user, user_acl, threads):
     if not threads:
         return
 
-    if not hasattr(threads, '__iter__'):
+    if not hasattr(threads, "__iter__"):
         threads = [threads]
 
     make_read(threads)
@@ -18,12 +18,13 @@ def make_read_aware(user, user_acl, threads):
 
     categories = [t.category for t in threads]
 
-    queryset = Post.objects.filter(
-        thread__in=threads,
-        posted_on__gt=get_cutoff_date(user),
-    ).values_list('thread', flat=True).distinct()
+    queryset = (
+        Post.objects.filter(thread__in=threads, posted_on__gt=get_cutoff_date(user))
+        .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_acl, categories, queryset)
 
     unread_threads = list(queryset)

+ 1 - 1
misago/search/__init__.py

@@ -1,4 +1,4 @@
 from .searchprovider import SearchProvider
 
 
-default_app_config = 'misago.search.apps.MisagoSearchConfig'
+default_app_config = "misago.search.apps.MisagoSearchConfig"

+ 14 - 12
misago/search/api.py

@@ -15,36 +15,38 @@ from .searchproviders import searchproviders
 @api_view()
 def search(request, search_provider=None):
     allowed_providers = searchproviders.get_allowed_providers(request)
-    if not request.user_acl['can_search'] or not allowed_providers:
+    if not request.user_acl["can_search"] or not allowed_providers:
         raise PermissionDenied(_("You don't have permission to search site."))
 
     search_query = get_search_query(request)
     response = []
     for provider in allowed_providers:
         provider_data = {
-            'id': provider.url,
-            'name': str(provider.name),
-            'icon': provider.icon,
-            'url': reverse('misago:search', kwargs={'search_provider': provider.url}),
-            'api': reverse('misago:api:search', kwargs={'search_provider': provider.url}),
-            'results': None,
-            'time': None,
+            "id": provider.url,
+            "name": str(provider.name),
+            "icon": provider.icon,
+            "url": reverse("misago:search", kwargs={"search_provider": provider.url}),
+            "api": reverse(
+                "misago:api:search", kwargs={"search_provider": provider.url}
+            ),
+            "results": None,
+            "time": None,
         }
 
         if not search_provider or search_provider == provider.url:
             start_time = time()
 
             if search_provider == provider.url:
-                page = get_int_or_404(request.query_params.get('page', 1))
+                page = get_int_or_404(request.query_params.get("page", 1))
             else:
                 page = 1
 
-            provider_data['results'] = provider.search(search_query, page)
-            provider_data['time'] = float('%.2f' % (time() - start_time))
+            provider_data["results"] = provider.search(search_query, page)
+            provider_data["time"] = float("%.2f" % (time() - start_time))
 
         response.append(provider_data)
     return Response(response)
 
 
 def get_search_query(request):
-    return request.query_params.get('q', '').strip()
+    return request.query_params.get("q", "").strip()

+ 2 - 2
misago/search/apps.py

@@ -2,6 +2,6 @@ from django.apps import AppConfig
 
 
 class MisagoSearchConfig(AppConfig):
-    name = 'misago.search'
-    label = 'misago_search'
+    name = "misago.search"
+    label = "misago_search"
     verbose_name = "Misago Search"

+ 19 - 13
misago/search/context_processors.py

@@ -8,7 +8,7 @@ def search_providers(request):
     allowed_providers = []
 
     try:
-        if request.user_acl['can_search']:
+        if request.user_acl["can_search"]:
             allowed_providers = searchproviders.get_allowed_providers(request)
     except AttributeError:
         # is user has no acl_cache attribute, cease entire middleware
@@ -17,19 +17,25 @@ def search_providers(request):
         # with non-misago's anonymous user model that has no acl support
         return {}
 
-    request.frontend_context['SEARCH_URL'] = reverse('misago:search')
-    request.frontend_context['SEARCH_API'] = reverse('misago:api:search')
-    request.frontend_context['SEARCH_PROVIDERS'] = []
+    request.frontend_context["SEARCH_URL"] = reverse("misago:search")
+    request.frontend_context["SEARCH_API"] = reverse("misago:api:search")
+    request.frontend_context["SEARCH_PROVIDERS"] = []
 
     for provider in allowed_providers:
-        request.frontend_context['SEARCH_PROVIDERS'].append({
-            'id': provider.url,
-            'name': str(provider.name),
-            'icon': provider.icon,
-            'url': reverse('misago:search', kwargs={'search_provider': provider.url}),
-            'api': reverse('misago:api:search', kwargs={'search_provider': provider.url}),
-            'results': None,
-            'time': None,
-        })
+        request.frontend_context["SEARCH_PROVIDERS"].append(
+            {
+                "id": provider.url,
+                "name": str(provider.name),
+                "icon": provider.icon,
+                "url": reverse(
+                    "misago:search", kwargs={"search_provider": provider.url}
+                ),
+                "api": reverse(
+                    "misago:api:search", kwargs={"search_provider": provider.url}
+                ),
+                "results": None,
+                "time": None,
+            }
+        )
 
     return {}

+ 4 - 2
misago/search/permissions.py

@@ -20,7 +20,9 @@ def change_permissions_form(role):
 
 
 def build_acl(acl, roles, key_name):
-    new_acl = {'can_search': 0}
+    new_acl = {"can_search": 0}
     new_acl.update(acl)
 
-    return algebra.sum_acls(new_acl, roles=roles, key=key_name, can_search=algebra.greater)
+    return algebra.sum_acls(
+        new_acl, roles=roles, key=key_name, can_search=algebra.greater
+    )

+ 1 - 1
misago/search/searchprovider.py

@@ -7,5 +7,5 @@ class SearchProvider(object):
 
     def search(self, query, page=1):
         raise NotImplementedError(
-            '%s has to define search(query, page=1) method' % self.__class__.__name__
+            "%s has to define search(query, page=1) method" % self.__class__.__name__
         )

+ 15 - 18
misago/search/tests/test_api.py

@@ -9,16 +9,16 @@ class SearchApiTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        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):
         """api validates permission to search"""
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You don't have permission to search site."
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You don't have permission to search site."}
+        )
 
     def test_no_phrase(self):
         """api handles no search query"""
@@ -28,29 +28,26 @@ class SearchApiTests(AuthenticatedUserTestCase):
         providers = searchproviders.get_providers(True)
         for i, provider in enumerate(response.json()):
             provider_api = reverse(
-                'misago:api:search', kwargs={
-                    'search_provider': providers[i].url,
-                }
+                "misago:api:search", kwargs={"search_provider": providers[i].url}
             )
-            self.assertEqual(provider_api, provider['api'])
+            self.assertEqual(provider_api, provider["api"])
 
-            self.assertEqual(str(providers[i].name), provider['name'])
-            self.assertEqual(provider['results']['results'], [])
-            self.assertEqual(int(provider['time']), 0)
+            self.assertEqual(str(providers[i].name), provider["name"])
+            self.assertEqual(provider["results"]["results"], [])
+            self.assertEqual(int(provider["time"]), 0)
 
     def test_empty_search(self):
         """api handles empty search query"""
-        response = self.client.get('%s?q=' % self.test_link)
+        response = self.client.get("%s?q=" % self.test_link)
         self.assertEqual(response.status_code, 200)
 
         providers = searchproviders.get_providers(True)
         for i, provider in enumerate(response.json()):
             provider_api = reverse(
-                'misago:api:search',
-                kwargs={'search_provider': providers[i].url},
+                "misago:api:search", kwargs={"search_provider": providers[i].url}
             )
-            self.assertEqual(provider_api, provider['api'])
+            self.assertEqual(provider_api, provider["api"])
 
-            self.assertEqual(str(providers[i].name), provider['name'])
-            self.assertEqual(provider['results']['results'], [])
-            self.assertEqual(int(provider['time']), 0)
+            self.assertEqual(str(providers[i].name), provider["name"])
+            self.assertEqual(provider["results"]["results"], [])
+            self.assertEqual(int(provider["time"]), 0)

+ 13 - 7
misago/search/tests/test_searchproviders.py

@@ -23,10 +23,12 @@ class SearchProvidersTests(TestCase):
 
         self.assertTrue(searchproviders._initialized)
 
-        self.assertEqual(len(searchproviders._providers), len(settings.MISAGO_SEARCH_EXTENSIONS))
+        self.assertEqual(
+            len(searchproviders._providers), len(settings.MISAGO_SEARCH_EXTENSIONS)
+        )
 
         for i, provider in enumerate(searchproviders._providers):
-            classname = settings.MISAGO_SEARCH_EXTENSIONS[i].split('.')[-1]
+            classname = settings.MISAGO_SEARCH_EXTENSIONS[i].split(".")[-1]
             self.assertEqual(provider.__name__, classname)
 
     def test_get_providers(self):
@@ -36,8 +38,10 @@ class SearchProvidersTests(TestCase):
         searchproviders._initialized = True
         searchproviders._providers = [MockProvider, MockProvider, MockProvider]
 
-        self.assertEqual([m.__class__ for m in searchproviders.get_providers(True)],
-                         searchproviders._providers)
+        self.assertEqual(
+            [m.__class__ for m in searchproviders.get_providers(True)],
+            searchproviders._providers,
+        )
 
     def test_providers_are_init_with_request(self):
         """providers constructor is provided with request"""
@@ -46,7 +50,7 @@ class SearchProvidersTests(TestCase):
         searchproviders._initialized = True
         searchproviders._providers = [MockProvider]
 
-        self.assertEqual(searchproviders.get_providers('REQUEST')[0].request, 'REQUEST')
+        self.assertEqual(searchproviders.get_providers("REQUEST")[0].request, "REQUEST")
 
     def test_get_allowed_providers(self):
         """get_allowed_providers returns only providers that didn't raise in allow_search"""
@@ -55,5 +59,7 @@ class SearchProvidersTests(TestCase):
         searchproviders._initialized = True
         searchproviders._providers = [MockProvider, DisallowedProvider, MockProvider]
 
-        self.assertEqual([m.__class__ for m in searchproviders.get_allowed_providers(True)],
-                         [MockProvider, MockProvider])
+        self.assertEqual(
+            [m.__class__ for m in searchproviders.get_allowed_providers(True)],
+            [MockProvider, MockProvider],
+        )

+ 15 - 19
misago/search/tests/test_views.py

@@ -9,59 +9,55 @@ class LandingTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.test_link = reverse('misago:search')
+        self.test_link = reverse("misago:search")
 
-    @patch_user_acl({'can_search': False})
+    @patch_user_acl({"can_search": False})
     def test_no_permission(self):
         """view validates permission to search forum"""
         response = self.client.get(self.test_link)
         self.assertContains(response, "have permission to search site", status_code=403)
 
-    @patch_user_acl({'can_search': True})
+    @patch_user_acl({"can_search": True})
     def test_redirect_to_provider(self):
         """view validates permission to search forum"""
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 302)
-        self.assertIn(SearchThreads.url, response['location'])
+        self.assertIn(SearchThreads.url, response["location"])
 
 
 class SearchTests(AuthenticatedUserTestCase):
-    @patch_user_acl({'can_search': False})
+    @patch_user_acl({"can_search": False})
     def test_no_permission(self):
         """view validates permission to search forum"""
         response = self.client.get(
-            reverse('misago:search', kwargs={
-                'search_provider': 'users',
-            })
+            reverse("misago:search", kwargs={"search_provider": "users"})
         )
 
         self.assertContains(response, "have permission to search site", status_code=403)
 
     def test_not_found(self):
         """view raises 404 for not found provider"""
-        response = self.client.get(reverse('misago:search', kwargs={
-            'search_provider': 'nada',
-        }))
+        response = self.client.get(
+            reverse("misago:search", kwargs={"search_provider": "nada"})
+        )
 
         self.assertEqual(response.status_code, 404)
 
-    @patch_user_acl({'can_search': True, 'can_search_users': False})
+    @patch_user_acl({"can_search": True, "can_search_users": False})
     def test_provider_no_permission(self):
         """provider raises 403 without permission"""
         response = self.client.get(
-            reverse('misago:search', kwargs={
-                'search_provider': 'users',
-            })
+            reverse("misago:search", kwargs={"search_provider": "users"})
         )
 
-        self.assertContains(response, "have permission to search users", status_code=403)
+        self.assertContains(
+            response, "have permission to search users", status_code=403
+        )
 
     def test_provider(self):
         """provider displays no script page"""
         response = self.client.get(
-            reverse('misago:search', kwargs={
-                'search_provider': 'users',
-            })
+            reverse("misago:search", kwargs={"search_provider": "users"})
         )
 
         self.assertContains(response, "Loading search...")

+ 2 - 2
misago/search/urls/__init__.py

@@ -3,6 +3,6 @@ from django.conf.urls import url
 from misago.search.views import landing, search
 
 urlpatterns = [
-    url(r'^search/$', landing, name='search'),
-    url(r'^search/(?P<search_provider>[-a-zA-Z0-9]+)/$', search, name='search'),
+    url(r"^search/$", landing, name="search"),
+    url(r"^search/(?P<search_provider>[-a-zA-Z0-9]+)/$", search, name="search"),
 ]

+ 2 - 2
misago/search/urls/api.py

@@ -4,6 +4,6 @@ from misago.search import api
 
 
 urlpatterns = [
-    url(r'^search/$', api.search, name='search'),
-    url(r'^search/(?P<search_provider>[-a-zA-Z0-9]+)/$', api.search, name='search'),
+    url(r"^search/$", api.search, name="search"),
+    url(r"^search/(?P<search_provider>[-a-zA-Z0-9]+)/$", api.search, name="search"),
 ]

+ 6 - 7
misago/search/views.py

@@ -9,16 +9,16 @@ from .searchproviders import searchproviders
 
 def landing(request):
     allowed_providers = searchproviders.get_allowed_providers(request)
-    if not request.user_acl['can_search'] or not allowed_providers:
+    if not request.user_acl["can_search"] or not allowed_providers:
         raise PermissionDenied(_("You don't have permission to search site."))
 
     default_provider = allowed_providers[0]
-    return redirect('misago:search', search_provider=default_provider.url)
+    return redirect("misago:search", search_provider=default_provider.url)
 
 
 def search(request, search_provider):
     all_providers = searchproviders.get_providers(request)
-    if not request.user_acl['can_search'] or not all_providers:
+    if not request.user_acl["can_search"] or not all_providers:
         raise PermissionDenied(_("You don't have permission to search site."))
 
     for provider in all_providers:
@@ -28,8 +28,7 @@ def search(request, search_provider):
     else:
         raise Http404()
 
-    if 'q' in request.GET:
-        request.frontend_context['SEARCH_QUERY'] = request.GET.get('q')
-
-    return render(request, 'misago/search.html')
+    if "q" in request.GET:
+        request.frontend_context["SEARCH_QUERY"] = request.GET.get("q")
 
+    return render(request, "misago/search.html")

+ 1 - 1
misago/threads/__init__.py

@@ -1 +1 @@
-default_app_config = 'misago.threads.apps.MisagoThreadsConfig'
+default_app_config = "misago.threads.apps.MisagoThreadsConfig"

+ 26 - 20
misago/threads/admin.py

@@ -3,42 +3,48 @@ from django.utils.translation import gettext_lazy as _
 
 from .views.admin.attachments import AttachmentsList, DeleteAttachment
 from .views.admin.attachmenttypes import (
-    AttachmentTypesList, DeleteAttachmentType, EditAttachmentType, NewAttachmentType)
+    AttachmentTypesList,
+    DeleteAttachmentType,
+    EditAttachmentType,
+    NewAttachmentType,
+)
 
 
 class MisagoAdminExtension(object):
     def register_urlpatterns(self, urlpatterns):
         # Attachment
-        urlpatterns.namespace(r'^attachments/', 'attachments', 'system')
+        urlpatterns.namespace(r"^attachments/", "attachments", "system")
         urlpatterns.patterns(
-            'system:attachments',
-            url(r'^$', AttachmentsList.as_view(), name='index'),
-            url(r'^(?P<page>\d+)/$', AttachmentsList.as_view(), name='index'),
-            url(r'^delete/(?P<pk>\d+)/$', DeleteAttachment.as_view(), name='delete'),
+            "system:attachments",
+            url(r"^$", AttachmentsList.as_view(), name="index"),
+            url(r"^(?P<page>\d+)/$", AttachmentsList.as_view(), name="index"),
+            url(r"^delete/(?P<pk>\d+)/$", DeleteAttachment.as_view(), name="delete"),
         )
 
         # AttachmentType
-        urlpatterns.namespace(r'^attachment-types/', 'attachment-types', 'system')
+        urlpatterns.namespace(r"^attachment-types/", "attachment-types", "system")
         urlpatterns.patterns(
-            'system:attachment-types',
-            url(r'^$', AttachmentTypesList.as_view(), name='index'),
-            url(r'^new/$', NewAttachmentType.as_view(), name='new'),
-            url(r'^edit/(?P<pk>\d+)/$', EditAttachmentType.as_view(), name='edit'),
-            url(r'^delete/(?P<pk>\d+)/$', DeleteAttachmentType.as_view(), name='delete'),
+            "system:attachment-types",
+            url(r"^$", AttachmentTypesList.as_view(), name="index"),
+            url(r"^new/$", NewAttachmentType.as_view(), name="new"),
+            url(r"^edit/(?P<pk>\d+)/$", EditAttachmentType.as_view(), name="edit"),
+            url(
+                r"^delete/(?P<pk>\d+)/$", DeleteAttachmentType.as_view(), name="delete"
+            ),
         )
 
     def register_navigation_nodes(self, site):
         site.add_node(
             name=_("Attachments"),
-            icon='fa fa-cubes',
-            parent='misago:admin:system',
-            after='misago:admin:system:settings:index',
-            link='misago:admin:system:attachments:index',
+            icon="fa fa-cubes",
+            parent="misago:admin:system",
+            after="misago:admin:system:settings:index",
+            link="misago:admin:system:attachments:index",
         )
         site.add_node(
             name=_("Attachment types"),
-            icon='fa fa-cube',
-            parent='misago:admin:system',
-            after='misago:admin:system:attachments:index',
-            link='misago:admin:system:attachment-types:index',
+            icon="fa fa-cube",
+            parent="misago:admin:system",
+            after="misago:admin:system:attachments:index",
+            link="misago:admin:system:attachment-types:index",
         )

+ 11 - 18
misago/threads/anonymize.py

@@ -2,12 +2,12 @@ from django.urls import reverse
 
 
 ANONYMIZABLE_EVENTS = (
-    'added_participant',
-    'changed_owner',
-    'owner_left',
-    'removed_owner',
-    'participant_left',
-    'removed_participant',
+    "added_participant",
+    "changed_owner",
+    "owner_left",
+    "removed_owner",
+    "participant_left",
+    "removed_participant",
 )
 
 
@@ -16,26 +16,19 @@ def anonymize_event(user, event):
         raise ValueError('event of type "%s" can\'t be ananymized' % event.event_type)
 
     event.event_context = {
-        'user': {
-            'id': None,
-            'username': user.username,
-            'url': reverse('misago:index'),
-        },
+        "user": {"id": None, "username": user.username, "url": reverse("misago:index")}
     }
-    event.save(update_fields=['event_context'])
+    event.save(update_fields=["event_context"])
 
 
 def anonymize_post_last_likes(user, post):
     cleaned_likes = []
     for like in post.last_likes:
-        if like['id'] == user.id:
-            cleaned_likes.append({
-                'id': None,
-                'username': user.username
-            })
+        if like["id"] == user.id:
+            cleaned_likes.append({"id": None, "username": user.username})
         else:
             cleaned_likes.append(like)
 
     if cleaned_likes != post.last_likes:
         post.last_likes = cleaned_likes
-        post.save(update_fields=['last_likes'])
+        post.save(update_fields=["last_likes"])

+ 26 - 17
misago/threads/api/attachments.py

@@ -11,27 +11,27 @@ from misago.threads.serializers import AttachmentSerializer
 from misago.users.audittrail import create_audit_trail
 
 
-IMAGE_EXTENSIONS = ('jpg', 'jpeg', 'png', 'gif')
+IMAGE_EXTENSIONS = ("jpg", "jpeg", "png", "gif")
 
 
 class AttachmentViewSet(viewsets.ViewSet):
     def create(self, request):
-        if not request.user_acl['max_attachment_size']:
+        if not request.user_acl["max_attachment_size"]:
             raise PermissionDenied(_("You don't have permission to upload new files."))
 
         try:
             return self.create_attachment(request)
         except ValidationError as e:
-            return Response({'detail': e.args[0]}, status=400)
+            return Response({"detail": e.args[0]}, status=400)
 
     def create_attachment(self, request):
-        upload = request.FILES.get('upload')
+        upload = request.FILES.get("upload")
         if not upload:
             raise ValidationError(_("No file has been uploaded."))
 
         user_roles = set(r.pk for r in request.user.get_roles())
         filetype = validate_filetype(upload, user_roles)
-        validate_filesize(upload, filetype, request.user_acl['max_attachment_size'])
+        validate_filesize(upload, filetype, request.user_acl["max_attachment_size"])
 
         attachment = Attachment(
             secret=Attachment.generate_new_secret(),
@@ -56,21 +56,26 @@ class AttachmentViewSet(viewsets.ViewSet):
 
         create_audit_trail(request, attachment)
 
-        return Response(AttachmentSerializer(attachment, context={'user': request.user}).data)
+        return Response(
+            AttachmentSerializer(attachment, context={"user": request.user}).data
+        )
 
 
 def validate_filetype(upload, user_roles):
     filename = upload.name.strip().lower()
 
     queryset = AttachmentType.objects.filter(status=AttachmentType.ENABLED)
-    for filetype in queryset.prefetch_related('limit_uploads_to'):
+    for filetype in queryset.prefetch_related("limit_uploads_to"):
         for extension in filetype.extensions_list:
-            if filename.endswith('.%s' % extension):
+            if filename.endswith(".%s" % extension):
                 break
         else:
             continue
 
-        if filetype.mimetypes_list and upload.content_type not in filetype.mimetypes_list:
+        if (
+            filetype.mimetypes_list
+            and upload.content_type not in filetype.mimetypes_list
+        ):
             continue
 
         if filetype.limit_uploads_to.exists():
@@ -85,11 +90,14 @@ def validate_filetype(upload, user_roles):
 
 def validate_filesize(upload, filetype, hard_limit):
     if upload.size > hard_limit * 1024:
-        message = _("You can't upload files larger than %(limit)s (your file has %(upload)s).")
+        message = _(
+            "You can't upload files larger than %(limit)s (your file has %(upload)s)."
+        )
         raise ValidationError(
-            message % {
-                'upload': filesizeformat(upload.size).rstrip('.0'),
-                'limit': filesizeformat(hard_limit * 1024).rstrip('.0'),
+            message
+            % {
+                "upload": filesizeformat(upload.size).rstrip(".0"),
+                "limit": filesizeformat(hard_limit * 1024).rstrip(".0"),
             }
         )
 
@@ -98,9 +106,10 @@ def validate_filesize(upload, filetype, hard_limit):
             "You can't upload files of this type larger than %(limit)s (your file has %(upload)s)."
         )
         raise ValidationError(
-            message % {
-                'upload': filesizeformat(upload.size).rstrip('.0'),
-                'limit': filesizeformat(filetype.size_limit * 1024).rstrip('.0'),
+            message
+            % {
+                "upload": filesizeformat(upload.size).rstrip(".0"),
+                "limit": filesizeformat(filetype.size_limit * 1024).rstrip(".0"),
             }
         )
 
@@ -109,6 +118,6 @@ def is_upload_image(upload):
     filename = upload.name.strip().lower()
 
     for extension in IMAGE_EXTENSIONS:
-        if filename.endswith('.%s' % extension):
+        if filename.endswith(".%s" % extension):
             return True
     return False

+ 14 - 24
misago/threads/api/pollvotecreateendpoint.py

@@ -13,25 +13,15 @@ def poll_vote_create(request, thread, poll):
     allow_vote_poll(request.user_acl, poll)
 
     serializer = NewVoteSerializer(
-        data={
-            'choices': request.data,
-        },
-        context={
-            'allowed_choices': poll.allowed_choices,
-            'choices': poll.choices,
-        },
+        data={"choices": request.data},
+        context={"allowed_choices": poll.allowed_choices, "choices": poll.choices},
     )
 
     if not serializer.is_valid():
-        return Response(
-            {
-                'detail': serializer.errors['choices'][0],
-            },
-            status=400,
-        )
+        return Response({"detail": serializer.errors["choices"][0]}, status=400)
 
-    remove_user_votes(request.user, poll, serializer.data['choices'])
-    set_new_votes(request, poll, serializer.data['choices'])
+    remove_user_votes(request.user, poll, serializer.data["choices"])
+    set_new_votes(request, poll, serializer.data["choices"])
 
     add_acl_to_obj(request.user_acl, poll)
     serialized_poll = PollSerializer(poll).data
@@ -43,19 +33,19 @@ def poll_vote_create(request, thread, poll):
 
 
 def presave_clean_choice(choice):
-    del choice['selected']
+    del choice["selected"]
     return choice
 
 
 def remove_user_votes(user, poll, final_votes):
     removed_votes = []
     for choice in poll.choices:
-        if choice['selected'] and choice['hash'] not in final_votes:
+        if choice["selected"] and choice["hash"] not in final_votes:
             poll.votes -= 1
-            choice['votes'] -= 1
+            choice["votes"] -= 1
 
-            choice['selected'] = False
-            removed_votes.append(choice['hash'])
+            choice["selected"] = False
+            removed_votes.append(choice["hash"])
 
     if removed_votes:
         poll.pollvote_set.filter(voter=user, choice_hash__in=removed_votes).delete()
@@ -63,16 +53,16 @@ def remove_user_votes(user, poll, final_votes):
 
 def set_new_votes(request, poll, final_votes):
     for choice in poll.choices:
-        if not choice['selected'] and choice['hash'] in final_votes:
+        if not choice["selected"] and choice["hash"] in final_votes:
             poll.votes += 1
-            choice['votes'] += 1
+            choice["votes"] += 1
 
-            choice['selected'] = True
+            choice["selected"] = True
             poll.pollvote_set.create(
                 category=poll.category,
                 thread=poll.thread,
                 voter=request.user,
                 voter_name=request.user.username,
                 voter_slug=request.user.slug,
-                choice_hash=choice['hash'],
+                choice_hash=choice["hash"],
             )

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

@@ -8,7 +8,10 @@ from misago.conf import settings
 from misago.core.utils import clean_ids_list
 from misago.threads.moderation import posts as moderation
 from misago.threads.permissions import (
-    allow_delete_best_answer, allow_delete_event, allow_delete_post)
+    allow_delete_best_answer,
+    allow_delete_event,
+    allow_delete_post,
+)
 from misago.threads.permissions import exclude_invisible_posts
 from misago.threads.serializers import DeletePostsSerializer
 
@@ -31,21 +34,18 @@ def delete_post(request, thread, post):
 
 def delete_bulk(request, thread):
     serializer = DeletePostsSerializer(
-        data={'posts': request.data},
-        context={
-            'thread': thread,
-            'user_acl': request.user_acl,
-        },
+        data={"posts": request.data},
+        context={"thread": thread, "user_acl": request.user_acl},
     )
 
     if not serializer.is_valid():
-        if 'posts' in serializer.errors:
-            errors = serializer.errors['posts']
+        if "posts" in serializer.errors:
+            errors = serializer.errors["posts"]
         else:
             errors = list(serializer.errors.values())[0]
-        return Response({'detail': errors[0]}, status=400)
+        return Response({"detail": errors[0]}, status=400)
 
-    for post in serializer.validated_data['posts']:
+    for post in serializer.validated_data["posts"]:
         post.delete()
 
     sync_related(thread)

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

@@ -15,27 +15,27 @@ from misago.users.online.utils import make_users_status_aware
 
 
 def get_edit_endpoint(request, post):
-    edit = get_edit(post, request.GET.get('edit'))
+    edit = get_edit(post, request.GET.get("edit"))
 
     data = PostEditSerializer(edit).data
 
     try:
-        queryset = post.edits_record.filter(id__gt=edit.id).order_by('id')
-        data['next'] = queryset[:1][0].id
+        queryset = post.edits_record.filter(id__gt=edit.id).order_by("id")
+        data["next"] = queryset[:1][0].id
     except IndexError:
-        data['next'] = None
+        data["next"] = None
 
     try:
-        queryset = post.edits_record.filter(id__lt=edit.id).order_by('-id')
-        data['previous'] = queryset[:1][0].id
+        queryset = post.edits_record.filter(id__lt=edit.id).order_by("-id")
+        data["previous"] = queryset[:1][0].id
     except IndexError:
-        data['previous'] = None
+        data["previous"] = None
 
     return Response(data)
 
 
 def revert_post_endpoint(request, post):
-    edit = get_edit_by_pk(post, request.GET.get('edit'))
+    edit = get_edit_by_pk(post, request.GET.get("edit"))
 
     datetime = timezone.now()
     post_edits = post.edits
@@ -53,13 +53,13 @@ def revert_post_endpoint(request, post):
 
     parsing_result = common_flavour(request, post.poster, edit.edited_from)
 
-    post.original = parsing_result['original_text']
-    post.parsed = parsing_result['parsed_text']
+    post.original = parsing_result["original_text"]
+    post.parsed = parsing_result["parsed_text"]
 
     update_post_checksum(post)
 
     post.updated_on = datetime
-    post.edits = F('edits') + 1
+    post.edits = F("edits") + 1
 
     post.last_editor = request.user
     post.last_editor_name = request.user.username
@@ -76,7 +76,7 @@ def revert_post_endpoint(request, post):
     if 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)
 
 
 def get_edit(post, pk=None):

+ 3 - 2
misago/threads/api/postendpoints/likes.py

@@ -4,8 +4,9 @@ from misago.threads.serializers import PostLikeSerializer
 
 
 def likes_list_endpoint(request, post):
-    queryset = post.postlike_set.select_related('liker').values(
-        'id', 'liker_id', 'liker_name', 'liker_slug', 'liked_on', 'liker__avatars')
+    queryset = post.postlike_set.select_related("liker").values(
+        "id", "liker_id", "liker_name", "liker_slug", "liked_on", "liker__avatars"
+    )
 
     likes = []
     for like in queryset.iterator():

+ 7 - 16
misago/threads/api/postendpoints/merge.py

@@ -8,26 +8,17 @@ from misago.threads.serializers import MergePostsSerializer, PostSerializer
 
 
 def posts_merge_endpoint(request, thread):
-    if not thread.acl['can_merge_posts']:
+    if not thread.acl["can_merge_posts"]:
         raise PermissionDenied(_("You can't merge posts in this thread."))
 
     serializer = MergePostsSerializer(
-        data=request.data,
-        context={
-            'thread': thread,
-            'user_acl': request.user_acl,
-        },
+        data=request.data, context={"thread": thread, "user_acl": request.user_acl}
     )
 
     if not serializer.is_valid():
-        return Response(
-            {
-                'detail': list(serializer.errors.values())[0][0],
-            },
-            status=400,
-        )
-
-    posts = serializer.validated_data['posts']
+        return Response({"detail": list(serializer.errors.values())[0][0]}, status=400)
+
+    posts = serializer.validated_data["posts"]
     first_post, merged_posts = posts[0], posts[1:]
 
     for post in merged_posts:
@@ -42,7 +33,7 @@ def posts_merge_endpoint(request, thread):
     first_post.save()
 
     first_post.update_search_vector()
-    first_post.save(update_fields=['search_vector'])
+    first_post.save(update_fields=["search_vector"])
 
     first_post.postread_set.all().delete()
 
@@ -57,4 +48,4 @@ def posts_merge_endpoint(request, thread):
 
     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)

+ 7 - 11
misago/threads/api/postendpoints/move.py

@@ -7,28 +7,24 @@ from misago.threads.serializers import MovePostsSerializer
 
 
 def posts_move_endpoint(request, thread, viewmodel):
-    if not thread.acl['can_move_posts']:
+    if not thread.acl["can_move_posts"]:
         raise PermissionDenied(_("You can't move posts in this thread."))
 
     serializer = MovePostsSerializer(
         data=request.data,
-        context={
-            'request': request,
-            'thread': thread,
-            'viewmodel': viewmodel,
-        }
+        context={"request": request, "thread": thread, "viewmodel": viewmodel},
     )
 
     if not serializer.is_valid():
-        if 'new_thread' in serializer.errors:
-            errors = serializer.errors['new_thread']
+        if "new_thread" in serializer.errors:
+            errors = serializer.errors["new_thread"]
         else:
             errors = list(serializer.errors.values())[0]
-        return Response({'detail': errors[0]}, status=400)
+        return Response({"detail": errors[0]}, status=400)
 
-    new_thread = serializer.validated_data['new_thread']
+    new_thread = serializer.validated_data["new_thread"]
 
-    for post in serializer.validated_data['posts']:
+    for post in serializer.validated_data["posts"]:
         post.move(new_thread)
         post.save()
 

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

@@ -14,12 +14,12 @@ def patch_acl(request, event, value):
     """useful little op that updates event acl to current state"""
     if value:
         add_acl_to_obj(request.user_acl, event)
-        return {'acl': event.acl}
+        return {"acl": event.acl}
     else:
-        return {'acl': None}
+        return {"acl": None}
 
 
-event_patch_dispatcher.add('acl', patch_acl)
+event_patch_dispatcher.add("acl", patch_acl)
 
 
 def patch_is_hidden(request, event, value):
@@ -30,10 +30,10 @@ def patch_is_hidden(request, event, value):
         allow_unhide_event(request.user_acl, event)
         moderation.unhide_post(request.user, event)
 
-    return {'is_hidden': event.is_hidden}
+    return {"is_hidden": event.is_hidden}
 
 
-event_patch_dispatcher.replace('is-hidden', patch_is_hidden)
+event_patch_dispatcher.replace("is-hidden", patch_is_hidden)
 
 
 def event_patch_endpoint(request, event):

+ 28 - 34
misago/threads/api/postendpoints/patch_post.py

@@ -10,8 +10,12 @@ from misago.core.apipatch import ApiPatch
 from misago.threads.models import PostLike
 from misago.threads.moderation import posts as moderation
 from misago.threads.permissions import (
-    allow_approve_post, allow_hide_best_answer, allow_hide_post, allow_protect_post,
-    allow_unhide_post)
+    allow_approve_post,
+    allow_hide_best_answer,
+    allow_hide_post,
+    allow_protect_post,
+    allow_unhide_post,
+)
 from misago.threads.permissions import exclude_invisible_posts
 
 
@@ -24,16 +28,16 @@ def patch_acl(request, post, value):
     """useful little op that updates post acl to current state"""
     if value:
         add_acl_to_obj(request.user_acl, post)
-        return {'acl': post.acl}
+        return {"acl": post.acl}
     else:
-        return {'acl': None}
+        return {"acl": None}
 
 
-post_patch_dispatcher.add('acl', patch_acl)
+post_patch_dispatcher.add("acl", patch_acl)
 
 
 def patch_is_liked(request, post, value):
-    if not post.acl['can_like']:
+    if not post.acl["can_like"]:
         raise PermissionDenied(_("You can't like posts in this category."))
 
     # lock user to protect us from likes flood
@@ -48,9 +52,9 @@ def patch_is_liked(request, post, value):
     # no change
     if (value and user_like) or (not value and not user_like):
         return {
-            'likes': post.likes,
-            'last_likes': post.last_likes or [],
-            'is_liked': value,
+            "likes": post.likes,
+            "last_likes": post.last_likes or [],
+            "is_liked": value,
         }
 
     # like
@@ -71,21 +75,14 @@ def patch_is_liked(request, post, value):
 
     post.last_likes = []
     for like in post.postlike_set.all()[:4]:
-        post.last_likes.append({
-            'id': like.liker_id,
-            'username': like.liker_name,
-        })
+        post.last_likes.append({"id": like.liker_id, "username": like.liker_name})
 
-    post.save(update_fields=['likes', 'last_likes'])
+    post.save(update_fields=["likes", "last_likes"])
 
-    return {
-        'likes': post.likes,
-        'last_likes': post.last_likes or [],
-        'is_liked': value,
-    }
+    return {"likes": post.likes, "last_likes": post.last_likes or [], "is_liked": value}
 
 
-post_patch_dispatcher.replace('is-liked', patch_is_liked)
+post_patch_dispatcher.replace("is-liked", patch_is_liked)
 
 
 def patch_is_protected(request, post, value):
@@ -94,10 +91,10 @@ def patch_is_protected(request, post, value):
         moderation.protect_post(request.user, post)
     else:
         moderation.unprotect_post(request.user, post)
-    return {'is_protected': post.is_protected}
+    return {"is_protected": post.is_protected}
 
 
-post_patch_dispatcher.replace('is-protected', patch_is_protected)
+post_patch_dispatcher.replace("is-protected", patch_is_protected)
 
 
 def patch_is_unapproved(request, post, value):
@@ -108,10 +105,10 @@ def patch_is_unapproved(request, post, value):
 
     moderation.approve_post(request.user, post)
 
-    return {'is_unapproved': post.is_unapproved}
+    return {"is_unapproved": post.is_unapproved}
 
 
-post_patch_dispatcher.replace('is-unapproved', patch_is_unapproved)
+post_patch_dispatcher.replace("is-unapproved", patch_is_unapproved)
 
 
 def patch_is_hidden(request, post, value):
@@ -123,10 +120,10 @@ def patch_is_hidden(request, post, value):
         allow_unhide_post(request.user_acl, post)
         moderation.unhide_post(request.user, post)
 
-    return {'is_hidden': post.is_hidden}
+    return {"is_hidden": post.is_hidden}
 
 
-post_patch_dispatcher.replace('is-hidden', patch_is_hidden)
+post_patch_dispatcher.replace("is-hidden", patch_is_hidden)
 
 
 def post_patch_endpoint(request, post):
@@ -150,7 +147,7 @@ def bulk_patch_endpoint(request, thread):
     if not serializer.is_valid():
         return Response(serializer.errors, status=400)
 
-    posts = clean_posts_for_patch(request, thread, serializer.data['ids'])
+    posts = clean_posts_for_patch(request, thread, serializer.data["ids"])
 
     old_unapproved_posts = [p.is_unapproved for p in posts].count(True)
 
@@ -172,10 +169,9 @@ def clean_posts_for_patch(request, thread, posts_ids):
     posts_queryset = exclude_invisible_posts(
         request.user_acl, thread.category, thread.post_set
     )
-    posts_queryset = posts_queryset.filter(
-        id__in=posts_ids,
-        is_event=False,
-    ).order_by('id')
+    posts_queryset = posts_queryset.filter(id__in=posts_ids, is_event=False).order_by(
+        "id"
+    )
 
     posts = []
     for post in posts_queryset:
@@ -196,7 +192,5 @@ class BulkPatchSerializer(serializers.Serializer):
         min_length=1,
     )
     ops = serializers.ListField(
-        child=serializers.DictField(),
-        min_length=1,
-        max_length=10,
+        child=serializers.DictField(), min_length=1, max_length=10
     )

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

@@ -19,4 +19,4 @@ def post_read_endpoint(request, thread, post):
     if post.is_new and thread.is_read:
         thread_read.send(request.user, thread=thread)
 
-    return Response({'thread_is_read': thread.is_read})
+    return Response({"thread_is_read": thread.is_read})

+ 14 - 16
misago/threads/api/postendpoints/split.py

@@ -9,24 +9,22 @@ from misago.threads.serializers import SplitPostsSerializer
 
 
 def posts_split_endpoint(request, thread):
-    if not thread.acl['can_move_posts']:
+    if not thread.acl["can_move_posts"]:
         raise PermissionDenied(_("You can't split posts from this thread."))
 
     serializer = SplitPostsSerializer(
         data=request.data,
         context={
-            'settings': request.settings,
-            'thread': thread,
-            'user_acl': request.user_acl,
+            "settings": request.settings,
+            "thread": thread,
+            "user_acl": request.user_acl,
         },
     )
 
     if not serializer.is_valid():
-        if 'posts' in serializer.errors:
-            errors = {
-                'detail': serializer.errors['posts'][0]
-            }
-        else :
+        if "posts" in serializer.errors:
+            errors = {"detail": serializer.errors["posts"][0]}
+        else:
             errors = serializer.errors
 
         return Response(errors, status=400)
@@ -38,15 +36,15 @@ def posts_split_endpoint(request, thread):
 
 def split_posts_to_new_thread(request, thread, validated_data):
     new_thread = Thread(
-        category=validated_data['category'],
+        category=validated_data["category"],
         started_on=thread.started_on,
         last_post_on=thread.last_post_on,
     )
 
-    new_thread.set_title(validated_data['title'])
+    new_thread.set_title(validated_data["title"])
     new_thread.save()
 
-    for post in validated_data['posts']:
+    for post in validated_data["posts"]:
         post.move(new_thread)
         post.save()
 
@@ -56,13 +54,13 @@ def split_posts_to_new_thread(request, thread, validated_data):
     new_thread.synchronize()
     new_thread.save()
 
-    if validated_data.get('weight') == Thread.WEIGHT_GLOBAL:
+    if validated_data.get("weight") == Thread.WEIGHT_GLOBAL:
         moderation.pin_thread_globally(request, new_thread)
-    elif validated_data.get('weight'):
+    elif validated_data.get("weight"):
         moderation.pin_thread_locally(request, new_thread)
-    if validated_data.get('is_hidden', False):
+    if validated_data.get("is_hidden", False):
         moderation.hide_thread(request, new_thread)
-    if validated_data.get('is_closed', False):
+    if validated_data.get("is_closed", False):
         moderation.close_thread(request, new_thread)
 
     thread.category.synchronize()

+ 16 - 13
misago/threads/api/postingendpoint/__init__.py

@@ -26,13 +26,15 @@ class PostingEndpoint(object):
 
         # build kwargs dict for passing to middlewares
         self.kwargs = kwargs
-        self.kwargs.update({
-            'mode': mode,
-            'request': request,
-            'settings': request.settings,
-            'user': request.user,
-            'user_acl': request.user_acl,
-        })
+        self.kwargs.update(
+            {
+                "mode": mode,
+                "request": request,
+                "settings": request.settings,
+                "user": request.user,
+                "user_acl": request.user_acl,
+            }
+        )
 
         self.__dict__.update(kwargs)
 
@@ -61,10 +63,7 @@ class PostingEndpoint(object):
 
     def _load_middlewares(self):
         kwargs = self.kwargs.copy()
-        kwargs.update({
-            'datetime': self.datetime,
-            'parsing_result': {},
-        })
+        kwargs.update({"datetime": self.datetime, "parsing_result": {}})
 
         middlewares = []
         for middleware in settings.MISAGO_POSTING_MIDDLEWARES:
@@ -75,7 +74,9 @@ class PostingEndpoint(object):
                 if middleware_obj.use_this_middleware():
                     middlewares.append((middleware, middleware_obj))
             except PostingInterrupt:
-                raise ValueError("Posting process can only be interrupted during pre_save phase")
+                raise ValueError(
+                    "Posting process can only be interrupted during pre_save phase"
+                )
 
         return middlewares
 
@@ -92,7 +93,9 @@ class PostingEndpoint(object):
                     serializers[middleware] = serializer
             return serializers
         except PostingInterrupt:
-            raise ValueError("Posting process can only be interrupted during pre_save phase")
+            raise ValueError(
+                "Posting process can only be interrupted during pre_save phase"
+            )
 
     def is_valid(self):
         """validate data against all serializers"""

+ 28 - 26
misago/threads/api/postingendpoint/attachments.py

@@ -12,17 +12,17 @@ from . import PostingEndpoint, PostingMiddleware
 
 class AttachmentsMiddleware(PostingMiddleware):
     def use_this_middleware(self):
-        return bool(self.user_acl['max_attachment_size'])
+        return bool(self.user_acl["max_attachment_size"])
 
     def get_serializer(self):
         return AttachmentsSerializer(
             data=self.request.data,
             context={
-                'mode': self.mode,
-                'user': self.user,
-                'user_acl': self.user_acl,
-                'post': self.post,
-            }
+                "mode": self.mode,
+                "user": self.user,
+                "user_acl": self.user_acl,
+                "post": self.post,
+            },
         )
 
     def save(self, serializer):
@@ -30,7 +30,9 @@ class AttachmentsMiddleware(PostingMiddleware):
 
 
 class AttachmentsSerializer(serializers.Serializer):
-    attachments = serializers.ListField(child=serializers.IntegerField(), required=False)
+    attachments = serializers.ListField(
+        child=serializers.IntegerField(), required=False
+    )
 
     def validate_attachments(self, ids):
         self.update_attachments = False
@@ -42,9 +44,9 @@ class AttachmentsSerializer(serializers.Serializer):
         validate_attachments_count(ids)
 
         attachments = self.get_initial_attachments(
-            self.context['mode'], self.context['user_acl'], 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)
 
         if not attachments and not new_attachments:
             return []  # no attachments
@@ -54,15 +56,15 @@ class AttachmentsSerializer(serializers.Serializer):
             if attachment.pk in ids:
                 self.final_attachments.append(attachment)
             else:
-                if attachment.acl['can_delete']:
+                if attachment.acl["can_delete"]:
                     self.update_attachments = True
                     self.removed_attachments.append(attachment)
                 else:
                     message = _(
-                        "You don't have permission to remove \"%(attachment)s\" attachment."
+                        'You don\'t have permission to remove "%(attachment)s" attachment.'
                     )
                     raise serializers.ValidationError(
-                        message % {'attachment': attachment.filename}
+                        message % {"attachment": attachment.filename}
                     )
 
         if new_attachments:
@@ -73,7 +75,7 @@ class AttachmentsSerializer(serializers.Serializer):
     def get_initial_attachments(self, mode, user_acl, post):
         attachments = []
         if mode == PostingEndpoint.EDIT:
-            queryset = post.attachment_set.select_related('filetype')
+            queryset = post.attachment_set.select_related("filetype")
             attachments = list(queryset)
             add_acl_to_obj(user_acl, attachments)
         return attachments
@@ -82,9 +84,8 @@ class AttachmentsSerializer(serializers.Serializer):
         if not ids:
             return []
 
-        queryset = user.attachment_set.select_related('filetype').filter(
-            post__isnull=True,
-            id__in=ids,
+        queryset = user.attachment_set.select_related("filetype").filter(
+            post__isnull=True, id__in=ids
         )
 
         return list(queryset)
@@ -97,28 +98,28 @@ class AttachmentsSerializer(serializers.Serializer):
             for attachment in self.removed_attachments:
                 attachment.delete_files()
 
-            self.context['post'].attachment_set.filter(
+            self.context["post"].attachment_set.filter(
                 id__in=[a.id for a in self.removed_attachments]
             ).delete()
 
         if self.final_attachments:
             # sort final attachments by id, descending
             self.final_attachments.sort(key=lambda a: a.pk, reverse=True)
-            self.context['user'].attachment_set.filter(
+            self.context["user"].attachment_set.filter(
                 id__in=[a.id for a in self.final_attachments]
-            ).update(post=self.context['post'])
+            ).update(post=self.context["post"])
 
-        self.sync_attachments_cache(self.context['post'], self.final_attachments)
+        self.sync_attachments_cache(self.context["post"], self.final_attachments)
 
     def sync_attachments_cache(self, post, attachments):
         if attachments:
             post.attachments_cache = AttachmentSerializer(attachments, many=True).data
             for attachment in post.attachments_cache:
-                del attachment['acl']
-                del attachment['post']
+                del attachment["acl"]
+                del attachment["post"]
         else:
             post.attachments_cache = None
-        post.update_fields.append('attachments_cache')
+        post.update_fields.append("attachments_cache")
 
 
 def validate_attachments_count(data):
@@ -130,8 +131,9 @@ def validate_attachments_count(data):
             settings.MISAGO_POST_ATTACHMENTS_LIMIT,
         )
         raise serializers.ValidationError(
-            message % {
-                'limit_value': settings.MISAGO_POST_ATTACHMENTS_LIMIT,
-                'show_value': total_attachments,
+            message
+            % {
+                "limit_value": settings.MISAGO_POST_ATTACHMENTS_LIMIT,
+                "show_value": total_attachments,
             }
         )

+ 5 - 3
misago/threads/api/postingendpoint/category.py

@@ -42,8 +42,8 @@ class CategoryMiddleware(PostingMiddleware):
 class CategorySerializer(serializers.Serializer):
     category = serializers.IntegerField(
         error_messages={
-            'required': gettext_lazy("You have to select category to post thread in."),
-            'invalid': gettext_lazy("Selected category is invalid."),
+            "required": gettext_lazy("You have to select category to post thread in."),
+            "invalid": gettext_lazy("Selected category is invalid."),
         }
     )
 
@@ -67,7 +67,9 @@ class CategorySerializer(serializers.Serializer):
             allow_start_thread(self.user_acl, self.category_cache)
         except Category.DoesNotExist:
             raise serializers.ValidationError(
-                _("Selected category doesn't exist or you don't have permission to browse it.")
+                _(
+                    "Selected category doesn't exist or you don't have permission to browse it."
+                )
             )
         except PermissionDenied as e:
             raise serializers.ValidationError(e.args[0])

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

@@ -13,8 +13,8 @@ class CloseMiddleware(PostingMiddleware):
         return CloseSerializer(data=self.request.data)
 
     def post_save(self, serializer):
-        if self.thread.category.acl['can_close_threads']:
-            if serializer.validated_data.get('close'):
+        if self.thread.category.acl["can_close_threads"]:
+            if serializer.validated_data.get("close"):
                 moderation.close_thread(self.request, self.thread)
 
 

+ 12 - 7
misago/threads/api/postingendpoint/emailnotification.py

@@ -17,10 +17,13 @@ class EmailNotificationMiddleware(PostingMiddleware):
         return self.mode == PostingEndpoint.REPLY
 
     def post_save(self, serializer):
-        queryset = self.thread.subscription_set.filter(
-            send_email=True,
-            last_read_on__gte=self.previous_last_post_on,
-        ).exclude(user=self.user).select_related('user')
+        queryset = (
+            self.thread.subscription_set.filter(
+                send_email=True, last_read_on__gte=self.previous_last_post_on
+            )
+            .exclude(user=self.user)
+            .select_related("user")
+        )
 
         notifications = []
         for subscription in queryset.iterator():
@@ -40,14 +43,16 @@ class EmailNotificationMiddleware(PostingMiddleware):
         if subscriber.id == self.thread.starter_id:
             subject = _('%(user)s has replied to your thread "%(thread)s"')
         else:
-            subject = _('%(user)s has replied to thread "%(thread)s" that you are watching')
+            subject = _(
+                '%(user)s has replied to thread "%(thread)s" that you are watching'
+            )
 
-        subject_formats = {'user': self.user.username, 'thread': self.thread.title}
+        subject_formats = {"user": self.user.username, "thread": self.thread.title}
 
         return build_mail(
             subscriber,
             subject % subject_formats,
-            'misago/emails/thread/reply',
+            "misago/emails/thread/reply",
             sender=self.user,
             context={
                 "settings": self.request.settings,

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

@@ -14,8 +14,8 @@ MIN_POSTING_PAUSE = 3
 class FloodProtectionMiddleware(PostingMiddleware):
     def use_this_middleware(self):
         return (
-            not self.user_acl['can_omit_flood_protection'] and
-            self.mode != PostingEndpoint.EDIT
+            not self.user_acl["can_omit_flood_protection"]
+            and self.mode != PostingEndpoint.EDIT
         )
 
     def interrupt_posting(self, serializer):
@@ -24,10 +24,12 @@ class FloodProtectionMiddleware(PostingMiddleware):
         if self.user.last_posted_on:
             previous_post = now - self.user.last_posted_on
             if previous_post.total_seconds() < MIN_POSTING_PAUSE:
-                raise PostingInterrupt(_("You can't post message so quickly after previous one."))
+                raise PostingInterrupt(
+                    _("You can't post message so quickly after previous one.")
+                )
 
         self.user.last_posted_on = timezone.now()
-        self.user.update_fields.append('last_posted_on')
+        self.user.update_fields.append("last_posted_on")
 
         if settings.MISAGO_HOURLY_POST_LIMIT:
             cutoff = now - timedelta(hours=24)

+ 3 - 3
misago/threads/api/postingendpoint/hide.py

@@ -13,11 +13,11 @@ class HideMiddleware(PostingMiddleware):
         return HideSerializer(data=self.request.data)
 
     def post_save(self, serializer):
-        if self.thread.category.acl['can_hide_threads']:
-            if serializer.validated_data.get('hide'):
+        if self.thread.category.acl["can_hide_threads"]:
+            if serializer.validated_data.get("hide"):
                 moderation.hide_thread(self.request, self.thread)
                 self.thread.update_all = True
-                self.thread.save(update_fields=['is_hidden'])
+                self.thread.save(update_fields=["is_hidden"])
 
                 self.thread.category.synchronize()
                 self.thread.category.update_all = True

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

@@ -8,7 +8,7 @@ class MentionsMiddleware(PostingMiddleware):
             existing_mentions = self.get_existing_mentions()
 
         new_mentions = []
-        for user in self.post.parsing_result['mentions']:
+        for user in self.post.parsing_result["mentions"]:
             if user.pk not in existing_mentions:
                 new_mentions.append(user)
 
@@ -16,4 +16,4 @@ class MentionsMiddleware(PostingMiddleware):
             self.post.mentions.add(*new_mentions)
 
     def get_existing_mentions(self):
-        return [u['id'] for u in self.post.mentions.values('id').iterator()]
+        return [u["id"] for u in self.post.mentions.values("id").iterator()]

+ 8 - 4
misago/threads/api/postingendpoint/moderationqueue.py

@@ -14,13 +14,17 @@ class ModerationQueueMiddleware(PostingMiddleware):
 
     def save(self, serializer):
         if self.mode == PostingEndpoint.START:
-            self.post.is_unapproved = self.thread.category.acl['require_threads_approval']
+            self.post.is_unapproved = self.thread.category.acl[
+                "require_threads_approval"
+            ]
 
         if self.mode == PostingEndpoint.REPLY:
-            self.post.is_unapproved = self.thread.category.acl['require_replies_approval']
+            self.post.is_unapproved = self.thread.category.acl[
+                "require_replies_approval"
+            ]
 
         if self.mode == PostingEndpoint.EDIT:
-            self.post.is_unapproved = self.thread.category.acl['require_edits_approval']
+            self.post.is_unapproved = self.thread.category.acl["require_edits_approval"]
 
         if self.post.is_unapproved:
-            self.post.update_fields.append('is_unapproved')
+            self.post.update_fields.append("is_unapproved")

+ 16 - 13
misago/threads/api/postingendpoint/participants.py

@@ -25,9 +25,9 @@ class ParticipantsMiddleware(PostingMiddleware):
         return ParticipantsSerializer(
             data=self.request.data,
             context={
-                'request': self.request,
-                'user': self.user,
-                'user_acl': self.user_acl,
+                "request": self.request,
+                "user": self.user,
+                "user_acl": self.user_acl,
             },
         )
 
@@ -48,9 +48,11 @@ class ParticipantsSerializer(serializers.Serializer):
         for name in usernames:
             clean_name = name.strip().lower()
 
-            if clean_name == self.context['user'].slug:
+            if clean_name == self.context["user"].slug:
                 raise serializers.ValidationError(
-                    _("You can't include yourself on the list of users to invite to new thread.")
+                    _(
+                        "You can't include yourself on the list of users to invite to new thread."
+                    )
                 )
 
             if clean_name and clean_name not in clean_usernames:
@@ -59,7 +61,7 @@ class ParticipantsSerializer(serializers.Serializer):
         if not clean_usernames:
             raise serializers.ValidationError(_("You have to enter user names."))
 
-        max_participants = self.context['user_acl']['max_private_thread_participants']
+        max_participants = self.context["user_acl"]["max_private_thread_participants"]
         if max_participants and len(clean_usernames) > max_participants:
             message = ngettext(
                 "You can't add more than %(users)s user to private thread (you've added %(added)s).",
@@ -67,10 +69,7 @@ class ParticipantsSerializer(serializers.Serializer):
                 max_participants,
             )
             raise serializers.ValidationError(
-                message % {
-                    'users': max_participants,
-                    'added': len(clean_usernames),
-                }
+                message % {"users": max_participants, "added": len(clean_usernames)}
             )
 
         return list(set(clean_usernames))
@@ -79,8 +78,10 @@ class ParticipantsSerializer(serializers.Serializer):
         users = []
         for user in UserModel.objects.filter(slug__in=usernames):
             try:
-                user_acl = useracl.get_user_acl(user, self.context["request"].cache_versions)
-                allow_message_user(self.context['user_acl'], user, user_acl)
+                user_acl = useracl.get_user_acl(
+                    user, self.context["request"].cache_versions
+                )
+                allow_message_user(self.context["user_acl"], user, user_acl)
             except PermissionDenied as e:
                 raise serializers.ValidationError(str(e))
             users.append(user)
@@ -90,6 +91,8 @@ class ParticipantsSerializer(serializers.Serializer):
             sorted_usernames = sorted(invalid_usernames)
 
             message = _("One or more users could not be found: %(usernames)s")
-            raise serializers.ValidationError(message % {'usernames': ', '.join(sorted_usernames)})
+            raise serializers.ValidationError(
+                message % {"usernames": ", ".join(sorted_usernames)}
+            )
 
         return users

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

@@ -14,9 +14,9 @@ class PinMiddleware(PostingMiddleware):
         return PinSerializer(data=self.request.data)
 
     def post_save(self, serializer):
-        allowed_pin = self.thread.category.acl['can_pin_threads']
+        allowed_pin = self.thread.category.acl["can_pin_threads"]
         if allowed_pin > 0:
-            pin = serializer.validated_data['pin']
+            pin = serializer.validated_data["pin"]
 
             if pin <= allowed_pin:
                 if pin == Thread.WEIGHT_GLOBAL:

+ 3 - 3
misago/threads/api/postingendpoint/protect.py

@@ -11,10 +11,10 @@ class ProtectMiddleware(PostingMiddleware):
         return ProtectSerializer(data=self.request.data)
 
     def post_save(self, serializer):
-        if self.thread.category.acl['can_protect_posts']:
+        if self.thread.category.acl["can_protect_posts"]:
             try:
-                self.post.is_protected = serializer.validated_data.get('protect', False)
-                self.post.update_fields.append('is_protected')
+                self.post.is_protected = serializer.validated_data.get("protect", False)
+                self.post.update_fields.append("is_protected")
             except (TypeError, ValueError):
                 pass
 

+ 8 - 2
misago/threads/api/postingendpoint/recordedit.py

@@ -15,14 +15,20 @@ class RecordEditMiddleware(PostingMiddleware):
             return
 
         self.post.updated_on = self.datetime
-        self.post.edits = F('edits') + 1
+        self.post.edits = F("edits") + 1
 
         self.post.last_editor = self.user
         self.post.last_editor_name = self.user.username
         self.post.last_editor_slug = self.user.slug
 
         self.post.update_fields.extend(
-            ('updated_on', 'edits', 'last_editor', 'last_editor_name', 'last_editor_slug', )
+            (
+                "updated_on",
+                "edits",
+                "last_editor",
+                "last_editor_name",
+                "last_editor_slug",
+            )
         )
 
         self.post.edits_record.create(

+ 19 - 19
misago/threads/api/postingendpoint/reply.py

@@ -5,7 +5,9 @@ from django.utils.translation import gettext_lazy
 from misago.markup import common_flavour
 from misago.threads.checksums import update_post_checksum
 from misago.threads.validators import (
-    validate_post, validate_post_length, validate_thread_title
+    validate_post,
+    validate_post_length,
+    validate_thread_title,
 )
 from misago.users.audittrail import create_audit_trail
 
@@ -23,7 +25,7 @@ class ReplyMiddleware(PostingMiddleware):
         if self.mode == PostingEndpoint.START:
             self.new_thread(serializer.validated_data)
 
-        parsing_result = serializer.validated_data['parsing_result']
+        parsing_result = serializer.validated_data["parsing_result"]
 
         if self.mode == PostingEndpoint.EDIT:
             self.edit_post(serializer.validated_data, parsing_result)
@@ -41,7 +43,7 @@ class ReplyMiddleware(PostingMiddleware):
         self.post.update_search_vector()
         update_post_checksum(self.post)
 
-        self.post.update_fields += ['checksum', 'search_vector']
+        self.post.update_fields += ["checksum", "search_vector"]
 
         if self.mode == PostingEndpoint.START:
             self.thread.set_first_post(self.post)
@@ -55,7 +57,7 @@ class ReplyMiddleware(PostingMiddleware):
         self.post.parsing_result = parsing_result
 
     def new_thread(self, validated_data):
-        self.thread.set_title(validated_data['title'])
+        self.thread.set_title(validated_data["title"])
         self.thread.starter_name = self.user.username
         self.thread.starter_slug = self.user.slug
         self.thread.last_poster_name = self.user.username
@@ -65,8 +67,8 @@ class ReplyMiddleware(PostingMiddleware):
         self.thread.save()
 
     def edit_post(self, validated_data, parsing_result):
-        self.post.original = parsing_result['original_text']
-        self.post.parsed = parsing_result['parsed_text']
+        self.post.original = parsing_result["original_text"]
+        self.post.parsed = parsing_result["parsed_text"]
 
     def new_post(self, validated_data, parsing_result):
         self.post.thread = self.thread
@@ -74,15 +76,13 @@ class ReplyMiddleware(PostingMiddleware):
         self.post.poster_name = self.user.username
         self.post.posted_on = self.datetime
 
-        self.post.original = parsing_result['original_text']
-        self.post.parsed = parsing_result['parsed_text']
+        self.post.original = parsing_result["original_text"]
+        self.post.parsed = parsing_result["parsed_text"]
 
 
 class ReplySerializer(serializers.Serializer):
     post = serializers.CharField(
-        error_messages={
-            'required': gettext_lazy("You have to enter a message."),
-        }
+        error_messages={"required": gettext_lazy("You have to enter a message.")}
     )
 
     def validate_post(self, data):
@@ -90,24 +90,24 @@ class ReplySerializer(serializers.Serializer):
         return data
 
     def validate(self, data):
-        if data.get('post'):
-            data['parsing_result'] = self.parse_post(data['post'])
+        if data.get("post"):
+            data["parsing_result"] = self.parse_post(data["post"])
             data = validate_post(self.context, data)
 
         return data
 
     def parse_post(self, post):
-        if self.context['mode'] == PostingEndpoint.START:
-            return common_flavour(self.context['request'], self.context['user'], post)
+        if self.context["mode"] == PostingEndpoint.START:
+            return common_flavour(self.context["request"], self.context["user"], post)
         else:
-            return common_flavour(self.context['request'], self.context['post'].poster, post)
+            return common_flavour(
+                self.context["request"], self.context["post"].poster, post
+            )
 
 
 class ThreadSerializer(ReplySerializer):
     title = serializers.CharField(
-        error_messages={
-            'required': gettext_lazy("You have to enter thread title."),
-        }
+        error_messages={"required": gettext_lazy("You have to enter thread title.")}
     )
 
     def validate_title(self, data):

+ 6 - 7
misago/threads/api/postingendpoint/subscribe.py

@@ -26,7 +26,8 @@ class SubscribeMiddleware(PostingMiddleware):
         self.user.subscription_set.create(
             category=self.thread.category,
             thread=self.thread,
-            send_email=self.user.subscribe_to_started_threads == UserModel.SUBSCRIPTION_ALL,
+            send_email=self.user.subscribe_to_started_threads
+            == UserModel.SUBSCRIPTION_ALL,
         )
 
     def subscribe_replied_thread(self):
@@ -43,11 +44,8 @@ class SubscribeMiddleware(PostingMiddleware):
 
         # posts user's posts in this thread, minus events and current post
         posts_queryset = self.user.post_set.filter(
-            thread=self.thread,
-            is_event=False,
-        ).exclude(
-            pk=self.post.pk,
-        )
+            thread=self.thread, is_event=False
+        ).exclude(pk=self.post.pk)
 
         if posts_queryset.exists():
             return
@@ -55,5 +53,6 @@ class SubscribeMiddleware(PostingMiddleware):
         self.user.subscription_set.create(
             category=self.thread.category,
             thread=self.thread,
-            send_email=self.user.subscribe_to_replied_threads == UserModel.SUBSCRIPTION_ALL,
+            send_email=self.user.subscribe_to_replied_threads
+            == UserModel.SUBSCRIPTION_ALL,
         )

+ 1 - 2
misago/threads/api/postingendpoint/syncprivatethreads.py

@@ -14,6 +14,5 @@ class SyncPrivateThreadsMiddleware(PostingMiddleware):
 
     def post_save(self, serializer):
         set_users_unread_private_threads_sync(
-            participants=self.thread.participants_list,
-            exclude_user=self.user,
+            participants=self.thread.participants_list, exclude_user=self.user
         )

+ 7 - 7
misago/threads/api/postingendpoint/updatestats.py

@@ -16,11 +16,11 @@ class UpdateStatsMiddleware(PostingMiddleware):
             return  # don't update category on moderated post
 
         if self.mode == PostingEndpoint.START:
-            category.threads = F('threads') + 1
+            category.threads = F("threads") + 1
 
         if self.mode != PostingEndpoint.EDIT:
             category.set_last_thread(thread)
-            category.posts = F('posts') + 1
+            category.posts = F("posts") + 1
             category.update_all = True
 
     def update_thread(self, thread, post):
@@ -33,7 +33,7 @@ class UpdateStatsMiddleware(PostingMiddleware):
                 thread.set_last_post(post)
 
             if self.mode == PostingEndpoint.REPLY:
-                thread.replies = F('replies') + 1
+                thread.replies = F("replies") + 1
 
         thread.update_all = True
 
@@ -43,9 +43,9 @@ class UpdateStatsMiddleware(PostingMiddleware):
 
         if self.thread.thread_type.root_name == THREADS_ROOT_NAME:
             if self.mode == PostingEndpoint.START:
-                user.threads = F('threads') + 1
-                user.update_fields.append('threads')
+                user.threads = F("threads") + 1
+                user.update_fields.append("threads")
 
             if self.mode != PostingEndpoint.EDIT:
-                user.posts = F('posts') + 1
-                user.update_fields.append('posts')
+                user.posts = F("posts") + 1
+                user.update_fields.append("posts")

+ 10 - 17
misago/threads/api/threadendpoints/delete.py

@@ -7,7 +7,6 @@ from misago.threads.permissions import allow_delete_thread
 from misago.threads.serializers import DeleteThreadsSerializer
 
 
-
 @transaction.atomic
 def delete_thread(request, thread):
     allow_delete_thread(request.user_acl, thread)
@@ -17,27 +16,21 @@ def delete_thread(request, thread):
 
 def delete_bulk(request, viewmodel):
     serializer = DeleteThreadsSerializer(
-        data={
-            'threads': request.data,
-        },
-        context={
-            'request': request,
-            'viewmodel': viewmodel,
-        },
+        data={"threads": request.data},
+        context={"request": request, "viewmodel": viewmodel},
     )
 
     if not serializer.is_valid():
-        if 'threads' in serializer.errors:
-            errors = serializer.errors['threads']
-            if 'details' in errors:
-                return Response(
-                    hydrate_error_details(errors['details']), status=400)
-            return Response({'detail': errors[0]}, status=403)
+        if "threads" in serializer.errors:
+            errors = serializer.errors["threads"]
+            if "details" in errors:
+                return Response(hydrate_error_details(errors["details"]), status=400)
+            return Response({"detail": errors[0]}, status=403)
         else:
             errors = list(serializer.errors)[0][0]
-            return Response({'detail': errors}, status=400)
+            return Response({"detail": errors}, status=400)
 
-    for thread in serializer.validated_data['threads']:
+    for thread in serializer.validated_data["threads"]:
         with transaction.atomic():
             delete_thread(request, thread)
 
@@ -46,5 +39,5 @@ def delete_bulk(request, viewmodel):
 
 def hydrate_error_details(errors):
     for error in errors:
-        error['thread']['id'] = int(error['thread']['id'])
+        error["thread"]["id"] = int(error["thread"]["id"])
     return errors

+ 18 - 14
misago/threads/api/threadendpoints/editor.py

@@ -19,9 +19,9 @@ def thread_start_editor(request):
     categories = []
 
     queryset = Category.objects.filter(
-        pk__in=request.user_acl['browseable_categories'],
-        tree_id=trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
-    ).order_by('-lft')
+        pk__in=request.user_acl["browseable_categories"],
+        tree_id=trees_map.get_tree_id_for_root(THREADS_ROOT_NAME),
+    ).order_by("-lft")
 
     for category in queryset:
         add_acl_to_obj(request.user_acl, category)
@@ -29,9 +29,9 @@ def thread_start_editor(request):
         post = False
         if can_start_thread(request.user_acl, category):
             post = {
-                'close': bool(category.acl['can_close_threads']),
-                'hide': bool(category.acl['can_hide_threads']),
-                'pin': category.acl['can_pin_threads'],
+                "close": bool(category.acl["can_close_threads"]),
+                "hide": bool(category.acl["can_hide_threads"]),
+                "pin": category.acl["can_pin_threads"],
             }
 
             available.append(category.pk)
@@ -39,22 +39,26 @@ def thread_start_editor(request):
         elif category.pk in available:
             available.append(category.parent_id)
 
-        categories.append({
-            'id': category.pk,
-            'name': category.name,
-            'level': category.level - 1,
-            'post': post,
-        })
+        categories.append(
+            {
+                "id": category.pk,
+                "name": category.name,
+                "level": category.level - 1,
+                "post": post,
+            }
+        )
 
     # list only categories that allow new threads, or contains subcategory that allows one
     cleaned_categories = []
     for category in reversed(categories):
-        if category['id'] in available:
+        if category["id"] in available:
             cleaned_categories.append(category)
 
     if not cleaned_categories:
         raise PermissionDenied(
-            _("No categories that allow new threads are available to you at the moment.")
+            _(
+                "No categories that allow new threads are available to you at the moment."
+            )
         )
 
     return Response(cleaned_categories)

+ 13 - 6
misago/threads/api/threadendpoints/list.py

@@ -2,26 +2,33 @@ from rest_framework.response import Response
 
 from misago.core.shortcuts import get_int_or_404
 from misago.threads.viewmodels import (
-    ForumThreads, PrivateThreads, PrivateThreadsCategory, ThreadsCategory, ThreadsRootCategory)
+    ForumThreads,
+    PrivateThreads,
+    PrivateThreadsCategory,
+    ThreadsCategory,
+    ThreadsRootCategory,
+)
 
 
 class ThreadsList(object):
     threads = None
 
     def __call__(self, request, **kwargs):
-        page = get_int_or_404(request.query_params.get('page', 0))
+        page = get_int_or_404(request.query_params.get("page", 0))
         if page == 1:
             page = 0  # api allows explicit first page
 
-        list_type = request.query_params.get('list', 'all')
+        list_type = request.query_params.get("list", "all")
 
-        category = self.get_category(request, pk=request.query_params.get('category'))
+        category = self.get_category(request, pk=request.query_params.get("category"))
         threads = self.get_threads(request, category, list_type, page)
 
-        return Response(self.get_response_json(request, category, threads)['THREADS'])
+        return Response(self.get_response_json(request, category, threads)["THREADS"])
 
     def get_category(self, request, pk=None):
-        raise NotImplementedError('Threads list has to implement get_category(request, pk=None)')
+        raise NotImplementedError(
+            "Threads list has to implement get_category(request, pk=None)"
+        )
 
     def get_threads(self, request, category, list_type, page):
         return self.threads(request, category, list_type, page)

+ 51 - 57
misago/threads/api/threadendpoints/merge.py

@@ -11,7 +11,10 @@ from misago.threads.models import Thread
 from misago.threads.moderation import threads as moderation
 from misago.threads.permissions import allow_merge_thread
 from misago.threads.serializers import (
-    MergeThreadSerializer, MergeThreadsSerializer, ThreadsListSerializer)
+    MergeThreadSerializer,
+    MergeThreadsSerializer,
+    ThreadsListSerializer,
+)
 
 
 def thread_merge_endpoint(request, thread, viewmodel):
@@ -19,33 +22,31 @@ def thread_merge_endpoint(request, thread, viewmodel):
 
     serializer = MergeThreadSerializer(
         data=request.data,
-        context={
-            'request': request,
-            'thread': thread,
-            'viewmodel': viewmodel,
-        },
+        context={"request": request, "thread": thread, "viewmodel": viewmodel},
     )
 
     if not serializer.is_valid():
-        if 'other_thread' in serializer.errors:
-            errors = serializer.errors['other_thread']
-        elif 'best_answer' in serializer.errors:
-            errors = serializer.errors['best_answer']
-        elif 'best_answers' in serializer.errors:
-            return Response({'best_answers': serializer.errors['best_answers']}, status=400)
-        elif 'poll' in serializer.errors:
-            errors = serializer.errors['poll']
-        elif 'polls' in serializer.errors:
-            return Response({'polls': serializer.errors['polls']}, status=400)
+        if "other_thread" in serializer.errors:
+            errors = serializer.errors["other_thread"]
+        elif "best_answer" in serializer.errors:
+            errors = serializer.errors["best_answer"]
+        elif "best_answers" in serializer.errors:
+            return Response(
+                {"best_answers": serializer.errors["best_answers"]}, status=400
+            )
+        elif "poll" in serializer.errors:
+            errors = serializer.errors["poll"]
+        elif "polls" in serializer.errors:
+            return Response({"polls": serializer.errors["polls"]}, status=400)
         else:
             errors = list(serializer.errors.values())[0]
-        return Response({'detail': errors[0]}, status=400)
+        return Response({"detail": errors[0]}, status=400)
 
     # merge conflict
-    other_thread = serializer.validated_data['other_thread']
+    other_thread = serializer.validated_data["other_thread"]
 
-    best_answer = serializer.validated_data.get('best_answer')
-    if 'best_answer' in serializer.merge_conflict and not best_answer:
+    best_answer = serializer.validated_data.get("best_answer")
+    if "best_answer" in serializer.merge_conflict and not best_answer:
         other_thread.clear_best_answer()
     if best_answer and best_answer != other_thread:
         other_thread.best_answer_id = thread.best_answer_id
@@ -55,8 +56,8 @@ def thread_merge_endpoint(request, thread, viewmodel):
         other_thread.best_answer_marked_by_name = thread.best_answer_marked_by_name
         other_thread.best_answer_marked_by_slug = thread.best_answer_marked_by_slug
 
-    poll = serializer.validated_data.get('poll')
-    if 'poll' in serializer.merge_conflict:
+    poll = serializer.validated_data.get("poll")
+    if "poll" in serializer.merge_conflict:
         if poll and poll.thread_id != other_thread.id:
             other_thread.poll.delete()
             poll.move(other_thread)
@@ -78,44 +79,41 @@ def thread_merge_endpoint(request, thread, viewmodel):
         thread.category.synchronize()
         thread.category.save()
 
-    return Response({
-        'id': other_thread.pk,
-        'title': other_thread.title,
-        'url': other_thread.get_absolute_url(),
-    })
+    return Response(
+        {
+            "id": other_thread.pk,
+            "title": other_thread.title,
+            "url": other_thread.get_absolute_url(),
+        }
+    )
 
 
 def threads_merge_endpoint(request):
     serializer = MergeThreadsSerializer(
         data=request.data,
-        context={
-            'settings': request.settings,
-            'user_acl': request.user_acl,
-        },
+        context={"settings": request.settings, "user_acl": request.user_acl},
     )
 
     if not serializer.is_valid():
-        if 'threads' in serializer.errors:
-            errors = {'detail': serializer.errors['threads'][0]}
+        if "threads" in serializer.errors:
+            errors = {"detail": serializer.errors["threads"][0]}
             return Response(errors, status=403)
-        elif 'non_field_errors' in serializer.errors:
-            errors = {'detail': serializer.errors['non_field_errors'][0]}
+        elif "non_field_errors" in serializer.errors:
+            errors = {"detail": serializer.errors["non_field_errors"][0]}
             return Response(errors, status=403)
         else:
             return Response(serializer.errors, status=400)
 
-    threads = serializer.validated_data['threads']
+    threads = serializer.validated_data["threads"]
     invalid_threads = []
 
     for thread in threads:
         try:
             allow_merge_thread(request.user_acl, thread)
         except PermissionDenied as e:
-            invalid_threads.append({
-                'id': thread.pk,
-                'title': thread.title,
-                'errors': [str(e)]
-            })
+            invalid_threads.append(
+                {"id": thread.pk, "title": thread.title, "errors": [str(e)]}
+            )
 
     if invalid_threads:
         return Response(invalid_threads, status=403)
@@ -124,23 +122,25 @@ def threads_merge_endpoint(request):
     merge_conflict = MergeConflict(serializer.validated_data, threads)
     merge_conflict.is_valid(raise_exception=True)
 
-    new_thread = merge_threads(request, serializer.validated_data, threads, merge_conflict)
+    new_thread = merge_threads(
+        request, serializer.validated_data, threads, merge_conflict
+    )
     return Response(ThreadsListSerializer(new_thread).data)
 
 
 def merge_threads(request, validated_data, threads, merge_conflict):
     new_thread = Thread(
-        category=validated_data['category'],
+        category=validated_data["category"],
         started_on=threads[0].started_on,
         last_post_on=threads[0].last_post_on,
     )
 
-    new_thread.set_title(validated_data['title'])
+    new_thread.set_title(validated_data["title"])
     new_thread.save()
 
     resolution = merge_conflict.get_resolution()
 
-    best_answer = resolution.get('best_answer')
+    best_answer = resolution.get("best_answer")
     if best_answer:
         new_thread.best_answer_id = best_answer.best_answer_id
         new_thread.best_answer_is_protected = best_answer.best_answer_is_protected
@@ -149,7 +149,7 @@ def merge_threads(request, validated_data, threads, merge_conflict):
         new_thread.best_answer_marked_by_name = best_answer.best_answer_marked_by_name
         new_thread.best_answer_marked_by_slug = best_answer.best_answer_marked_by_slug
 
-    poll = resolution.get('poll')
+    poll = resolution.get("poll")
     if poll:
         poll.move(new_thread)
 
@@ -160,25 +160,19 @@ def merge_threads(request, validated_data, threads, merge_conflict):
         thread.delete()
 
         record_event(
-            request,
-            new_thread,
-            'merged',
-            {
-                'merged_thread': thread.title,
-            },
-            commit=False,
+            request, new_thread, "merged", {"merged_thread": thread.title}, commit=False
         )
 
     new_thread.synchronize()
     new_thread.save()
 
-    if validated_data.get('weight') == Thread.WEIGHT_GLOBAL:
+    if validated_data.get("weight") == Thread.WEIGHT_GLOBAL:
         moderation.pin_thread_globally(request, new_thread)
-    elif validated_data.get('weight'):
+    elif validated_data.get("weight"):
         moderation.pin_thread_locally(request, new_thread)
-    if validated_data.get('is_hidden', False):
+    if validated_data.get("is_hidden", False):
         moderation.hide_thread(request, new_thread)
-    if validated_data.get('is_closed', False):
+    if validated_data.get("is_closed", False):
         moderation.close_thread(request, new_thread)
 
     if new_thread.category not in categories:

+ 99 - 74
misago/threads/api/threadendpoints/patch.py

@@ -17,13 +17,28 @@ from misago.core.apipatch import ApiPatch
 from misago.core.shortcuts import get_int_or_404
 from misago.threads.moderation import threads as moderation
 from misago.threads.participants import (
-    add_participant, change_owner, make_participants_aware, remove_participant
+    add_participant,
+    change_owner,
+    make_participants_aware,
+    remove_participant,
 )
 from misago.threads.permissions import (
-    allow_add_participant, allow_add_participants, allow_approve_thread, allow_change_best_answer,
-    allow_change_owner, allow_edit_thread, allow_pin_thread, allow_hide_thread, allow_mark_as_best_answer,
-    allow_mark_best_answer, allow_move_thread, allow_remove_participant, allow_see_post,
-    allow_start_thread, allow_unhide_thread, allow_unmark_best_answer
+    allow_add_participant,
+    allow_add_participants,
+    allow_approve_thread,
+    allow_change_best_answer,
+    allow_change_owner,
+    allow_edit_thread,
+    allow_pin_thread,
+    allow_hide_thread,
+    allow_mark_as_best_answer,
+    allow_mark_best_answer,
+    allow_move_thread,
+    allow_remove_participant,
+    allow_see_post,
+    allow_start_thread,
+    allow_unhide_thread,
+    allow_unmark_best_answer,
 )
 from misago.threads.serializers import ThreadParticipantSerializer
 from misago.threads.validators import validate_thread_title
@@ -39,19 +54,19 @@ def patch_acl(request, thread, value):
     """useful little op that updates thread acl to current state"""
     if value:
         add_acl_to_obj(request.user_acl, thread)
-        return {'acl': thread.acl}
+        return {"acl": thread.acl}
     else:
-        return {'acl': None}
+        return {"acl": None}
 
 
-thread_patch_dispatcher.add('acl', patch_acl)
+thread_patch_dispatcher.add("acl", patch_acl)
 
 
 def patch_title(request, thread, value):
     try:
         value_cleaned = str(value).strip()
     except (TypeError, ValueError):
-        raise PermissionDenied(_('Not a valid string.'))
+        raise PermissionDenied(_("Not a valid string."))
 
     try:
         validate_thread_title(request.settings, value_cleaned)
@@ -61,32 +76,36 @@ def patch_title(request, thread, value):
     allow_edit_thread(request.user_acl, thread)
 
     moderation.change_thread_title(request, thread, value_cleaned)
-    return {'title': thread.title}
+    return {"title": thread.title}
 
 
-thread_patch_dispatcher.replace('title', patch_title)
+thread_patch_dispatcher.replace("title", patch_title)
 
 
 def patch_weight(request, thread, value):
     allow_pin_thread(request.user_acl, thread)
 
-    if not thread.acl.get('can_pin_globally') and thread.weight == 2:
-        raise PermissionDenied(_("You can't change globally pinned threads weights in this category."))
+    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.")
+        )
 
     if value == 2:
-        if thread.acl.get('can_pin_globally'):
+        if thread.acl.get("can_pin_globally"):
             moderation.pin_thread_globally(request, thread)
         else:
-            raise PermissionDenied(_("You can't pin threads globally in this category."))
+            raise PermissionDenied(
+                _("You can't pin threads globally in this category.")
+            )
     elif value == 1:
         moderation.pin_thread_locally(request, thread)
     elif value == 0:
         moderation.unpin_thread(request, thread)
 
-    return {'weight': thread.weight}
+    return {"weight": thread.weight}
 
 
-thread_patch_dispatcher.replace('weight', patch_weight)
+thread_patch_dispatcher.replace("weight", patch_weight)
 
 
 def patch_move(request, thread, value):
@@ -94,7 +113,7 @@ def patch_move(request, thread, value):
 
     category_pk = get_int_or_404(value)
     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_to_obj(request.user_acl, new_category)
@@ -103,24 +122,26 @@ def patch_move(request, thread, value):
     allow_start_thread(request.user_acl, new_category)
 
     if new_category == thread.category:
-        raise PermissionDenied(_("You can't move thread to the category it's already in."))
+        raise PermissionDenied(
+            _("You can't move thread to the category it's already in.")
+        )
 
     moderation.move_thread(request, thread, new_category)
 
-    return {'category': CategorySerializer(new_category).data}
+    return {"category": CategorySerializer(new_category).data}
 
 
-thread_patch_dispatcher.replace('category', patch_move)
+thread_patch_dispatcher.replace("category", patch_move)
 
 
 def patch_flatten_categories(request, thread, value):
     try:
-        return {'category': thread.category_id}
+        return {"category": thread.category_id}
     except AttributeError:
-        return {'category': thread.category_id}
+        return {"category": thread.category_id}
 
 
-thread_patch_dispatcher.replace('flatten-categories', patch_flatten_categories)
+thread_patch_dispatcher.replace("flatten-categories", patch_flatten_categories)
 
 
 def patch_is_unapproved(request, thread, value):
@@ -132,22 +153,22 @@ def patch_is_unapproved(request, thread, value):
     moderation.approve_thread(request, thread)
 
     return {
-        'is_unapproved': thread.is_unapproved,
-        'has_unapproved_posts': thread.has_unapproved_posts,
+        "is_unapproved": thread.is_unapproved,
+        "has_unapproved_posts": thread.has_unapproved_posts,
     }
 
 
-thread_patch_dispatcher.replace('is-unapproved', patch_is_unapproved)
+thread_patch_dispatcher.replace("is-unapproved", patch_is_unapproved)
 
 
 def patch_is_closed(request, thread, value):
-    if thread.acl.get('can_close'):
+    if thread.acl.get("can_close"):
         if value:
             moderation.close_thread(request, thread)
         else:
             moderation.open_thread(request, thread)
 
-        return {'is_closed': thread.is_closed}
+        return {"is_closed": thread.is_closed}
     else:
         if value:
             raise PermissionDenied(_("You don't have permission to close this thread."))
@@ -155,7 +176,7 @@ def patch_is_closed(request, thread, value):
             raise PermissionDenied(_("You don't have permission to open this thread."))
 
 
-thread_patch_dispatcher.replace('is-closed', patch_is_closed)
+thread_patch_dispatcher.replace("is-closed", patch_is_closed)
 
 
 def patch_is_hidden(request, thread, value):
@@ -166,16 +187,16 @@ def patch_is_hidden(request, thread, value):
         allow_unhide_thread(request.user_acl, thread)
         moderation.unhide_thread(request, thread)
 
-    return {'is_hidden': thread.is_hidden}
+    return {"is_hidden": thread.is_hidden}
 
 
-thread_patch_dispatcher.replace('is-hidden', patch_is_hidden)
+thread_patch_dispatcher.replace("is-hidden", patch_is_hidden)
 
 
 def patch_subscription(request, thread, value):
     request.user.subscription_set.filter(thread=thread).delete()
 
-    if value == 'notify':
+    if value == "notify":
         thread.subscription = request.user.subscription_set.create(
             thread=thread,
             category=thread.category,
@@ -183,8 +204,8 @@ def patch_subscription(request, thread, value):
             send_email=False,
         )
 
-        return {'subscription': False}
-    elif value == 'email':
+        return {"subscription": False}
+    elif value == "email":
         thread.subscription = request.user.subscription_set.create(
             thread=thread,
             category=thread.category,
@@ -192,12 +213,12 @@ def patch_subscription(request, thread, value):
             send_email=True,
         )
 
-        return {'subscription': True}
+        return {"subscription": True}
     else:
-        return {'subscription': None}
+        return {"subscription": None}
 
 
-thread_patch_dispatcher.replace('subscription', patch_subscription)
+thread_patch_dispatcher.replace("subscription", patch_subscription)
 
 
 def patch_best_answer(request, thread, value):
@@ -216,25 +237,27 @@ def patch_best_answer(request, thread, value):
     allow_mark_as_best_answer(request.user_acl, post)
 
     if post.is_best_answer:
-        raise PermissionDenied(_("This post is already marked as thread's best answer."))
+        raise PermissionDenied(
+            _("This post is already marked as thread's best answer.")
+        )
 
     if thread.has_best_answer:
         allow_change_best_answer(request.user_acl, thread)
-        
+
     thread.set_best_answer(request.user, post)
     thread.save()
 
     return {
-        'best_answer': thread.best_answer_id,
-        'best_answer_is_protected': thread.best_answer_is_protected,
-        'best_answer_marked_on': thread.best_answer_marked_on,
-        'best_answer_marked_by': thread.best_answer_marked_by_id,
-        'best_answer_marked_by_name': thread.best_answer_marked_by_name,
-        'best_answer_marked_by_slug': thread.best_answer_marked_by_slug,
+        "best_answer": thread.best_answer_id,
+        "best_answer_is_protected": thread.best_answer_is_protected,
+        "best_answer_marked_on": thread.best_answer_marked_on,
+        "best_answer_marked_by": thread.best_answer_marked_by_id,
+        "best_answer_marked_by_name": thread.best_answer_marked_by_name,
+        "best_answer_marked_by_slug": thread.best_answer_marked_by_slug,
     }
 
 
-thread_patch_dispatcher.replace('best-answer', patch_best_answer)
+thread_patch_dispatcher.replace("best-answer", patch_best_answer)
 
 
 def patch_unmark_best_answer(request, thread, value):
@@ -249,23 +272,26 @@ def patch_unmark_best_answer(request, thread, value):
 
     if not post.is_best_answer:
         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_acl, thread)
     thread.clear_best_answer()
     thread.save()
 
     return {
-        'best_answer': None,
-        'best_answer_is_protected': False,
-        'best_answer_marked_on': None,
-        'best_answer_marked_by': None,
-        'best_answer_marked_by_name': None,
-        'best_answer_marked_by_slug': None,
+        "best_answer": None,
+        "best_answer_is_protected": False,
+        "best_answer_marked_on": None,
+        "best_answer_marked_by": None,
+        "best_answer_marked_by_name": None,
+        "best_answer_marked_by_slug": None,
     }
 
 
-thread_patch_dispatcher.remove('best-answer', patch_unmark_best_answer)
+thread_patch_dispatcher.remove("best-answer", patch_unmark_best_answer)
 
 
 def patch_add_participant(request, thread, value):
@@ -289,10 +315,10 @@ def patch_add_participant(request, thread, value):
     make_participants_aware(request.user, thread)
     participants = ThreadParticipantSerializer(thread.participants_list, many=True)
 
-    return {'participants': participants.data}
+    return {"participants": participants.data}
 
 
-thread_patch_dispatcher.add('participants', patch_add_participant)
+thread_patch_dispatcher.add("participants", patch_add_participant)
 
 
 def patch_remove_participant(request, thread, value):
@@ -311,18 +337,15 @@ def patch_remove_participant(request, thread, value):
     remove_participant(request, thread, participant.user)
 
     if len(thread.participants_list) == 1:
-        return {'deleted': True}
+        return {"deleted": True}
     else:
         make_participants_aware(request.user, thread)
         participants = ThreadParticipantSerializer(thread.participants_list, many=True)
 
-        return {
-            'deleted': False,
-            'participants': participants.data,
-        }
+        return {"deleted": False, "participants": participants.data}
 
 
-thread_patch_dispatcher.remove('participants', patch_remove_participant)
+thread_patch_dispatcher.remove("participants", patch_remove_participant)
 
 
 def patch_replace_owner(request, thread, value):
@@ -345,10 +368,10 @@ def patch_replace_owner(request, thread, value):
 
     make_participants_aware(request.user, thread)
     participants = ThreadParticipantSerializer(thread.participants_list, many=True)
-    return {'participants': participants.data}
+    return {"participants": participants.data}
 
 
-thread_patch_dispatcher.replace('owner', patch_replace_owner)
+thread_patch_dispatcher.replace("owner", patch_replace_owner)
 
 
 def thread_patch_endpoint(request, thread):
@@ -378,7 +401,7 @@ def thread_patch_endpoint(request, thread):
     elif title_changed:
         thread.category.last_thread_title = thread.title
         thread.category.last_thread_slug = thread.slug
-        thread.category.save(update_fields=['last_thread_title', 'last_thread_slug'])
+        thread.category.save(update_fields=["last_thread_title", "last_thread_slug"])
 
     return response
 
@@ -388,7 +411,7 @@ def bulk_patch_endpoint(request, viewmodel):
     if not serializer.is_valid():
         return Response(serializer.errors, status=400)
 
-    threads = clean_threads_for_patch(request, viewmodel, serializer.data['ids'])
+    threads = clean_threads_for_patch(request, viewmodel, serializer.data["ids"])
 
     old_titles = [t.title for t in threads]
     old_is_hidden = [t.is_hidden for t in threads]
@@ -408,7 +431,7 @@ def bulk_patch_endpoint(request, viewmodel):
             if t.title != old_titles[i] and t.category.last_thread_id == t.pk:
                 t.category.last_thread_title = t.title
                 t.category.last_thread_slug = t.slug
-                t.category.save(update_fields=['last_thread_title', 'last_thread_slug'])
+                t.category.save(update_fields=["last_thread_title", "last_thread_slug"])
 
     # sync categories
     sync_categories = []
@@ -420,7 +443,10 @@ def bulk_patch_endpoint(request, viewmodel):
 
     if new_is_unapproved != old_is_unapproved:
         for i, t in enumerate(threads):
-            if t.is_unapproved != old_is_unapproved[i] and t.category_id not in sync_categories:
+            if (
+                t.is_unapproved != old_is_unapproved[i]
+                and t.category_id not in sync_categories
+            ):
                 sync_categories.append(t.category_id)
 
     if new_category != old_category:
@@ -445,7 +471,9 @@ def clean_threads_for_patch(request, viewmodel, threads_ids):
         try:
             threads.append(viewmodel(request, thread_id).unwrap())
         except (Http404, PermissionDenied):
-            raise PermissionDenied(_("One or more threads to update could not be found."))
+            raise PermissionDenied(
+                _("One or more threads to update could not be found.")
+            )
     return threads
 
 
@@ -456,8 +484,5 @@ class BulkPatchSerializer(serializers.Serializer):
         min_length=1,
     )
     ops = serializers.ListField(
-        child=serializers.DictField(),
-        min_length=1,
-        max_length=10,
+        child=serializers.DictField(), min_length=1, max_length=10
     )
-

+ 21 - 17
misago/threads/api/threadpoll.py

@@ -11,9 +11,18 @@ from misago.acl.objectacl import add_acl_to_obj
 from misago.core.shortcuts import get_int_or_404
 from misago.threads.models import Poll
 from misago.threads.permissions import (
-    allow_delete_poll, allow_edit_poll, allow_see_poll_votes, allow_start_poll, can_start_poll)
+    allow_delete_poll,
+    allow_edit_poll,
+    allow_see_poll_votes,
+    allow_start_poll,
+    can_start_poll,
+)
 from misago.threads.serializers import (
-    EditPollSerializer, NewPollSerializer, PollSerializer, PollVoteSerializer)
+    EditPollSerializer,
+    NewPollSerializer,
+    PollSerializer,
+    PollVoteSerializer,
+)
 from misago.threads.viewmodels import ForumThread
 from misago.users.audittrail import create_audit_trail
 
@@ -24,10 +33,7 @@ class ViewSet(viewsets.ViewSet):
     thread = None
 
     def get_thread(self, request, thread_pk):
-        return self.thread(
-            request,
-            get_int_or_404(thread_pk),
-        ).unwrap()
+        return self.thread(request, get_int_or_404(thread_pk)).unwrap()
 
     def get_poll(self, thread, pk):
         try:
@@ -70,7 +76,7 @@ class ViewSet(viewsets.ViewSet):
 
         add_acl_to_obj(request.user_acl, instance)
         for choice in instance.choices:
-            choice['selected'] = False
+            choice["selected"] = False
 
         thread.has_poll = True
         thread.save()
@@ -110,13 +116,11 @@ class ViewSet(viewsets.ViewSet):
         thread.has_poll = False
         thread.save()
 
-        return Response({
-            'can_start_poll': can_start_poll(request.user_acl, thread),
-        })
+        return Response({"can_start_poll": can_start_poll(request.user_acl, thread)})
 
-    @detail_route(methods=['get', 'post'])
+    @detail_route(methods=["get", "post"])
     def votes(self, request, thread_pk, pk=None):
-        if request.method == 'POST':
+        if request.method == "POST":
             return self.post_votes(request, thread_pk, pk)
         else:
             return self.get_votes(request, thread_pk, pk)
@@ -144,17 +148,17 @@ class ViewSet(viewsets.ViewSet):
         voters = {}
 
         for choice in thread.poll.choices:
-            choice['voters'] = []
-            voters[choice['hash']] = choice['voters']
+            choice["voters"] = []
+            voters[choice["hash"]] = choice["voters"]
 
             choices.append(choice)
 
         queryset = thread.poll.pollvote_set.values(
-            'voter_id', 'voter_name', 'voter_slug', 'voted_on', 'choice_hash'
+            "voter_id", "voter_name", "voter_slug", "voted_on", "choice_hash"
         )
 
-        for voter in queryset.order_by('voter_name').iterator():
-            voters[voter['choice_hash']].append(PollVoteSerializer(voter).data)
+        for voter in queryset.order_by("voter_name").iterator():
+            voters[voter["choice_hash"]].append(PollVoteSerializer(voter).data)
 
         return Response(choices)
 

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

@@ -11,7 +11,12 @@ from misago.core.shortcuts import get_int_or_404
 from misago.threads.models import Post
 from misago.threads.permissions import allow_edit_post, allow_reply_thread
 from misago.threads.serializers import AttachmentSerializer, PostSerializer
-from misago.threads.viewmodels import ForumThread, PrivateThread, ThreadPost, ThreadPosts
+from misago.threads.viewmodels import (
+    ForumThread,
+    PrivateThread,
+    ThreadPost,
+    ThreadPosts,
+)
 from misago.users.online.utils import make_users_status_aware
 
 from .postendpoints.delete import delete_bulk, delete_post
@@ -31,7 +36,9 @@ class ViewSet(viewsets.ViewSet):
     posts = ThreadPosts
     post_ = ThreadPost
 
-    def get_thread(self, request, pk, path_aware=False, read_aware=False, subscription_aware=False):
+    def get_thread(
+        self, request, pk, path_aware=False, read_aware=False, subscription_aware=False
+    ):
         return self.thread(
             request,
             get_int_or_404(pk),
@@ -47,7 +54,7 @@ class ViewSet(viewsets.ViewSet):
         return self.post_(request, thread, get_int_or_404(pk))
 
     def list(self, request, thread_pk):
-        page = get_int_or_404(request.query_params.get('page', 0))
+        page = get_int_or_404(request.query_params.get("page", 0))
         if page == 1:
             page = 0  # api allows explicit first page
 
@@ -61,23 +68,23 @@ class ViewSet(viewsets.ViewSet):
         posts = self.get_posts(request, thread, page)
 
         data = thread.get_frontend_context()
-        data['post_set'] = posts.get_frontend_context()
+        data["post_set"] = posts.get_frontend_context()
 
         return Response(data)
 
-    @list_route(methods=['post'])
+    @list_route(methods=["post"])
     @transaction.atomic
     def merge(self, request, thread_pk):
         thread = self.get_thread(request, thread_pk).unwrap()
         return posts_merge_endpoint(request, thread)
 
-    @list_route(methods=['post'])
+    @list_route(methods=["post"])
     @transaction.atomic
     def move(self, request, thread_pk):
         thread = self.get_thread(request, thread_pk).unwrap()
         return posts_move_endpoint(request, thread, self.thread)
 
-    @list_route(methods=['post'])
+    @list_route(methods=["post"])
     @transaction.atomic
     def split(self, request, thread_pk):
         thread = self.get_thread(request, thread_pk).unwrap()
@@ -88,17 +95,11 @@ class ViewSet(viewsets.ViewSet):
         thread = self.get_thread(request, thread_pk).unwrap()
         allow_reply_thread(request.user_acl, thread)
 
-        post = Post(
-            thread=thread,
-            category=thread.category,
-        )
+        post = Post(thread=thread, category=thread.category)
 
         # Put them through posting pipeline
         posting = PostingEndpoint(
-            request,
-            PostingEndpoint.REPLY,
-            thread=thread,
-            post=post,
+            request, PostingEndpoint.REPLY, thread=thread, post=post
         )
 
         if posting.is_valid():
@@ -113,7 +114,7 @@ class ViewSet(viewsets.ViewSet):
 
             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:
             return Response(posting.errors, status=400)
 
@@ -125,10 +126,7 @@ class ViewSet(viewsets.ViewSet):
         allow_edit_post(request.user_acl, post)
 
         posting = PostingEndpoint(
-            request,
-            PostingEndpoint.EDIT,
-            thread=thread,
-            post=post,
+            request, PostingEndpoint.EDIT, thread=thread, post=post
         )
 
         if posting.is_valid():
@@ -143,7 +141,7 @@ class ViewSet(viewsets.ViewSet):
             if 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:
             return Response(posting.errors, status=400)
 
@@ -171,19 +169,15 @@ class ViewSet(viewsets.ViewSet):
 
         return delete_bulk(request, thread.unwrap())
 
-    @detail_route(methods=['post'])
+    @detail_route(methods=["post"])
     def read(self, request, thread_pk, pk=None):
-        thread = self.get_thread(
-            request,
-            thread_pk,
-            subscription_aware=True,
-        ).unwrap()
+        thread = self.get_thread(request, thread_pk, subscription_aware=True).unwrap()
 
         post = self.get_post(request, thread, pk).unwrap()
 
         return post_read_endpoint(request, thread, post)
 
-    @detail_route(methods=['get'], url_path='editor')
+    @detail_route(methods=["get"], url_path="editor")
     def post_editor(self, request, thread_pk, pk=None):
         thread = self.get_thread(request, thread_pk)
         post = self.get_post(request, thread, pk).unwrap()
@@ -191,53 +185,59 @@ class ViewSet(viewsets.ViewSet):
         allow_edit_post(request.user_acl, post)
 
         attachments = []
-        for attachment in post.attachment_set.order_by('-id'):
+        for attachment in post.attachment_set.order_by("-id"):
             add_acl_to_obj(request.user_acl, attachment)
             attachments.append(attachment)
         attachments_json = AttachmentSerializer(
-            attachments, many=True, context={'user': request.user}
+            attachments, many=True, context={"user": request.user}
         ).data
 
-        return Response({
-            'id': post.pk,
-            'api': post.get_api_url(),
-            'post': post.original,
-            'attachments': attachments_json,
-            'can_protect': bool(thread.category.acl['can_protect_posts']),
-            'is_protected': post.is_protected,
-            'poster': post.poster_name,
-        })
-
-    @list_route(methods=['get'], url_path='editor')
+        return Response(
+            {
+                "id": post.pk,
+                "api": post.get_api_url(),
+                "post": post.original,
+                "attachments": attachments_json,
+                "can_protect": bool(thread.category.acl["can_protect_posts"]),
+                "is_protected": post.is_protected,
+                "poster": post.poster_name,
+            }
+        )
+
+    @list_route(methods=["get"], url_path="editor")
     def reply_editor(self, request, thread_pk):
         thread = self.get_thread(request, thread_pk).unwrap()
         allow_reply_thread(request.user_acl, thread)
 
-        if 'reply' in request.query_params:
-            reply_to = self.get_post(request, thread, request.query_params['reply']).unwrap()
+        if "reply" in request.query_params:
+            reply_to = self.get_post(
+                request, thread, request.query_params["reply"]
+            ).unwrap()
 
             if reply_to.is_event:
                 raise PermissionDenied(_("You can't reply to events."))
-            if reply_to.is_hidden and not reply_to.acl['can_see_hidden']:
+            if reply_to.is_hidden and not reply_to.acl["can_see_hidden"]:
                 raise PermissionDenied(_("You can't reply to hidden posts."))
 
-            return Response({
-                'id': reply_to.pk,
-                'post': reply_to.original,
-                'poster': reply_to.poster_name,
-            })
+            return Response(
+                {
+                    "id": reply_to.pk,
+                    "post": reply_to.original,
+                    "poster": reply_to.poster_name,
+                }
+            )
         else:
             return Response({})
 
-    @detail_route(methods=['get', 'post'])
+    @detail_route(methods=["get", "post"])
     def edits(self, request, thread_pk, pk=None):
-        if request.method == 'GET':
+        if request.method == "GET":
             thread = self.get_thread(request, thread_pk)
             post = self.get_post(request, thread, pk).unwrap()
 
             return get_edit_endpoint(request, post)
 
-        if request.method == 'POST':
+        if request.method == "POST":
             with transaction.atomic():
                 thread = self.get_thread(request, thread_pk)
                 post = self.get_post(request, thread, pk).unwrap()
@@ -246,12 +246,12 @@ class ViewSet(viewsets.ViewSet):
 
                 return revert_post_endpoint(request, post)
 
-    @detail_route(methods=['get'])
+    @detail_route(methods=["get"])
     def likes(self, request, thread_pk, pk=None):
         thread = self.get_thread(request, thread_pk)
         post = self.get_post(request, thread, pk).unwrap()
 
-        if post.acl['can_see_likes'] < 2:
+        if post.acl["can_see_likes"] < 2:
             raise PermissionDenied(_("You can't see who liked this post."))
 
         return likes_list_endpoint(request, post)

+ 28 - 22
misago/threads/api/threads.py

@@ -11,8 +11,12 @@ from misago.core.shortcuts import get_int_or_404
 from misago.threads.models import Post, Thread
 from misago.threads.moderation import threads as moderation
 from misago.threads.permissions import allow_use_private_threads
-from misago.threads.viewmodels import (ForumThread, PrivateThread,
-    ThreadsRootCategory, PrivateThreadsCategory)
+from misago.threads.viewmodels import (
+    ForumThread,
+    PrivateThread,
+    ThreadsRootCategory,
+    PrivateThreadsCategory,
+)
 
 from .postingendpoint import PostingEndpoint
 from .threadendpoints.delete import delete_bulk, delete_thread
@@ -25,7 +29,9 @@ from .threadendpoints.patch import bulk_patch_endpoint, thread_patch_endpoint
 class ViewSet(viewsets.ViewSet):
     thread = None
 
-    def get_thread(self, request, pk, path_aware=False, read_aware=False, subscription_aware=False):
+    def get_thread(
+        self, request, pk, path_aware=False, read_aware=False, subscription_aware=False
+    ):
         return self.thread(
             request,
             get_int_or_404(pk),
@@ -36,11 +42,7 @@ class ViewSet(viewsets.ViewSet):
 
     def retrieve(self, request, pk):
         thread = self.get_thread(
-            request,
-            pk,
-            path_aware=True,
-            read_aware=True,
-            subscription_aware=True,
+            request, pk, path_aware=True, read_aware=True, subscription_aware=True
         )
 
         return Response(thread.get_frontend_context())
@@ -85,26 +87,28 @@ class ThreadViewSet(ViewSet):
         if posting.is_valid():
             posting.save()
 
-            return Response({
-                'id': thread.pk,
-                'title': thread.title,
-                'url': thread.get_absolute_url(),
-            })
+            return Response(
+                {
+                    "id": thread.pk,
+                    "title": thread.title,
+                    "url": thread.get_absolute_url(),
+                }
+            )
         else:
             return Response(posting.errors, status=400)
 
-    @detail_route(methods=['post'], url_path='merge')
+    @detail_route(methods=["post"], url_path="merge")
     @transaction.atomic
     def thread_merge(self, request, pk=None):
         thread = self.get_thread(request, pk).unwrap()
         return thread_merge_endpoint(request, thread, self.thread)
 
-    @list_route(methods=['post'], url_path='merge')
+    @list_route(methods=["post"], url_path="merge")
     @transaction.atomic
     def threads_merge(self, request):
         return threads_merge_endpoint(request)
 
-    @list_route(methods=['get'])
+    @list_route(methods=["get"])
     def editor(self, request):
         return thread_start_editor(request)
 
@@ -119,7 +123,7 @@ class PrivateThreadViewSet(ViewSet):
     @transaction.atomic
     def create(self, request):
         allow_use_private_threads(request.user_acl)
-        if not request.user_acl['can_start_private_threads']:
+        if not request.user_acl["can_start_private_threads"]:
             raise PermissionDenied(_("You can't start private threads."))
 
         request.user.lock()
@@ -140,10 +144,12 @@ class PrivateThreadViewSet(ViewSet):
         if posting.is_valid():
             posting.save()
 
-            return Response({
-                'id': thread.pk,
-                'title': thread.title,
-                'url': thread.get_absolute_url(),
-            })
+            return Response(
+                {
+                    "id": thread.pk,
+                    "title": thread.title,
+                    "url": thread.get_absolute_url(),
+                }
+            )
         else:
             return Response(posting.errors, status=400)

+ 2 - 2
misago/threads/apps.py

@@ -2,8 +2,8 @@ from django.apps import AppConfig
 
 
 class MisagoThreadsConfig(AppConfig):
-    name = 'misago.threads'
-    label = 'misago_threads'
+    name = "misago.threads"
+    label = "misago_threads"
     verbose_name = "Misago Threads"
 
     def ready(self):

+ 9 - 7
misago/threads/context_processors.py

@@ -2,12 +2,14 @@ from django.urls import reverse
 
 
 def preload_threads_urls(request):
-    request.frontend_context.update({
-        'ATTACHMENTS_API': reverse('misago:api:attachment-list'),
-        'THREAD_EDITOR_API': reverse('misago:api:thread-editor'),
-        'THREADS_API': reverse('misago:api:thread-list'),
-        'PRIVATE_THREADS_API': reverse('misago:api:private-thread-list'),
-        'PRIVATE_THREADS_URL': reverse('misago:private-threads'),
-    })
+    request.frontend_context.update(
+        {
+            "ATTACHMENTS_API": reverse("misago:api:attachment-list"),
+            "THREAD_EDITOR_API": reverse("misago:api:thread-editor"),
+            "THREADS_API": reverse("misago:api:thread-list"),
+            "PRIVATE_THREADS_API": reverse("misago:api:private-thread-list"),
+            "PRIVATE_THREADS_URL": reverse("misago:private-threads"),
+        }
+    )
 
     return {}

+ 2 - 2
misago/threads/events.py

@@ -13,8 +13,8 @@ def record_event(request, thread, event_type, context=None, commit=True):
         thread=thread,
         poster=request.user,
         poster_name=request.user.username,
-        original='-',
-        parsed='-',
+        original="-",
+        parsed="-",
         posted_on=time_now,
         updated_on=time_now,
         is_event=True,

+ 36 - 32
misago/threads/forms.py

@@ -6,7 +6,7 @@ from .models import AttachmentType
 
 def get_searchable_filetypes():
     choices = [(0, _("All types"))]
-    choices += [(a.id, a.name) for a in AttachmentType.objects.order_by('name')]
+    choices += [(a.id, a.name) for a in AttachmentType.objects.order_by("name")]
     return choices
 
 
@@ -24,22 +24,24 @@ class SearchAttachmentsForm(forms.Form):
         label=_("State"),
         required=False,
         choices=[
-            ('', _("All")),
-            ('yes', _("Only orphaned")),
-            ('no', _("Not orphaned")),
+            ("", _("All")),
+            ("yes", _("Only orphaned")),
+            ("no", _("Not orphaned")),
         ],
     )
 
     def filter_queryset(self, criteria, queryset):
-        if criteria.get('uploader'):
-            queryset = queryset.filter(uploader_slug__contains=criteria['uploader'].lower())
-        if criteria.get('filename'):
-            queryset = queryset.filter(filename__icontains=criteria['filename'])
-        if criteria.get('filetype'):
-            queryset = queryset.filter(filetype_id=criteria['filetype'])
-        if criteria.get('is_orphan') == 'yes':
+        if criteria.get("uploader"):
+            queryset = queryset.filter(
+                uploader_slug__contains=criteria["uploader"].lower()
+            )
+        if criteria.get("filename"):
+            queryset = queryset.filter(filename__icontains=criteria["filename"])
+        if criteria.get("filetype"):
+            queryset = queryset.filter(filetype_id=criteria["filetype"])
+        if criteria.get("is_orphan") == "yes":
             queryset = queryset.filter(post__isnull=True)
-        elif criteria.get('is_orphan') == 'no':
+        elif criteria.get("is_orphan") == "no":
             queryset = queryset.filter(post__isnull=False)
         return queryset
 
@@ -47,33 +49,35 @@ class SearchAttachmentsForm(forms.Form):
 class AttachmentTypeForm(forms.ModelForm):
     class Meta:
         model = AttachmentType
-        fields = '__all__'
+        fields = "__all__"
         labels = {
-            'name': _("Type name"),
-            'extensions': _("File extensions"),
-            'mimetypes': _("Mimetypes"),
-            'size_limit': _("Maximum allowed uploaded file size"),
-            'status': _("Status"),
-            'limit_uploads_to': _("Limit uploads to"),
-            'limit_downloads_to': _("Limit downloads to"),
+            "name": _("Type name"),
+            "extensions": _("File extensions"),
+            "mimetypes": _("Mimetypes"),
+            "size_limit": _("Maximum allowed uploaded file size"),
+            "status": _("Status"),
+            "limit_uploads_to": _("Limit uploads to"),
+            "limit_downloads_to": _("Limit downloads to"),
         }
         help_texts = {
-            'extensions': _("List of comma separated file extensions associated with this attachment type."),
-            'mimetypes': _(
+            "extensions": _(
+                "List of comma separated file extensions associated with this attachment type."
+            ),
+            "mimetypes": _(
                 "Optional list of comma separated mime types associated with this attachment type."
             ),
-            'size_limit': _(
+            "size_limit": _(
                 "Maximum allowed uploaded file size for this type, in kb. "
                 "May be overriden via user permission."
             ),
-            'status': _("Controls this attachment type availability on your site."),
-            'limit_uploads_to': _(
+            "status": _("Controls this attachment type availability on your site."),
+            "limit_uploads_to": _(
                 "If you wish to limit option to upload files of this type to users with specific "
                 "roles, select them on this list. Otherwhise don't select any roles to allow all "
                 "users with permission to upload attachments to be able to upload attachments of "
                 "this type."
             ),
-            'limit_downloads_to': _(
+            "limit_downloads_to": _(
                 "If you wish to limit option to download files of this type to users with "
                 "specific roles, select them on this list. Otherwhise don't select any roles to "
                 "allow all users with permission to download attachments to be able to download "
@@ -81,22 +85,22 @@ class AttachmentTypeForm(forms.ModelForm):
             ),
         }
         widgets = {
-            'limit_uploads_to': forms.CheckboxSelectMultiple,
-            'limit_downloads_to': forms.CheckboxSelectMultiple,
+            "limit_uploads_to": forms.CheckboxSelectMultiple,
+            "limit_downloads_to": forms.CheckboxSelectMultiple,
         }
 
     def clean_extensions(self):
-        data = self.clean_list(self.cleaned_data['extensions'])
+        data = self.clean_list(self.cleaned_data["extensions"])
         if not data:
             raise forms.ValidationError(_("This field is required."))
         return data
 
     def clean_mimetypes(self):
-        data = self.cleaned_data['mimetypes']
+        data = self.cleaned_data["mimetypes"]
         if not data:
             return None
         return self.clean_list(data)
 
     def clean_list(self, value):
-        items = [v.lstrip('.') for v in value.lower().replace(' ', '').split(',')]
-        return ','.join(set(filter(bool, items)))
+        items = [v.lstrip(".") for v in value.lower().replace(" ", "").split(",")]
+        return ",".join(set(filter(bool, items)))

+ 4 - 5
misago/threads/management/commands/clearattachments.py

@@ -14,11 +14,10 @@ class Command(BaseCommand):
     help = "Deletes attachments unassociated with any posts"
 
     def handle(self, *args, **options):
-        cutoff = timezone.now() - timedelta(minutes=settings.MISAGO_ATTACHMENT_ORPHANED_EXPIRE)
-        queryset = Attachment.objects.filter(
-            post__isnull=True,
-            uploaded_on__lt=cutoff,
+        cutoff = timezone.now() - timedelta(
+            minutes=settings.MISAGO_ATTACHMENT_ORPHANED_EXPIRE
         )
+        queryset = Attachment.objects.filter(post__isnull=True, uploaded_on__lt=cutoff)
 
         attachments_to_sync = queryset.count()
 
@@ -33,7 +32,7 @@ class Command(BaseCommand):
         cleared_count = 0
         show_progress(self, cleared_count, attachments_to_sync)
         start_time = time.time()
-        
+
         for attachment in chunk_queryset(queryset):
             attachment.delete()
 

+ 3 - 3
misago/threads/management/commands/rebuildpostssearch.py

@@ -25,16 +25,16 @@ class Command(BaseCommand):
         show_progress(self, rebuild_count, posts_to_reindex)
         start_time = time.time()
 
-        queryset = Post.objects.select_related('thread').filter(is_event=False)
+        queryset = Post.objects.select_related("thread").filter(is_event=False)
         for post in chunk_queryset(queryset):
             if post.id == post.thread.first_post_id:
                 post.set_search_document(post.thread.title)
             else:
                 post.set_search_document()
-            post.save(update_fields=['search_document'])
+            post.save(update_fields=["search_document"])
 
             post.update_search_vector()
-            post.save(update_fields=['search_vector'])
+            post.save(update_fields=["search_vector"])
 
             rebuild_count += 1
             show_progress(self, rebuild_count, posts_to_reindex, start_time)

+ 1 - 1
misago/threads/management/commands/updatepostschecksums.py

@@ -29,7 +29,7 @@ class Command(BaseCommand):
         queryset = Post.objects.filter(is_event=False)
         for post in chunk_queryset(queryset):
             update_post_checksum(post)
-            post.save(update_fields=['checksum'])
+            post.save(update_fields=["checksum"])
 
             updated_count += 1
             show_progress(self, updated_count, posts_to_update, start_time)

+ 11 - 11
misago/threads/mergeconflict.py

@@ -21,7 +21,7 @@ class MergeConflictHandler(object):
             self._resolution = self.items[0]
 
     def populate_from_threads(self, threads):
-        raise NotImplementedError('merge handler must define populate_from_threads')
+        raise NotImplementedError("merge handler must define populate_from_threads")
 
     def is_merge_conflict(self):
         return len(self.items) > 1
@@ -44,8 +44,8 @@ class MergeConflictHandler(object):
 
 
 class BestAnswerMergeHandler(MergeConflictHandler):
-    data_name = 'best_answer'
-    
+    data_name = "best_answer"
+
     def populate_from_threads(self, threads):
         for thread in threads:
             if thread.has_best_answer:
@@ -61,7 +61,7 @@ class BestAnswerMergeHandler(MergeConflictHandler):
 
 
 class PollMergeHandler(MergeConflictHandler):
-    data_name = 'poll'
+    data_name = "poll"
 
     def populate_from_threads(self, threads):
         for thread in threads:
@@ -75,7 +75,9 @@ class PollMergeHandler(MergeConflictHandler):
     def get_available_resolutions(self):
         resolutions = [[0, _("Delete all polls")]]
         for poll in self.items:
-            resolutions.append([poll.id, '%s (%s)' % (poll.question, poll.thread.title)])
+            resolutions.append(
+                [poll.id, "%s (%s)" % (poll.question, poll.thread.title)]
+            )
         return resolutions
 
 
@@ -84,10 +86,8 @@ class MergeConflict(object):
     Utility class single point of entry for detecting merge conflicts on different properties
     and validating user resolutions.
     """
-    HANDLERS = (
-        BestAnswerMergeHandler,
-        PollMergeHandler,
-    )
+
+    HANDLERS = (BestAnswerMergeHandler, PollMergeHandler)
 
     def __init__(self, data=None, threads=None):
         self.data = data or {}
@@ -131,11 +131,11 @@ class MergeConflict(object):
     def raise_resolutions_exception(self):
         resolutions = {}
         for conflict in self._conflicts:
-            key = '%ss' % conflict.data_name
+            key = "%ss" % conflict.data_name
             resolutions[key] = conflict.get_available_resolutions()
         if resolutions:
             raise ValidationError(resolutions)
 
     def get_resolution(self):
         resolved_handlers = [i for i in self._handlers if i.is_valid()]
-        return {i.data_name: i.get_resolution() for i in resolved_handlers}
+        return {i.data_name: i.get_resolution() for i in resolved_handlers}

+ 10 - 9
misago/threads/middleware.py

@@ -11,26 +11,27 @@ class UnreadThreadsCountMiddleware(MiddlewareMixin):
         if request.user.is_anonymous:
             return
 
-        if not request.user_acl['can_use_private_threads']:
+        if not request.user_acl["can_use_private_threads"]:
             return
 
         if not request.user.sync_unread_private_threads:
             return
 
-        participated_threads = request.user.threadparticipant_set.values('thread_id')
+        participated_threads = request.user.threadparticipant_set.values("thread_id")
 
         category = Category.objects.private_threads()
         threads = Thread.objects.filter(category=category, id__in=participated_threads)
 
-        new_threads = filter_read_threads_queryset(request, [category], 'new', threads)
-        unread_threads = filter_read_threads_queryset(request, [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.save(
-            update_fields=[
-                'unread_private_threads',
-                'sync_unread_private_threads',
-            ]
+            update_fields=["unread_private_threads", "sync_unread_private_threads"]
         )

+ 410 - 321
misago/threads/migrations/0001_initial.py

@@ -13,560 +13,649 @@ class Migration(migrations.Migration):
     initial = True
 
     dependencies = [
-        ('misago_categories', '0001_initial'),
+        ("misago_categories", "0001_initial"),
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
     ]
 
     operations = [
         migrations.CreateModel(
-            name='Post',
+            name="Post",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
-                ),
-                ('poster_name', models.CharField(max_length=255)),
-                ('poster_ip', models.GenericIPAddressField()),
-                ('original', models.TextField()),
-                ('parsed', models.TextField()),
-                ('checksum', models.CharField(max_length=64, default='-')),
-                ('attachments_cache', JSONField(null=True, blank=True)),
-                ('posted_on', models.DateTimeField()),
-                ('updated_on', models.DateTimeField()),
-                ('edits', models.PositiveIntegerField(default=0)),
-                ('last_editor_name', models.CharField(max_length=255, null=True, blank=True)),
-                ('last_editor_slug', models.SlugField(max_length=255, null=True, blank=True)),
-                (
-                    'hidden_by', models.ForeignKey(
-                        related_name='+',
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
+                ),
+                ("poster_name", models.CharField(max_length=255)),
+                ("poster_ip", models.GenericIPAddressField()),
+                ("original", models.TextField()),
+                ("parsed", models.TextField()),
+                ("checksum", models.CharField(max_length=64, default="-")),
+                ("attachments_cache", JSONField(null=True, blank=True)),
+                ("posted_on", models.DateTimeField()),
+                ("updated_on", models.DateTimeField()),
+                ("edits", models.PositiveIntegerField(default=0)),
+                (
+                    "last_editor_name",
+                    models.CharField(max_length=255, null=True, blank=True),
+                ),
+                (
+                    "last_editor_slug",
+                    models.SlugField(max_length=255, null=True, blank=True),
+                ),
+                (
+                    "hidden_by",
+                    models.ForeignKey(
+                        related_name="+",
                         on_delete=django.db.models.deletion.SET_NULL,
                         blank=True,
                         to=settings.AUTH_USER_MODEL,
-                        null=True
-                    )
-                ),
-                ('hidden_by_name', models.CharField(max_length=255, null=True, blank=True)),
-                ('hidden_by_slug', models.SlugField(max_length=255, null=True, blank=True)),
-                ('hidden_on', models.DateTimeField(default=django.utils.timezone.now)),
-                ('has_reports', models.BooleanField(default=False)),
-                ('has_open_reports', models.BooleanField(default=False)),
-                ('is_unapproved', models.BooleanField(default=False, db_index=True)),
-                ('is_hidden', models.BooleanField(default=False)),
-                ('is_protected', models.BooleanField(default=False)),
-                (
-                    'category', models.ForeignKey(
+                        null=True,
+                    ),
+                ),
+                (
+                    "hidden_by_name",
+                    models.CharField(max_length=255, null=True, blank=True),
+                ),
+                (
+                    "hidden_by_slug",
+                    models.SlugField(max_length=255, null=True, blank=True),
+                ),
+                ("hidden_on", models.DateTimeField(default=django.utils.timezone.now)),
+                ("has_reports", models.BooleanField(default=False)),
+                ("has_open_reports", models.BooleanField(default=False)),
+                ("is_unapproved", models.BooleanField(default=False, db_index=True)),
+                ("is_hidden", models.BooleanField(default=False)),
+                ("is_protected", models.BooleanField(default=False)),
+                (
+                    "category",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_categories.Category',
-                    )
+                        to="misago_categories.Category",
+                    ),
                 ),
                 (
-                    'last_editor', models.ForeignKey(
-                        related_name='+',
+                    "last_editor",
+                    models.ForeignKey(
+                        related_name="+",
                         on_delete=django.db.models.deletion.SET_NULL,
                         blank=True,
                         to=settings.AUTH_USER_MODEL,
-                        null=True
-                    )
+                        null=True,
+                    ),
                 ),
                 (
-                    'mentions', models.ManyToManyField(
-                        related_name='mention_set', to=settings.AUTH_USER_MODEL
-                    )
+                    "mentions",
+                    models.ManyToManyField(
+                        related_name="mention_set", to=settings.AUTH_USER_MODEL
+                    ),
                 ),
                 (
-                    'poster', models.ForeignKey(
+                    "poster",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.SET_NULL,
                         blank=True,
                         to=settings.AUTH_USER_MODEL,
-                        null=True
-                    )
-                ),
-                ('is_event', models.BooleanField(default=False, db_index=True)),
-                ('event_type', models.CharField(max_length=255, null=True, blank=True)),
-                ('event_context', JSONField(null=True, blank=True)),
-                ('likes', models.PositiveIntegerField(default=0)),
-                ('last_likes', JSONField(blank=True, null=True)),
-                ('search_document', models.TextField(blank=True, null=True)),
-                ('search_vector', SearchVectorField()),
+                        null=True,
+                    ),
+                ),
+                ("is_event", models.BooleanField(default=False, db_index=True)),
+                ("event_type", models.CharField(max_length=255, null=True, blank=True)),
+                ("event_context", JSONField(null=True, blank=True)),
+                ("likes", models.PositiveIntegerField(default=0)),
+                ("last_likes", JSONField(blank=True, null=True)),
+                ("search_document", models.TextField(blank=True, null=True)),
+                ("search_vector", SearchVectorField()),
             ],
             options={},
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
         migrations.CreateModel(
-            name='Thread',
+            name="Thread",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
-                ),
-                ('title', models.CharField(max_length=255)),
-                ('slug', models.CharField(max_length=255)),
-                ('replies', models.PositiveIntegerField(default=0, db_index=True)),
-                ('has_events', models.BooleanField(default=False)),
-                ('has_poll', models.BooleanField(default=False)),
-                ('has_reported_posts', models.BooleanField(default=False)),
-                ('has_open_reports', models.BooleanField(default=False)),
-                ('has_unapproved_posts', models.BooleanField(default=False)),
-                ('has_hidden_posts', models.BooleanField(default=False)),
-                ('started_on', models.DateTimeField(db_index=True)),
-                ('starter_name', models.CharField(max_length=255)),
-                ('starter_slug', models.CharField(max_length=255)),
-                ('last_post_is_event', models.BooleanField(default=False)),
-                ('last_post_on', models.DateTimeField(db_index=True)),
-                ('last_poster_name', models.CharField(max_length=255, null=True, blank=True)),
-                ('last_poster_slug', models.CharField(max_length=255, null=True, blank=True)),
-                ('weight', models.PositiveIntegerField(default=0)),
-                ('is_unapproved', models.BooleanField(default=False, db_index=True)),
-                ('is_hidden', models.BooleanField(default=False)),
-                ('is_closed', models.BooleanField(default=False)),
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
+                ),
+                ("title", models.CharField(max_length=255)),
+                ("slug", models.CharField(max_length=255)),
+                ("replies", models.PositiveIntegerField(default=0, db_index=True)),
+                ("has_events", models.BooleanField(default=False)),
+                ("has_poll", models.BooleanField(default=False)),
+                ("has_reported_posts", models.BooleanField(default=False)),
+                ("has_open_reports", models.BooleanField(default=False)),
+                ("has_unapproved_posts", models.BooleanField(default=False)),
+                ("has_hidden_posts", models.BooleanField(default=False)),
+                ("started_on", models.DateTimeField(db_index=True)),
+                ("starter_name", models.CharField(max_length=255)),
+                ("starter_slug", models.CharField(max_length=255)),
+                ("last_post_is_event", models.BooleanField(default=False)),
+                ("last_post_on", models.DateTimeField(db_index=True)),
+                (
+                    "last_poster_name",
+                    models.CharField(max_length=255, null=True, blank=True),
+                ),
+                (
+                    "last_poster_slug",
+                    models.CharField(max_length=255, null=True, blank=True),
+                ),
+                ("weight", models.PositiveIntegerField(default=0)),
+                ("is_unapproved", models.BooleanField(default=False, db_index=True)),
+                ("is_hidden", models.BooleanField(default=False)),
+                ("is_closed", models.BooleanField(default=False)),
             ],
             options={},
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
         migrations.CreateModel(
-            name='ThreadParticipant',
+            name="ThreadParticipant",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
                 ),
                 (
-                    'thread', models.ForeignKey(
+                    "thread",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_threads.Thread',
-                    )
+                        to="misago_threads.Thread",
+                    ),
                 ),
                 (
-                    'user', models.ForeignKey(
+                    "user",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
                         to=settings.AUTH_USER_MODEL,
-                    )
+                    ),
                 ),
-                ('is_owner', models.BooleanField(default=False)),
+                ("is_owner", models.BooleanField(default=False)),
             ],
             options={},
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
         migrations.AddField(
-            model_name='thread',
-            name='participants',
+            model_name="thread",
+            name="participants",
             field=models.ManyToManyField(
-                related_name='privatethread_set',
-                through='misago_threads.ThreadParticipant',
-                to=settings.AUTH_USER_MODEL
+                related_name="privatethread_set",
+                through="misago_threads.ThreadParticipant",
+                to=settings.AUTH_USER_MODEL,
             ),
             preserve_default=True,
         ),
         migrations.AddField(
-            model_name='post',
-            name='thread',
+            model_name="post",
+            name="thread",
             field=models.ForeignKey(
-                on_delete=django.db.models.deletion.CASCADE,
-                to='misago_threads.Thread',
+                on_delete=django.db.models.deletion.CASCADE, to="misago_threads.Thread"
             ),
             preserve_default=True,
         ),
         migrations.AddField(
-            model_name='thread',
-            name='first_post',
+            model_name="thread",
+            name="first_post",
             field=models.ForeignKey(
-                related_name='+',
+                related_name="+",
                 on_delete=django.db.models.deletion.SET_NULL,
                 blank=True,
-                to='misago_threads.Post',
-                null=True
+                to="misago_threads.Post",
+                null=True,
             ),
             preserve_default=True,
         ),
         migrations.AddField(
-            model_name='thread',
-            name='category',
+            model_name="thread",
+            name="category",
             field=models.ForeignKey(
                 on_delete=django.db.models.deletion.CASCADE,
-                to='misago_categories.Category',
+                to="misago_categories.Category",
             ),
             preserve_default=True,
         ),
         migrations.AddField(
-            model_name='thread',
-            name='last_post',
+            model_name="thread",
+            name="last_post",
             field=models.ForeignKey(
-                related_name='+',
+                related_name="+",
                 on_delete=django.db.models.deletion.SET_NULL,
                 blank=True,
-                to='misago_threads.Post',
-                null=True
+                to="misago_threads.Post",
+                null=True,
             ),
             preserve_default=True,
         ),
         migrations.AddField(
-            model_name='thread',
-            name='last_poster',
+            model_name="thread",
+            name="last_poster",
             field=models.ForeignKey(
-                related_name='last_poster_set',
+                related_name="last_poster_set",
                 on_delete=django.db.models.deletion.SET_NULL,
                 blank=True,
                 to=settings.AUTH_USER_MODEL,
-                null=True
+                null=True,
             ),
             preserve_default=True,
         ),
         migrations.AddField(
-            model_name='thread',
-            name='starter',
+            model_name="thread",
+            name="starter",
             field=models.ForeignKey(
                 on_delete=django.db.models.deletion.SET_NULL,
                 blank=True,
                 to=settings.AUTH_USER_MODEL,
-                null=True
+                null=True,
             ),
             preserve_default=True,
         ),
         migrations.AlterIndexTogether(
-            name='thread',
-            index_together=set([
-                ('category', 'id'),
-                ('category', 'last_post_on'),
-                ('category', 'replies'),
-            ]),
+            name="thread",
+            index_together=set(
+                [
+                    ("category", "id"),
+                    ("category", "last_post_on"),
+                    ("category", "replies"),
+                ]
+            ),
         ),
         migrations.AlterIndexTogether(
-            name='post',
-            index_together=set([
-                ('thread', 'id'),
-                ('is_event', 'is_hidden'),
-                ('poster', 'posted_on'),
-            ]),
+            name="post",
+            index_together=set(
+                [("thread", "id"), ("is_event", "is_hidden"), ("poster", "posted_on")]
+            ),
         ),
         migrations.CreateModel(
-            name='Subscription',
+            name="Subscription",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
+                ),
+                (
+                    "last_read_on",
+                    models.DateTimeField(default=django.utils.timezone.now),
                 ),
-                ('last_read_on', models.DateTimeField(default=django.utils.timezone.now)),
-                ('send_email', models.BooleanField(default=False)),
+                ("send_email", models.BooleanField(default=False)),
                 (
-                    'category', models.ForeignKey(
+                    "category",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_categories.Category',
-                    )
+                        to="misago_categories.Category",
+                    ),
                 ),
                 (
-                    'thread', models.ForeignKey(
+                    "thread",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_threads.Thread',
-                    )
+                        to="misago_threads.Thread",
+                    ),
                 ),
                 (
-                    'user', models.ForeignKey(
+                    "user",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
                         to=settings.AUTH_USER_MODEL,
-                    )
+                    ),
                 ),
             ],
             options={},
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
         migrations.AlterIndexTogether(
-            name='subscription',
-            index_together=set([
-                ('send_email', 'last_read_on'),
-            ]),
+            name="subscription", index_together=set([("send_email", "last_read_on")])
         ),
         migrations.CreateModel(
-            name='PostEdit',
+            name="PostEdit",
             fields=[
                 (
-                    'id', models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    )
-                ),
-                ('edited_on', models.DateTimeField(default=django.utils.timezone.now)),
-                ('editor_name', models.CharField(max_length=255)),
-                ('editor_slug', models.CharField(max_length=255)),
-                ('editor_ip', models.GenericIPAddressField()),
-                ('edited_from', models.TextField()),
-                ('edited_to', models.TextField()),
-                (
-                    'category', models.ForeignKey(
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("edited_on", models.DateTimeField(default=django.utils.timezone.now)),
+                ("editor_name", models.CharField(max_length=255)),
+                ("editor_slug", models.CharField(max_length=255)),
+                ("editor_ip", models.GenericIPAddressField()),
+                ("edited_from", models.TextField()),
+                ("edited_to", models.TextField()),
+                (
+                    "category",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_categories.Category'
-                    )
+                        to="misago_categories.Category",
+                    ),
                 ),
                 (
-                    'editor', models.ForeignKey(
+                    "editor",
+                    models.ForeignKey(
                         blank=True,
                         null=True,
                         on_delete=django.db.models.deletion.SET_NULL,
-                        to=settings.AUTH_USER_MODEL
-                    )
+                        to=settings.AUTH_USER_MODEL,
+                    ),
                 ),
                 (
-                    'post', models.ForeignKey(
+                    "post",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        related_name='edits_record',
-                        to='misago_threads.Post'
-                    )
+                        related_name="edits_record",
+                        to="misago_threads.Post",
+                    ),
                 ),
                 (
-                    'thread', models.ForeignKey(
-                        on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread'
-                    )
+                    "thread",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="misago_threads.Thread",
+                    ),
                 ),
             ],
-            options={
-                'ordering': ['-id'],
-            },
+            options={"ordering": ["-id"]},
         ),
         migrations.CreateModel(
-            name='Attachment',
+            name="Attachment",
             fields=[
                 (
-                    'id', models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    )
-                ),
-                ('secret', models.CharField(max_length=64)),
-                (
-                    'uploaded_on',
-                    models.DateTimeField(default=django.utils.timezone.now, db_index=True)
-                ),
-                ('uploader_name', models.CharField(max_length=255)),
-                ('uploader_slug', models.CharField(max_length=255, db_index=True)),
-                ('uploader_ip', models.GenericIPAddressField()),
-                ('filename', models.CharField(max_length=255, db_index=True)),
-                ('size', models.PositiveIntegerField(default=0, db_index=True)),
-                (
-                    'thumbnail', models.ImageField(
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("secret", models.CharField(max_length=64)),
+                (
+                    "uploaded_on",
+                    models.DateTimeField(
+                        default=django.utils.timezone.now, db_index=True
+                    ),
+                ),
+                ("uploader_name", models.CharField(max_length=255)),
+                ("uploader_slug", models.CharField(max_length=255, db_index=True)),
+                ("uploader_ip", models.GenericIPAddressField()),
+                ("filename", models.CharField(max_length=255, db_index=True)),
+                ("size", models.PositiveIntegerField(default=0, db_index=True)),
+                (
+                    "thumbnail",
+                    models.ImageField(
                         max_length=255,
                         blank=True,
                         null=True,
-                        upload_to=misago.threads.models.attachment.upload_to
-                    )
+                        upload_to=misago.threads.models.attachment.upload_to,
+                    ),
                 ),
                 (
-                    'image', models.ImageField(
+                    "image",
+                    models.ImageField(
                         max_length=255,
                         blank=True,
                         null=True,
-                        upload_to=misago.threads.models.attachment.upload_to
-                    )
+                        upload_to=misago.threads.models.attachment.upload_to,
+                    ),
                 ),
                 (
-                    'file', models.FileField(
+                    "file",
+                    models.FileField(
                         max_length=255,
                         blank=True,
                         null=True,
-                        upload_to=misago.threads.models.attachment.upload_to
-                    )
+                        upload_to=misago.threads.models.attachment.upload_to,
+                    ),
                 ),
                 (
-                    'post', models.ForeignKey(
+                    "post",
+                    models.ForeignKey(
                         blank=True,
                         null=True,
                         on_delete=django.db.models.deletion.SET_NULL,
-                        to='misago_threads.Post'
-                    )
+                        to="misago_threads.Post",
+                    ),
                 ),
             ],
         ),
         migrations.CreateModel(
-            name='AttachmentType',
+            name="AttachmentType",
             fields=[
                 (
-                    'id', models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    )
-                ),
-                ('name', models.CharField(max_length=255)),
-                ('extensions', models.CharField(max_length=255)),
-                ('mimetypes', models.CharField(blank=True, max_length=255, null=True)),
-                ('size_limit', models.PositiveIntegerField(default=1024)),
-                (
-                    'status', models.PositiveIntegerField(
-                        choices=[(0, 'Allow uploads and downloads'), (1, 'Allow downloads only'),
-                                 (2, 'Disallow both uploading and downloading')],
-                        default=0
-                    )
-                ),
-                (
-                    'limit_downloads_to', models.ManyToManyField(
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("name", models.CharField(max_length=255)),
+                ("extensions", models.CharField(max_length=255)),
+                ("mimetypes", models.CharField(blank=True, max_length=255, null=True)),
+                ("size_limit", models.PositiveIntegerField(default=1024)),
+                (
+                    "status",
+                    models.PositiveIntegerField(
+                        choices=[
+                            (0, "Allow uploads and downloads"),
+                            (1, "Allow downloads only"),
+                            (2, "Disallow both uploading and downloading"),
+                        ],
+                        default=0,
+                    ),
+                ),
+                (
+                    "limit_downloads_to",
+                    models.ManyToManyField(
                         blank=True,
-                        related_name='_attachmenttype_limit_downloads_to_+',
-                        to='misago_acl.Role'
-                    )
+                        related_name="_attachmenttype_limit_downloads_to_+",
+                        to="misago_acl.Role",
+                    ),
                 ),
                 (
-                    'limit_uploads_to', models.ManyToManyField(
+                    "limit_uploads_to",
+                    models.ManyToManyField(
                         blank=True,
-                        related_name='_attachmenttype_limit_uploads_to_+',
-                        to='misago_acl.Role'
-                    )
+                        related_name="_attachmenttype_limit_uploads_to_+",
+                        to="misago_acl.Role",
+                    ),
                 ),
             ],
         ),
         migrations.AddField(
-            model_name='attachment',
-            name='filetype',
+            model_name="attachment",
+            name="filetype",
             field=models.ForeignKey(
-                on_delete=django.db.models.deletion.CASCADE, to='misago_threads.AttachmentType'
+                on_delete=django.db.models.deletion.CASCADE,
+                to="misago_threads.AttachmentType",
             ),
         ),
         migrations.AddField(
-            model_name='attachment',
-            name='uploader',
+            model_name="attachment",
+            name="uploader",
             field=models.ForeignKey(
                 blank=True,
                 null=True,
                 on_delete=django.db.models.deletion.SET_NULL,
-                to=settings.AUTH_USER_MODEL
+                to=settings.AUTH_USER_MODEL,
             ),
         ),
         migrations.CreateModel(
-            name='Poll',
+            name="Poll",
             fields=[
                 (
-                    'id', models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    )
-                ),
-                ('poster_name', models.CharField(max_length=255)),
-                ('poster_slug', models.CharField(max_length=255)),
-                ('poster_ip', models.GenericIPAddressField()),
-                ('posted_on', models.DateTimeField(default=django.utils.timezone.now)),
-                ('length', models.PositiveIntegerField(default=0)),
-                ('question', models.CharField(max_length=255)),
-                ('choices', django.contrib.postgres.fields.jsonb.JSONField()),
-                ('allowed_choices', models.PositiveIntegerField(default=1)),
-                ('allow_revotes', models.BooleanField(default=False)),
-                ('votes', models.PositiveIntegerField(default=0)),
-                ('is_public', models.BooleanField(default=False)),
-                (
-                    'category', models.ForeignKey(
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("poster_name", models.CharField(max_length=255)),
+                ("poster_slug", models.CharField(max_length=255)),
+                ("poster_ip", models.GenericIPAddressField()),
+                ("posted_on", models.DateTimeField(default=django.utils.timezone.now)),
+                ("length", models.PositiveIntegerField(default=0)),
+                ("question", models.CharField(max_length=255)),
+                ("choices", django.contrib.postgres.fields.jsonb.JSONField()),
+                ("allowed_choices", models.PositiveIntegerField(default=1)),
+                ("allow_revotes", models.BooleanField(default=False)),
+                ("votes", models.PositiveIntegerField(default=0)),
+                ("is_public", models.BooleanField(default=False)),
+                (
+                    "category",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_categories.Category'
-                    )
+                        to="misago_categories.Category",
+                    ),
                 ),
                 (
-                    'poster', models.ForeignKey(
+                    "poster",
+                    models.ForeignKey(
                         blank=True,
                         null=True,
                         on_delete=django.db.models.deletion.SET_NULL,
-                        to=settings.AUTH_USER_MODEL
-                    )
+                        to=settings.AUTH_USER_MODEL,
+                    ),
                 ),
                 (
-                    'thread', models.OneToOneField(
-                        on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread'
-                    )
+                    "thread",
+                    models.OneToOneField(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="misago_threads.Thread",
+                    ),
                 ),
             ],
         ),
         migrations.CreateModel(
-            name='PollVote',
+            name="PollVote",
             fields=[
                 (
-                    'id', models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    )
-                ),
-                ('voter_name', models.CharField(max_length=255)),
-                ('voter_slug', models.CharField(max_length=255)),
-                ('voter_ip', models.GenericIPAddressField()),
-                ('voted_on', models.DateTimeField(default=django.utils.timezone.now)),
-                ('choice_hash', models.CharField(db_index=True, max_length=12)),
-                (
-                    'category', models.ForeignKey(
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("voter_name", models.CharField(max_length=255)),
+                ("voter_slug", models.CharField(max_length=255)),
+                ("voter_ip", models.GenericIPAddressField()),
+                ("voted_on", models.DateTimeField(default=django.utils.timezone.now)),
+                ("choice_hash", models.CharField(db_index=True, max_length=12)),
+                (
+                    "category",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_categories.Category'
-                    )
+                        to="misago_categories.Category",
+                    ),
                 ),
                 (
-                    'poll', models.ForeignKey(
-                        on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Poll'
-                    )
+                    "poll",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="misago_threads.Poll",
+                    ),
                 ),
                 (
-                    'thread', models.ForeignKey(
-                        on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread'
-                    )
+                    "thread",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="misago_threads.Thread",
+                    ),
                 ),
                 (
-                    'voter', models.ForeignKey(
+                    "voter",
+                    models.ForeignKey(
                         blank=True,
                         null=True,
                         on_delete=django.db.models.deletion.SET_NULL,
-                        to=settings.AUTH_USER_MODEL
-                    )
+                        to=settings.AUTH_USER_MODEL,
+                    ),
                 ),
             ],
         ),
         migrations.AlterIndexTogether(
-            name='pollvote',
-            index_together=set([
-                ('poll', 'voter_name'),
-            ]),
+            name="pollvote", index_together=set([("poll", "voter_name")])
         ),
         migrations.CreateModel(
-            name='PostLike',
+            name="PostLike",
             fields=[
                 (
-                    'id', models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    )
-                ),
-                ('liker_name', models.CharField(max_length=255, db_index=True)),
-                ('liker_slug', models.CharField(max_length=255)),
-                ('liker_ip', models.GenericIPAddressField()),
-                ('liked_on', models.DateTimeField(default=django.utils.timezone.now)),
-                (
-                    'category', models.ForeignKey(
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("liker_name", models.CharField(max_length=255, db_index=True)),
+                ("liker_slug", models.CharField(max_length=255)),
+                ("liker_ip", models.GenericIPAddressField()),
+                ("liked_on", models.DateTimeField(default=django.utils.timezone.now)),
+                (
+                    "category",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.CASCADE,
-                        to='misago_categories.Category'
-                    )
+                        to="misago_categories.Category",
+                    ),
                 ),
             ],
-            options={
-                'ordering': ['-id'],
-            },
+            options={"ordering": ["-id"]},
         ),
         migrations.AddField(
-            model_name='postlike',
-            name='post',
+            model_name="postlike",
+            name="post",
             field=models.ForeignKey(
-                on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Post'
+                on_delete=django.db.models.deletion.CASCADE, to="misago_threads.Post"
             ),
         ),
         migrations.AddField(
-            model_name='postlike',
-            name='thread',
+            model_name="postlike",
+            name="thread",
             field=models.ForeignKey(
-                on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread'
+                on_delete=django.db.models.deletion.CASCADE, to="misago_threads.Thread"
             ),
         ),
         migrations.AddField(
-            model_name='postlike',
-            name='liker',
+            model_name="postlike",
+            name="liker",
             field=models.ForeignKey(
                 blank=True,
                 null=True,
                 on_delete=django.db.models.deletion.SET_NULL,
-                to=settings.AUTH_USER_MODEL
+                to=settings.AUTH_USER_MODEL,
             ),
         ),
         migrations.AddField(
-            model_name='post',
-            name='liked_by',
+            model_name="post",
+            name="liked_by",
             field=models.ManyToManyField(
-                related_name='liked_post_set',
-                through='misago_threads.PostLike',
-                to=settings.AUTH_USER_MODEL
+                related_name="liked_post_set",
+                through="misago_threads.PostLike",
+                to=settings.AUTH_USER_MODEL,
             ),
         ),
     ]

+ 39 - 53
misago/threads/migrations/0002_threads_settings.py

@@ -8,74 +8,60 @@ _ = lambda s: s
 
 def create_threads_settings_group(apps, schema_editor):
     migrate_settings_group(
-        apps, {
-            'key': 'threads',
-            'name': _("Threads"),
-            'description': _("Those settings control threads and posts."),
-            'settings': [
+        apps,
+        {
+            "key": "threads",
+            "name": _("Threads"),
+            "description": _("Those settings control threads and posts."),
+            "settings": [
                 {
-                    'setting': 'thread_title_length_min',
-                    'name': _("Minimum length"),
-                    'description': _("Minimum allowed thread title length."),
-                    'legend': _("Thread titles"),
-                    'python_type': 'int',
-                    'value': 5,
-                    'field_extra': {
-                        'min_value': 2,
-                        'max_value': 255,
-                    },
-                    'is_public': True,
+                    "setting": "thread_title_length_min",
+                    "name": _("Minimum length"),
+                    "description": _("Minimum allowed thread title length."),
+                    "legend": _("Thread titles"),
+                    "python_type": "int",
+                    "value": 5,
+                    "field_extra": {"min_value": 2, "max_value": 255},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'thread_title_length_max',
-                    'name': _("Maximum length"),
-                    'description': _("Maximum allowed thread length."),
-                    'python_type': 'int',
-                    'value': 90,
-                    'field_extra': {
-                        'min_value': 2,
-                        'max_value': 255,
-                    },
-                    'is_public': True,
+                    "setting": "thread_title_length_max",
+                    "name": _("Maximum length"),
+                    "description": _("Maximum allowed thread length."),
+                    "python_type": "int",
+                    "value": 90,
+                    "field_extra": {"min_value": 2, "max_value": 255},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'post_length_min',
-                    'name': _("Minimum length"),
-                    'description': _("Minimum allowed user post length."),
-                    'legend': _("Posts"),
-                    'python_type': 'int',
-                    'value': 5,
-                    'field_extra': {
-                        'min_value': 1,
-                    },
-                    'is_public': True,
+                    "setting": "post_length_min",
+                    "name": _("Minimum length"),
+                    "description": _("Minimum allowed user post length."),
+                    "legend": _("Posts"),
+                    "python_type": "int",
+                    "value": 5,
+                    "field_extra": {"min_value": 1},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'post_length_max',
-                    'name': _("Maximum length"),
-                    'description': _(
+                    "setting": "post_length_max",
+                    "name": _("Maximum length"),
+                    "description": _(
                         "Maximum allowed user post length. Enter zero to disable. "
                         "Longer posts are more costful to parse and index."
                     ),
-                    'python_type': 'int',
-                    'value': 60000,
-                    'field_extra': {
-                        'min_value': 0,
-                    },
-                    'is_public': True,
+                    "python_type": "int",
+                    "value": 60000,
+                    "field_extra": {"min_value": 0},
+                    "is_public": True,
                 },
             ],
-        }
+        },
     )
 
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_threads', '0001_initial'),
-        ('misago_conf', '0001_initial'),
-    ]
+    dependencies = [("misago_threads", "0001_initial"), ("misago_conf", "0001_initial")]
 
-    operations = [
-        migrations.RunPython(create_threads_settings_group),
-    ]
+    operations = [migrations.RunPython(create_threads_settings_group)]

+ 57 - 58
misago/threads/migrations/0003_attachment_types.py

@@ -4,97 +4,96 @@ from django.db import migrations
 
 ATTACHMENTS = [
     {
-        'name': 'GIF',
-        'extensions': ('gif', ),
-        'mimetypes': ('image/gif', ),
-        'size_limit': 5 * 1024
+        "name": "GIF",
+        "extensions": ("gif",),
+        "mimetypes": ("image/gif",),
+        "size_limit": 5 * 1024,
     },
     {
-        'name': 'JPG',
-        'extensions': ('jpg', 'jpeg', ),
-        'mimetypes': ('image/jpeg', ),
-        'size_limit': 3 * 1024
+        "name": "JPG",
+        "extensions": ("jpg", "jpeg"),
+        "mimetypes": ("image/jpeg",),
+        "size_limit": 3 * 1024,
     },
     {
-        'name': 'PNG',
-        'extensions': ('png', ),
-        'mimetypes': ('image/png', ),
-        'size_limit': 3 * 1024
+        "name": "PNG",
+        "extensions": ("png",),
+        "mimetypes": ("image/png",),
+        "size_limit": 3 * 1024,
     },
     {
-        'name': 'PDF',
-        'extensions': ('pdf', ),
-        'mimetypes': (
-            'application/pdf', 'application/x-pdf', 'application/x-bzpdf', 'application/x-gzpdf',
+        "name": "PDF",
+        "extensions": ("pdf",),
+        "mimetypes": (
+            "application/pdf",
+            "application/x-pdf",
+            "application/x-bzpdf",
+            "application/x-gzpdf",
         ),
-        'size_limit': 4 * 1024
+        "size_limit": 4 * 1024,
     },
     {
-        'name': 'Text',
-        'extensions': ('txt', ),
-        'mimetypes': ('text/plain', ),
-        'size_limit': 4 * 1024
+        "name": "Text",
+        "extensions": ("txt",),
+        "mimetypes": ("text/plain",),
+        "size_limit": 4 * 1024,
     },
     {
-        'name': 'Markdown',
-        'extensions': ('md', ),
-        'mimetypes': ('text/markdown', ),
-        'size_limit': 4 * 1024
+        "name": "Markdown",
+        "extensions": ("md",),
+        "mimetypes": ("text/markdown",),
+        "size_limit": 4 * 1024,
     },
     {
-        'name': 'reStructuredText',
-        'extensions': ('rst', ),
-        'mimetypes': ('text/x-rst', ),
-        'size_limit': 4 * 1024
+        "name": "reStructuredText",
+        "extensions": ("rst",),
+        "mimetypes": ("text/x-rst",),
+        "size_limit": 4 * 1024,
     },
     {
-        'name': '7Z',
-        'extensions': ('7z', ),
-        'mimetypes': ('application/x-7z-compressed', ),
-        'size_limit': 4 * 1024
+        "name": "7Z",
+        "extensions": ("7z",),
+        "mimetypes": ("application/x-7z-compressed",),
+        "size_limit": 4 * 1024,
     },
     {
-        'name': 'RAR',
-        'extensions': ('rar', ),
-        'mimetypes': ('application/vnd.rar', ),
-        'size_limit': 4 * 1024
+        "name": "RAR",
+        "extensions": ("rar",),
+        "mimetypes": ("application/vnd.rar",),
+        "size_limit": 4 * 1024,
     },
     {
-        'name': 'TAR',
-        'extensions': ('tar', ),
-        'mimetypes': ('application/x-tar', ),
-        'size_limit': 4 * 1024
+        "name": "TAR",
+        "extensions": ("tar",),
+        "mimetypes": ("application/x-tar",),
+        "size_limit": 4 * 1024,
     },
     {
-        'name': 'GZ',
-        'extensions': ('gz', ),
-        'mimetypes': ('application/gzip', ),
-        'size_limit': 4 * 1024
+        "name": "GZ",
+        "extensions": ("gz",),
+        "mimetypes": ("application/gzip",),
+        "size_limit": 4 * 1024,
     },
     {
-        'name': 'ZIP',
-        'extensions': ('zip', 'zipx', ),
-        'mimetypes': ('application/zip', ),
-        'size_limit': 4 * 1024
+        "name": "ZIP",
+        "extensions": ("zip", "zipx"),
+        "mimetypes": ("application/zip",),
+        "size_limit": 4 * 1024,
     },
 ]
 
 
 def create_attachment_types(apps, schema_editor):
-    AttachmentType = apps.get_model('misago_threads', 'AttachmentType')
+    AttachmentType = apps.get_model("misago_threads", "AttachmentType")
     for attachment in ATTACHMENTS:
         kwargs = attachment
-        kwargs['extensions'] = ','.join(kwargs['extensions'])
-        kwargs['mimetypes'] = ','.join(kwargs['mimetypes'])
+        kwargs["extensions"] = ",".join(kwargs["extensions"])
+        kwargs["mimetypes"] = ",".join(kwargs["mimetypes"])
         AttachmentType.objects.create(**kwargs)
 
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_threads', '0002_threads_settings'),
-    ]
+    dependencies = [("misago_threads", "0002_threads_settings")]
 
-    operations = [
-        migrations.RunPython(create_attachment_types),
-    ]
+    operations = [migrations.RunPython(create_attachment_types)]

+ 39 - 52
misago/threads/migrations/0004_update_settings.py

@@ -7,73 +7,60 @@ _ = lambda s: s
 
 def update_threads_settings(apps, schema_editor):
     migrate_settings_group(
-        apps, {
-            'key': 'threads',
-            'name': _("Threads"),
-            'description': _("Those settings control threads and posts."),
-            'settings': [
+        apps,
+        {
+            "key": "threads",
+            "name": _("Threads"),
+            "description": _("Those settings control threads and posts."),
+            "settings": [
                 {
-                    'setting': 'thread_title_length_min',
-                    'name': _("Minimum length"),
-                    'description': _("Minimum allowed thread title length."),
-                    'legend': _("Thread titles"),
-                    'python_type': 'int',
-                    'default_value': 5,
-                    'field_extra': {
-                        'min_value': 2,
-                        'max_value': 255,
-                    },
-                    'is_public': True,
+                    "setting": "thread_title_length_min",
+                    "name": _("Minimum length"),
+                    "description": _("Minimum allowed thread title length."),
+                    "legend": _("Thread titles"),
+                    "python_type": "int",
+                    "default_value": 5,
+                    "field_extra": {"min_value": 2, "max_value": 255},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'thread_title_length_max',
-                    'name': _("Maximum length"),
-                    'description': _("Maximum allowed thread length."),
-                    'python_type': 'int',
-                    'default_value': 90,
-                    'field_extra': {
-                        'min_value': 2,
-                        'max_value': 255,
-                    },
-                    'is_public': True,
+                    "setting": "thread_title_length_max",
+                    "name": _("Maximum length"),
+                    "description": _("Maximum allowed thread length."),
+                    "python_type": "int",
+                    "default_value": 90,
+                    "field_extra": {"min_value": 2, "max_value": 255},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'post_length_min',
-                    'name': _("Minimum length"),
-                    'description': _("Minimum allowed user post length."),
-                    'legend': _("Posts"),
-                    'python_type': 'int',
-                    'default_value': 5,
-                    'field_extra': {
-                        'min_value': 1,
-                    },
-                    'is_public': True,
+                    "setting": "post_length_min",
+                    "name": _("Minimum length"),
+                    "description": _("Minimum allowed user post length."),
+                    "legend": _("Posts"),
+                    "python_type": "int",
+                    "default_value": 5,
+                    "field_extra": {"min_value": 1},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'post_length_max',
-                    'name': _("Maximum length"),
-                    'description': _(
+                    "setting": "post_length_max",
+                    "name": _("Maximum length"),
+                    "description": _(
                         "Maximum allowed user post length. Enter zero to disable. "
                         "Longer posts are more costful to parse and index."
                     ),
-                    'python_type': 'int',
-                    'default_value': 60000,
-                    'field_extra': {
-                        'min_value': 0,
-                    },
-                    'is_public': True,
+                    "python_type": "int",
+                    "default_value": 60000,
+                    "field_extra": {"min_value": 0},
+                    "is_public": True,
                 },
             ],
-        }
+        },
     )
 
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_threads', '0003_attachment_types'),
-    ]
+    dependencies = [("misago_threads", "0003_attachment_types")]
 
-    operations = [
-        migrations.RunPython(update_threads_settings),
-    ]
+    operations = [migrations.RunPython(update_threads_settings)]

+ 5 - 5
misago/threads/migrations/0005_index_search_document.py

@@ -6,14 +6,14 @@ from django.db import migrations
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_threads', '0004_update_settings'),
-    ]
+    dependencies = [("misago_threads", "0004_update_settings")]
 
     operations = [
         BtreeGinExtension(),
         migrations.AddIndex(
-            model_name='post',
-            index=django.contrib.postgres.indexes.GinIndex(fields=['search_vector'], name='misago_thre_search__b472a2_gin'),
+            model_name="post",
+            index=django.contrib.postgres.indexes.GinIndex(
+                fields=["search_vector"], name="misago_thre_search__b472a2_gin"
+            ),
         ),
     ]

+ 55 - 21
misago/threads/migrations/0006_redo_partial_indexes.py

@@ -8,45 +8,79 @@ import misago.core.pgutils
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_threads', '0005_index_search_document'),
-    ]
+    dependencies = [("misago_threads", "0005_index_search_document")]
 
     operations = [
         migrations.AddIndex(
-            model_name='post',
-            index=misago.core.pgutils.PgPartialIndex(fields=['has_open_reports'], name='misago_thre_has_ope_479906_part', where={'has_open_reports': True}),
+            model_name="post",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["has_open_reports"],
+                name="misago_thre_has_ope_479906_part",
+                where={"has_open_reports": True},
+            ),
         ),
         migrations.AddIndex(
-            model_name='post',
-            index=misago.core.pgutils.PgPartialIndex(fields=['is_hidden'], name='misago_thre_is_hidd_85db69_part', where={'is_hidden': False}),
+            model_name="post",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["is_hidden"],
+                name="misago_thre_is_hidd_85db69_part",
+                where={"is_hidden": False},
+            ),
         ),
         migrations.AddIndex(
-            model_name='thread',
-            index=misago.core.pgutils.PgPartialIndex(fields=['weight'], name='misago_thre_weight_955884_part', where={'weight': 2}),
+            model_name="thread",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["weight"],
+                name="misago_thre_weight_955884_part",
+                where={"weight": 2},
+            ),
         ),
         migrations.AddIndex(
-            model_name='thread',
-            index=misago.core.pgutils.PgPartialIndex(fields=['weight'], name='misago_thre_weight_9e8f9c_part', where={'weight': 1}),
+            model_name="thread",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["weight"],
+                name="misago_thre_weight_9e8f9c_part",
+                where={"weight": 1},
+            ),
         ),
         migrations.AddIndex(
-            model_name='thread',
-            index=misago.core.pgutils.PgPartialIndex(fields=['weight'], name='misago_thre_weight_c7ef29_part', where={'weight': 0}),
+            model_name="thread",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["weight"],
+                name="misago_thre_weight_c7ef29_part",
+                where={"weight": 0},
+            ),
         ),
         migrations.AddIndex(
-            model_name='thread',
-            index=misago.core.pgutils.PgPartialIndex(fields=['weight'], name='misago_thre_weight__4af9ee_part', where={'weight__lt': 2}),
+            model_name="thread",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["weight"],
+                name="misago_thre_weight__4af9ee_part",
+                where={"weight__lt": 2},
+            ),
         ),
         migrations.AddIndex(
-            model_name='thread',
-            index=misago.core.pgutils.PgPartialIndex(fields=['has_reported_posts'], name='misago_thre_has_rep_84acfa_part', where={'has_reported_posts': True}),
+            model_name="thread",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["has_reported_posts"],
+                name="misago_thre_has_rep_84acfa_part",
+                where={"has_reported_posts": True},
+            ),
         ),
         migrations.AddIndex(
-            model_name='thread',
-            index=misago.core.pgutils.PgPartialIndex(fields=['has_unapproved_posts'], name='misago_thre_has_una_b0dbf5_part', where={'has_unapproved_posts': True}),
+            model_name="thread",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["has_unapproved_posts"],
+                name="misago_thre_has_una_b0dbf5_part",
+                where={"has_unapproved_posts": True},
+            ),
         ),
         migrations.AddIndex(
-            model_name='thread',
-            index=misago.core.pgutils.PgPartialIndex(fields=['is_hidden'], name='misago_thre_is_hidd_d2b96c_part', where={'is_hidden': False}),
+            model_name="thread",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["is_hidden"],
+                name="misago_thre_is_hidd_d2b96c_part",
+                where={"is_hidden": False},
+            ),
         ),
     ]

+ 4 - 6
misago/threads/migrations/0007_auto_20171008_0131.py

@@ -4,14 +4,12 @@ from django.db import migrations, models
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_threads', '0006_redo_partial_indexes'),
-    ]
+    dependencies = [("misago_threads", "0006_redo_partial_indexes")]
 
     operations = [
         migrations.AlterField(
-            model_name='post',
-            name='posted_on',
+            model_name="post",
+            name="posted_on",
             field=models.DateTimeField(db_index=True),
-        ),
+        )
     ]

+ 27 - 15
misago/threads/migrations/0008_auto_20180310_2234.py

@@ -8,38 +8,50 @@ class Migration(migrations.Migration):
 
     dependencies = [
         migrations.swappable_dependency(settings.AUTH_USER_MODEL),
-        ('misago_threads', '0007_auto_20171008_0131'),
+        ("misago_threads", "0007_auto_20171008_0131"),
     ]
 
     operations = [
         migrations.AddField(
-            model_name='thread',
-            name='best_answer',
-            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='misago_threads.Post'),
+            model_name="thread",
+            name="best_answer",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="+",
+                to="misago_threads.Post",
+            ),
         ),
         migrations.AddField(
-            model_name='thread',
-            name='best_answer_is_protected',
+            model_name="thread",
+            name="best_answer_is_protected",
             field=models.BooleanField(default=False),
         ),
         migrations.AddField(
-            model_name='thread',
-            name='best_answer_marked_by',
-            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='marked_best_answer_set', to=settings.AUTH_USER_MODEL),
+            model_name="thread",
+            name="best_answer_marked_by",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name="marked_best_answer_set",
+                to=settings.AUTH_USER_MODEL,
+            ),
         ),
         migrations.AddField(
-            model_name='thread',
-            name='best_answer_marked_by_name',
+            model_name="thread",
+            name="best_answer_marked_by_name",
             field=models.CharField(blank=True, max_length=255, null=True),
         ),
         migrations.AddField(
-            model_name='thread',
-            name='best_answer_marked_by_slug',
+            model_name="thread",
+            name="best_answer_marked_by_slug",
             field=models.CharField(blank=True, max_length=255, null=True),
         ),
         migrations.AddField(
-            model_name='thread',
-            name='best_answer_marked_on',
+            model_name="thread",
+            name="best_answer_marked_on",
             field=models.DateTimeField(blank=True, null=True),
         ),
     ]

+ 8 - 6
misago/threads/migrations/0009_auto_20180326_0010.py

@@ -6,13 +6,15 @@ import misago.core.pgutils
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_threads', '0008_auto_20180310_2234'),
-    ]
+    dependencies = [("misago_threads", "0008_auto_20180310_2234")]
 
     operations = [
         migrations.AddIndex(
-            model_name='post',
-            index=misago.core.pgutils.PgPartialIndex(fields=['is_event', 'event_type'], name='misago_thre_is_even_42bda7_part', where={'is_event': True}),
-        ),
+            model_name="post",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["is_event", "event_type"],
+                name="misago_thre_is_even_42bda7_part",
+                where={"is_event": True},
+            ),
+        )
     ]

+ 7 - 27
misago/threads/migrations/0010_auto_20180609_1523.py

@@ -4,33 +4,13 @@ from django.db import migrations
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_threads', '0009_auto_20180326_0010'),
-    ]
+    dependencies = [("misago_threads", "0009_auto_20180326_0010")]
 
     operations = [
-        migrations.RemoveField(
-            model_name='attachment',
-            name='uploader_ip',
-        ),
-        migrations.RemoveField(
-            model_name='poll',
-            name='poster_ip',
-        ),
-        migrations.RemoveField(
-            model_name='pollvote',
-            name='voter_ip',
-        ),
-        migrations.RemoveField(
-            model_name='post',
-            name='poster_ip',
-        ),
-        migrations.RemoveField(
-            model_name='postedit',
-            name='editor_ip',
-        ),
-        migrations.RemoveField(
-            model_name='postlike',
-            name='liker_ip',
-        ),
+        migrations.RemoveField(model_name="attachment", name="uploader_ip"),
+        migrations.RemoveField(model_name="poll", name="poster_ip"),
+        migrations.RemoveField(model_name="pollvote", name="voter_ip"),
+        migrations.RemoveField(model_name="post", name="poster_ip"),
+        migrations.RemoveField(model_name="postedit", name="editor_ip"),
+        migrations.RemoveField(model_name="postlike", name="liker_ip"),
     ]

+ 21 - 21
misago/threads/models/attachment.py

@@ -24,23 +24,24 @@ def upload_to(instance, filename):
         if filename_lowered.endswith(extension):
             break
 
-    filename_clean = '.'.join((slugify(filename[:(len(extension) + 1) * -1])[:16], extension))
+    filename_clean = ".".join(
+        (slugify(filename[: (len(extension) + 1) * -1])[:16], extension)
+    )
 
-    return os.path.join('attachments', spread_path[:2], spread_path[2:4], secret, filename_clean)
+    return os.path.join(
+        "attachments", spread_path[:2], spread_path[2:4], secret, filename_clean
+    )
 
 
 class Attachment(models.Model):
     secret = models.CharField(max_length=64)
-    filetype = models.ForeignKey('AttachmentType', on_delete=models.CASCADE)
-    post = models.ForeignKey('Post', blank=True, null=True, on_delete=models.SET_NULL)
+    filetype = models.ForeignKey("AttachmentType", on_delete=models.CASCADE)
+    post = models.ForeignKey("Post", blank=True, null=True, on_delete=models.SET_NULL)
 
     uploaded_on = models.DateTimeField(default=timezone.now, db_index=True)
 
     uploader = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        blank=True,
-        null=True,
-        on_delete=models.SET_NULL,
+        settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL
     )
     uploader_name = models.CharField(max_length=255)
     uploader_slug = models.CharField(max_length=255, db_index=True)
@@ -48,8 +49,12 @@ class Attachment(models.Model):
     filename = models.CharField(max_length=255, db_index=True)
     size = models.PositiveIntegerField(default=0, db_index=True)
 
-    thumbnail = models.ImageField(max_length=255, blank=True, null=True, upload_to=upload_to)
-    image = models.ImageField(max_length=255, blank=True, null=True, upload_to=upload_to)
+    thumbnail = models.ImageField(
+        max_length=255, blank=True, null=True, upload_to=upload_to
+    )
+    image = models.ImageField(
+        max_length=255, blank=True, null=True, upload_to=upload_to
+    )
     file = models.FileField(max_length=255, blank=True, null=True, upload_to=upload_to)
 
     def __str__(self):
@@ -81,19 +86,14 @@ class Attachment(models.Model):
 
     def get_absolute_url(self):
         return reverse(
-            'misago:attachment', kwargs={
-                'pk': self.pk,
-                'secret': self.secret,
-            }
+            "misago:attachment", kwargs={"pk": self.pk, "secret": self.secret}
         )
 
     def get_thumbnail_url(self):
         if self.thumbnail:
             return reverse(
-                'misago:attachment-thumbnail', kwargs={
-                    'pk': self.pk,
-                    'secret': self.secret,
-                }
+                "misago:attachment-thumbnail",
+                kwargs={"pk": self.pk, "secret": self.secret},
             )
         else:
             return None
@@ -111,14 +111,14 @@ class Attachment(models.Model):
             thumbnail.size[0] > settings.MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT[0]
             or thumbnail.size[1] > settings.MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT[1]
         )
-        strip_animation = fileformat == 'gif'
+        strip_animation = fileformat == "gif"
 
         thumb_stream = BytesIO()
         if downscale_image:
             thumbnail.thumbnail(settings.MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT)
-            if fileformat == 'jpg':
+            if fileformat == "jpg":
                 # normalize jpg to jpeg for Pillow
-                thumbnail.save(thumb_stream, 'jpeg')
+                thumbnail.save(thumb_stream, "jpeg")
             else:
                 thumbnail.save(thumb_stream, fileformat)
         elif strip_animation:

+ 8 - 4
misago/threads/models/attachmenttype.py

@@ -20,8 +20,12 @@ class AttachmentType(models.Model):
         ],
     )
 
-    limit_uploads_to = models.ManyToManyField('misago_acl.Role', related_name='+', blank=True)
-    limit_downloads_to = models.ManyToManyField('misago_acl.Role', related_name='+', blank=True)
+    limit_uploads_to = models.ManyToManyField(
+        "misago_acl.Role", related_name="+", blank=True
+    )
+    limit_downloads_to = models.ManyToManyField(
+        "misago_acl.Role", related_name="+", blank=True
+    )
 
     def __str__(self):
         return self.name
@@ -33,11 +37,11 @@ class AttachmentType(models.Model):
     @property
     def extensions_list(self):
         if self.extensions:
-            return self.extensions.split(',')
+            return self.extensions.split(",")
         return []
 
     @property
     def mimetypes_list(self):
         if self.mimetypes:
-            return self.mimetypes.split(',')
+            return self.mimetypes.split(",")
         return []

+ 18 - 25
misago/threads/models/poll.py

@@ -8,19 +8,10 @@ from django.utils import timezone
 
 
 class Poll(models.Model):
-    category = models.ForeignKey(
-        'misago_categories.Category',
-        on_delete=models.CASCADE,
-    )
-    thread = models.OneToOneField(
-        'misago_threads.Thread',
-        on_delete=models.CASCADE,
-    )
+    category = models.ForeignKey("misago_categories.Category", on_delete=models.CASCADE)
+    thread = models.OneToOneField("misago_threads.Thread", on_delete=models.CASCADE)
     poster = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        blank=True,
-        null=True,
-        on_delete=models.SET_NULL,
+        settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL
     )
     poster_name = models.CharField(max_length=255)
     poster_slug = models.CharField(max_length=255)
@@ -69,19 +60,19 @@ class Poll(models.Model):
     def make_choices_votes_aware(self, user):
         if user.is_anonymous:
             for choice in self.choices:
-                choice['selected'] = False
+                choice["selected"] = False
             return
 
-        queryset = self.pollvote_set.filter(voter=user).values('choice_hash')
-        user_votes = [v['choice_hash'] for v in queryset]
+        queryset = self.pollvote_set.filter(voter=user).values("choice_hash")
+        user_votes = [v["choice_hash"] for v in queryset]
 
         for choice in self.choices:
-            choice['selected'] = choice['hash'] in user_votes
+            choice["selected"] = choice["hash"] in user_votes
 
     @property
     def has_selected_choices(self):
         for choice in self.choices:
-            if choice.get('selected'):
+            if choice.get("selected"):
                 return True
         return False
 
@@ -89,15 +80,17 @@ class Poll(models.Model):
     def view_choices(self):
         view_choices = []
         for choice in self.choices:
-            if choice['votes'] and self.votes:
-                proc = int(ceil(choice['votes'] * 100 / self.votes))
+            if choice["votes"] and self.votes:
+                proc = int(ceil(choice["votes"] * 100 / self.votes))
             else:
                 proc = 0
 
-            view_choices.append({
-                'label': choice['label'],
-                'votes': choice['votes'],
-                'selected': choice['selected'],
-                'proc': proc,
-            })
+            view_choices.append(
+                {
+                    "label": choice["label"],
+                    "votes": choice["votes"],
+                    "selected": choice["selected"],
+                    "proc": proc,
+                }
+            )
         return view_choices

+ 5 - 19
misago/threads/models/pollvote.py

@@ -4,23 +4,11 @@ from django.utils import timezone
 
 
 class PollVote(models.Model):
-    category = models.ForeignKey(
-        'misago_categories.Category',
-        on_delete=models.CASCADE,
-    )
-    thread = models.ForeignKey(
-        'misago_threads.Thread',
-        on_delete=models.CASCADE,
-    )
-    poll = models.ForeignKey(
-        'misago_threads.Poll',
-        on_delete=models.CASCADE,
-    )
+    category = models.ForeignKey("misago_categories.Category", on_delete=models.CASCADE)
+    thread = models.ForeignKey("misago_threads.Thread", on_delete=models.CASCADE)
+    poll = models.ForeignKey("misago_threads.Poll", on_delete=models.CASCADE)
     voter = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        blank=True,
-        null=True,
-        on_delete=models.SET_NULL,
+        settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL
     )
     voter_name = models.CharField(max_length=255)
     voter_slug = models.CharField(max_length=255)
@@ -28,6 +16,4 @@ class PollVote(models.Model):
     choice_hash = models.CharField(max_length=12, db_index=True)
 
     class Meta:
-        index_together = [
-            ['poll', 'voter_name'],
-        ]
+        index_together = [["poll", "voter_name"]]

+ 39 - 45
misago/threads/models/post.py

@@ -15,25 +15,18 @@ from misago.threads.filtersearch import filter_search
 
 
 class Post(models.Model):
-    category = models.ForeignKey(
-        'misago_categories.Category',
-        on_delete=models.CASCADE,
-    )
-    thread = models.ForeignKey(
-        'misago_threads.Thread',
-        on_delete=models.CASCADE,
-    )
+    category = models.ForeignKey("misago_categories.Category", on_delete=models.CASCADE)
+    thread = models.ForeignKey("misago_threads.Thread", on_delete=models.CASCADE)
     poster = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        blank=True,
-        null=True,
-        on_delete=models.SET_NULL,
+        settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL
     )
     poster_name = models.CharField(max_length=255)
     original = models.TextField()
     parsed = models.TextField()
-    checksum = models.CharField(max_length=64, default='-')
-    mentions = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name="mention_set")
+    checksum = models.CharField(max_length=64, default="-")
+    mentions = models.ManyToManyField(
+        settings.AUTH_USER_MODEL, related_name="mention_set"
+    )
 
     attachments_cache = JSONField(null=True, blank=True)
 
@@ -47,7 +40,7 @@ class Post(models.Model):
         blank=True,
         null=True,
         on_delete=models.SET_NULL,
-        related_name='+',
+        related_name="+",
     )
     last_editor_name = models.CharField(max_length=255, null=True, blank=True)
     last_editor_slug = models.SlugField(max_length=255, null=True, blank=True)
@@ -57,7 +50,7 @@ class Post(models.Model):
         blank=True,
         null=True,
         on_delete=models.SET_NULL,
-        related_name='+',
+        related_name="+",
     )
     hidden_by_name = models.CharField(max_length=255, null=True, blank=True)
     hidden_by_slug = models.SlugField(max_length=255, null=True, blank=True)
@@ -77,8 +70,8 @@ class Post(models.Model):
 
     liked_by = models.ManyToManyField(
         settings.AUTH_USER_MODEL,
-        related_name='liked_post_set',
-        through='misago_threads.PostLike',
+        related_name="liked_post_set",
+        through="misago_threads.PostLike",
     )
 
     search_document = models.TextField(null=True, blank=True)
@@ -87,31 +80,25 @@ class Post(models.Model):
     class Meta:
         indexes = [
             PgPartialIndex(
-                fields=['has_open_reports'],
-                where={'has_open_reports': True},
+                fields=["has_open_reports"], where={"has_open_reports": True}
             ),
-            PgPartialIndex(
-                fields=['is_hidden'],
-                where={'is_hidden': False},
-            ),
-            PgPartialIndex(
-                fields=['is_event', 'event_type'],
-                where={'is_event': True},
-            ),
-            GinIndex(fields=['search_vector']),
+            PgPartialIndex(fields=["is_hidden"], where={"is_hidden": False}),
+            PgPartialIndex(fields=["is_event", "event_type"], where={"is_event": True}),
+            GinIndex(fields=["search_vector"]),
         ]
 
         index_together = [
-            ('thread', 'id'),  # speed up threadview for team members
-            ('is_event', 'is_hidden'),
-            ('poster', 'posted_on'),
+            ("thread", "id"),  # speed up threadview for team members
+            ("is_event", "is_hidden"),
+            ("poster", "posted_on"),
         ]
 
     def __str__(self):
-        return '%s...' % self.original[10:].strip()
+        return "%s..." % self.original[10:].strip()
 
     def delete(self, *args, **kwargs):
         from misago.threads.signals import delete_post
+
         delete_post.send(sender=self)
 
         super().delete(*args, **kwargs)
@@ -119,8 +106,11 @@ class Post(models.Model):
     def merge(self, other_post):
         if self.poster_id != other_post.poster_id:
             raise ValueError("post can't be merged with other user's post")
-        elif (self.poster_id is None and other_post.poster_id is None and
-                self.poster_name != other_post.poster_name):
+        elif (
+            self.poster_id is None
+            and other_post.poster_id is None
+            and self.poster_name != other_post.poster_name
+        ):
             raise ValueError("post can't be merged with other user's post")
 
         if self.thread_id != other_post.thread_id:
@@ -132,8 +122,8 @@ class Post(models.Model):
         if self.pk == other_post.pk:
             raise ValueError("post can't be merged with itself")
 
-        other_post.original = str('\n\n').join((other_post.original, self.original))
-        other_post.parsed = str('\n').join((other_post.parsed, self.parsed))
+        other_post.original = str("\n\n").join((other_post.original, self.original))
+        other_post.parsed = str("\n").join((other_post.parsed, self.parsed))
         update_post_checksum(other_post)
 
         if self.is_protected:
@@ -144,6 +134,7 @@ class Post(models.Model):
             self.thread.best_answer_is_protected = other_post.is_protected
 
         from misago.threads.signals import merge_post
+
         merge_post.send(sender=self, other_post=other_post)
 
     def move(self, new_thread):
@@ -158,20 +149,22 @@ class Post(models.Model):
 
     @property
     def attachments(self):
-        if hasattr(self, '_hydrated_attachments_cache'):
+        if hasattr(self, "_hydrated_attachments_cache"):
             return self._hydrated_attachments_cache
 
         self._hydrated_attachments_cache = []
         if self.attachments_cache:
             for attachment in copy.deepcopy(self.attachments_cache):
-                attachment['uploaded_on'] = parse_iso8601_string(attachment['uploaded_on'])
+                attachment["uploaded_on"] = parse_iso8601_string(
+                    attachment["uploaded_on"]
+                )
                 self._hydrated_attachments_cache.append(attachment)
 
         return self._hydrated_attachments_cache
 
     @property
     def content(self):
-        if not hasattr(self, '_finalised_parsed'):
+        if not hasattr(self, "_finalised_parsed"):
             self._finalised_parsed = finalise_markup(self.parsed)
         return self._finalised_parsed
 
@@ -199,25 +192,26 @@ class Post(models.Model):
 
     def set_search_document(self, thread_title=None):
         if thread_title:
-            self.search_document = filter_search('\n\n'.join([thread_title, self.original]))
+            self.search_document = filter_search(
+                "\n\n".join([thread_title, self.original])
+            )
         else:
             self.search_document = filter_search(self.original)
 
     def update_search_vector(self):
         self.search_vector = SearchVector(
-            'search_document',
-            config=settings.MISAGO_SEARCH_CONFIG,
+            "search_document", config=settings.MISAGO_SEARCH_CONFIG
         )
 
     @property
     def short(self):
         if self.is_valid:
             if len(self.original) > 150:
-                return str('%s...') % self.original[:150].strip()
+                return str("%s...") % self.original[:150].strip()
             else:
                 return self.original
         else:
-            return ''
+            return ""
 
     @property
     def is_valid(self):

+ 5 - 10
misago/threads/models/postedit.py

@@ -6,21 +6,16 @@ from django.utils import timezone
 
 
 class PostEdit(models.Model):
-    category = models.ForeignKey('misago_categories.Category', on_delete=models.CASCADE)
-    thread = models.ForeignKey('misago_threads.Thread', on_delete=models.CASCADE)
+    category = models.ForeignKey("misago_categories.Category", on_delete=models.CASCADE)
+    thread = models.ForeignKey("misago_threads.Thread", on_delete=models.CASCADE)
     post = models.ForeignKey(
-        'misago_threads.Post',
-        related_name='edits_record',
-        on_delete=models.CASCADE,
+        "misago_threads.Post", related_name="edits_record", on_delete=models.CASCADE
     )
 
     edited_on = models.DateTimeField(default=timezone.now)
 
     editor = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        blank=True,
-        null=True,
-        on_delete=models.SET_NULL,
+        settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL
     )
     editor_name = models.CharField(max_length=255)
     editor_slug = models.CharField(max_length=255)
@@ -29,7 +24,7 @@ class PostEdit(models.Model):
     edited_to = models.TextField()
 
     class Meta:
-        ordering = ['-id']
+        ordering = ["-id"]
 
     def get_diff(self):
         return difflib.ndiff(self.edited_from.splitlines(), self.edited_to.splitlines())

+ 5 - 17
misago/threads/models/postlike.py

@@ -4,24 +4,12 @@ from django.utils import timezone
 
 
 class PostLike(models.Model):
-    category = models.ForeignKey(
-        'misago_categories.Category',
-        on_delete=models.CASCADE,
-    )
-    thread = models.ForeignKey(
-        'misago_threads.Thread',
-        on_delete=models.CASCADE,
-    )
-    post = models.ForeignKey(
-        'misago_threads.Post',
-        on_delete=models.CASCADE,
-    )
+    category = models.ForeignKey("misago_categories.Category", on_delete=models.CASCADE)
+    thread = models.ForeignKey("misago_threads.Thread", on_delete=models.CASCADE)
+    post = models.ForeignKey("misago_threads.Post", on_delete=models.CASCADE)
 
     liker = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        blank=True,
-        null=True,
-        on_delete=models.SET_NULL,
+        settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL
     )
     liker_name = models.CharField(max_length=255, db_index=True)
     liker_slug = models.CharField(max_length=255)
@@ -29,4 +17,4 @@ class PostLike(models.Model):
     liked_on = models.DateTimeField(default=timezone.now)
 
     class Meta:
-        ordering = ['-id']
+        ordering = ["-id"]

+ 4 - 10
misago/threads/models/subscription.py

@@ -5,18 +5,12 @@ from misago.conf import settings
 
 
 class Subscription(models.Model):
-    user = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        on_delete=models.CASCADE,
-    )
-    thread = models.ForeignKey('Thread', on_delete=models.CASCADE)
-    category = models.ForeignKey(
-        'misago_categories.Category',
-        on_delete=models.CASCADE,
-    )
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
+    thread = models.ForeignKey("Thread", on_delete=models.CASCADE)
+    category = models.ForeignKey("misago_categories.Category", on_delete=models.CASCADE)
 
     last_read_on = models.DateTimeField(default=timezone.now)
     send_email = models.BooleanField(default=False)
 
     class Meta:
-        index_together = [['send_email', 'last_read_on']]
+        index_together = [["send_email", "last_read_on"]]

+ 28 - 49
misago/threads/models/thread.py

@@ -19,10 +19,7 @@ class Thread(models.Model):
         (WEIGHT_GLOBAL, _("Pin thread globally")),
     ]
 
-    category = models.ForeignKey(
-        'misago_categories.Category',
-        on_delete=models.CASCADE,
-    )
+    category = models.ForeignKey("misago_categories.Category", on_delete=models.CASCADE)
     title = models.CharField(max_length=255)
     slug = models.CharField(max_length=255)
     replies = models.PositiveIntegerField(default=0, db_index=True)
@@ -38,24 +35,21 @@ class Thread(models.Model):
     last_post_on = models.DateTimeField(db_index=True)
 
     first_post = models.ForeignKey(
-        'misago_threads.Post',
-        related_name='+',
+        "misago_threads.Post",
+        related_name="+",
         null=True,
         blank=True,
         on_delete=models.SET_NULL,
     )
     starter = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        null=True,
-        blank=True,
-        on_delete=models.SET_NULL,
+        settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL
     )
     starter_name = models.CharField(max_length=255)
     starter_slug = models.CharField(max_length=255)
 
     last_post = models.ForeignKey(
-        'misago_threads.Post',
-        related_name='+',
+        "misago_threads.Post",
+        related_name="+",
         null=True,
         blank=True,
         on_delete=models.SET_NULL,
@@ -63,7 +57,7 @@ class Thread(models.Model):
     last_post_is_event = models.BooleanField(default=False)
     last_poster = models.ForeignKey(
         settings.AUTH_USER_MODEL,
-        related_name='last_poster_set',
+        related_name="last_poster_set",
         null=True,
         blank=True,
         on_delete=models.SET_NULL,
@@ -78,17 +72,17 @@ class Thread(models.Model):
     is_closed = models.BooleanField(default=False)
 
     best_answer = models.ForeignKey(
-        'misago_threads.Post',
-        related_name='+',
+        "misago_threads.Post",
+        related_name="+",
         null=True,
         blank=True,
         on_delete=models.SET_NULL,
     )
-    best_answer_is_protected =  models.BooleanField(default=False)
+    best_answer_is_protected = models.BooleanField(default=False)
     best_answer_marked_on = models.DateTimeField(null=True, blank=True)
     best_answer_marked_by = models.ForeignKey(
         settings.AUTH_USER_MODEL,
-        related_name='marked_best_answer_set',
+        related_name="marked_best_answer_set",
         null=True,
         blank=True,
         on_delete=models.SET_NULL,
@@ -98,47 +92,30 @@ class Thread(models.Model):
 
     participants = models.ManyToManyField(
         settings.AUTH_USER_MODEL,
-        related_name='privatethread_set',
-        through='ThreadParticipant',
-        through_fields=('thread', 'user'),
+        related_name="privatethread_set",
+        through="ThreadParticipant",
+        through_fields=("thread", "user"),
     )
 
     class Meta:
         indexes = [
+            PgPartialIndex(fields=["weight"], where={"weight": 2}),
+            PgPartialIndex(fields=["weight"], where={"weight": 1}),
+            PgPartialIndex(fields=["weight"], where={"weight": 0}),
+            PgPartialIndex(fields=["weight"], where={"weight__lt": 2}),
             PgPartialIndex(
-                fields=['weight'],
-                where={'weight': 2},
-            ),
-            PgPartialIndex(
-                fields=['weight'],
-                where={'weight': 1},
-            ),
-            PgPartialIndex(
-                fields=['weight'],
-                where={'weight': 0},
+                fields=["has_reported_posts"], where={"has_reported_posts": True}
             ),
             PgPartialIndex(
-                fields=['weight'],
-                where={'weight__lt': 2},
-            ),
-            PgPartialIndex(
-                fields=['has_reported_posts'],
-                where={'has_reported_posts': True},
-            ),
-            PgPartialIndex(
-                fields=['has_unapproved_posts'],
-                where={'has_unapproved_posts': True},
-            ),
-            PgPartialIndex(
-                fields=['is_hidden'],
-                where={'is_hidden': False},
+                fields=["has_unapproved_posts"], where={"has_unapproved_posts": True}
             ),
+            PgPartialIndex(fields=["is_hidden"], where={"is_hidden": False}),
         ]
 
         index_together = [
-            ['category', 'id'],
-            ['category', 'last_post_on'],
-            ['category', 'replies'],
+            ["category", "id"],
+            ["category", "last_post_on"],
+            ["category", "replies"],
         ]
 
     def __str__(self):
@@ -146,6 +123,7 @@ class Thread(models.Model):
 
     def delete(self, *args, **kwargs):
         from misago.threads.signals import delete_thread
+
         delete_thread.send(sender=self)
 
         super().delete(*args, **kwargs)
@@ -155,6 +133,7 @@ class Thread(models.Model):
             raise ValueError("thread can't be merged with itself")
 
         from misago.threads.signals import merge_thread
+
         merge_thread.send(sender=self, other_thread=other_thread)
 
     def move(self, new_category):
@@ -189,7 +168,7 @@ class Thread(models.Model):
         hidden_post_qs = self.post_set.filter(is_hidden=True)[:1]
         self.has_hidden_posts = hidden_post_qs.exists()
 
-        posts = self.post_set.order_by('id')
+        posts = self.post_set.order_by("id")
 
         first_post = posts.first()
         self.set_first_post(first_post)
@@ -305,4 +284,4 @@ class Thread(models.Model):
         self.best_answer_marked_on = None
         self.best_answer_marked_by = None
         self.best_answer_marked_by_name = None
-        self.best_answer_marked_by_slug = None
+        self.best_answer_marked_by_slug = None

+ 5 - 9
misago/threads/models/threadparticipant.py

@@ -5,7 +5,9 @@ from misago.conf import settings
 
 class ThreadParticipantManager(models.Manager):
     def set_owner(self, thread, user):
-        ThreadParticipant.objects.filter(thread=thread, is_owner=True).update(is_owner=False)
+        ThreadParticipant.objects.filter(thread=thread, is_owner=True).update(
+            is_owner=False
+        )
 
         self.remove_participant(thread, user)
 
@@ -23,14 +25,8 @@ class ThreadParticipantManager(models.Manager):
 
 
 class ThreadParticipant(models.Model):
-    thread = models.ForeignKey(
-        'misago_threads.Thread',
-        on_delete=models.CASCADE,
-    )
-    user = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        on_delete=models.CASCADE,
-    )
+    thread = models.ForeignKey("misago_threads.Thread", on_delete=models.CASCADE)
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
     is_owner = models.BooleanField(default=False)
 
     objects = ThreadParticipantManager()

+ 23 - 19
misago/threads/moderation/posts.py

@@ -6,19 +6,19 @@ from .exceptions import ModerationError
 
 
 __all__ = [
-    'approve_post',
-    'protect_post',
-    'unprotect_post',
-    'unhide_post',
-    'hide_post',
-    'delete_post',
+    "approve_post",
+    "protect_post",
+    "unprotect_post",
+    "unhide_post",
+    "hide_post",
+    "delete_post",
 ]
 
 
 def approve_post(user, post):
     if post.is_unapproved:
         post.is_unapproved = False
-        post.save(update_fields=['is_unapproved'])
+        post.save(update_fields=["is_unapproved"])
         return True
     else:
         return False
@@ -27,10 +27,10 @@ def approve_post(user, post):
 def protect_post(user, post):
     if not post.is_protected:
         post.is_protected = True
-        post.save(update_fields=['is_protected'])
+        post.save(update_fields=["is_protected"])
         if post.is_best_answer:
             post.thread.best_answer_is_protected = True
-            post.thread.save(update_fields=['best_answer_is_protected'])
+            post.thread.save(update_fields=["best_answer_is_protected"])
         return True
     else:
         return False
@@ -39,10 +39,10 @@ def protect_post(user, post):
 def unprotect_post(user, post):
     if post.is_protected:
         post.is_protected = False
-        post.save(update_fields=['is_protected'])
+        post.save(update_fields=["is_protected"])
         if post.is_best_answer:
             post.thread.best_answer_is_protected = False
-            post.thread.save(update_fields=['best_answer_is_protected'])
+            post.thread.save(update_fields=["best_answer_is_protected"])
         return True
     else:
         return False
@@ -50,11 +50,13 @@ def unprotect_post(user, post):
 
 def unhide_post(user, post):
     if post.is_first_post:
-        raise ModerationError(_("You can't make original post visible without revealing thread."))
+        raise ModerationError(
+            _("You can't make original post visible without revealing thread.")
+        )
 
     if post.is_hidden:
         post.is_hidden = False
-        post.save(update_fields=['is_hidden'])
+        post.save(update_fields=["is_hidden"])
         return True
     else:
         return False
@@ -72,11 +74,11 @@ def hide_post(user, post):
         post.hidden_on = timezone.now()
         post.save(
             update_fields=[
-                'is_hidden',
-                'hidden_by',
-                'hidden_by_name',
-                'hidden_by_slug',
-                'hidden_on',
+                "is_hidden",
+                "hidden_by",
+                "hidden_by_name",
+                "hidden_by_slug",
+                "hidden_on",
             ]
         )
         return True
@@ -87,7 +89,9 @@ def hide_post(user, post):
 @transaction.atomic
 def delete_post(user, post):
     if post.is_first_post:
-        raise ModerationError(_("You can't delete original post without deleting thread."))
+        raise ModerationError(
+            _("You can't delete original post without deleting thread.")
+        )
 
     post.delete()
     return True

+ 41 - 42
misago/threads/moderation/threads.py

@@ -5,18 +5,18 @@ from misago.threads.events import record_event
 
 
 __all__ = [
-    'change_thread_title',
-    'pin_thread_globally',
-    'pin_thread_locally',
-    'unpin_thread',
-    'move_thread',
-    'merge_thread',
-    'approve_thread',
-    'open_thread',
-    'close_thread',
-    'unhide_thread',
-    'hide_thread',
-    'delete_thread',
+    "change_thread_title",
+    "pin_thread_globally",
+    "pin_thread_locally",
+    "unpin_thread",
+    "move_thread",
+    "merge_thread",
+    "approve_thread",
+    "open_thread",
+    "close_thread",
+    "unhide_thread",
+    "hide_thread",
+    "delete_thread",
 ]
 
 
@@ -25,17 +25,15 @@ def change_thread_title(request, thread, new_title):
     if thread.title != new_title:
         old_title = thread.title
         thread.set_title(new_title)
-        thread.save(update_fields=['title', 'slug'])
+        thread.save(update_fields=["title", "slug"])
 
         thread.first_post.set_search_document(thread.title)
-        thread.first_post.save(update_fields=['search_document'])
+        thread.first_post.save(update_fields=["search_document"])
 
         thread.first_post.update_search_vector()
-        thread.first_post.save(update_fields=['search_vector'])
+        thread.first_post.save(update_fields=["search_vector"])
 
-        record_event(request, thread, 'changed_title', {
-            'old_title': old_title,
-        })
+        record_event(request, thread, "changed_title", {"old_title": old_title})
         return True
     else:
         return False
@@ -45,7 +43,7 @@ def change_thread_title(request, thread, new_title):
 def pin_thread_globally(request, thread):
     if thread.weight != 2:
         thread.weight = 2
-        record_event(request, thread, 'pinned_globally')
+        record_event(request, thread, "pinned_globally")
         return True
     else:
         return False
@@ -55,7 +53,7 @@ def pin_thread_globally(request, thread):
 def pin_thread_locally(request, thread):
     if thread.weight != 1:
         thread.weight = 1
-        record_event(request, thread, 'pinned_locally')
+        record_event(request, thread, "pinned_locally")
         return True
     else:
         return False
@@ -65,7 +63,7 @@ def pin_thread_locally(request, thread):
 def unpin_thread(request, thread):
     if thread.weight:
         thread.weight = 0
-        record_event(request, thread, 'unpinned')
+        record_event(request, thread, "unpinned")
         return True
     else:
         return False
@@ -78,12 +76,15 @@ def move_thread(request, thread, new_category):
         thread.move(new_category)
 
         record_event(
-            request, thread, 'moved', {
-                'from_category': {
-                    'name': from_category.name,
-                    'url': from_category.get_absolute_url(),
-                },
-            }
+            request,
+            thread,
+            "moved",
+            {
+                "from_category": {
+                    "name": from_category.name,
+                    "url": from_category.get_absolute_url(),
+                }
+            },
         )
         return True
     else:
@@ -95,9 +96,7 @@ def merge_thread(request, thread, other_thread):
     thread.merge(other_thread)
     other_thread.delete()
 
-    record_event(request, thread, 'merged', {
-        'merged_thread': other_thread.title,
-    })
+    record_event(request, thread, "merged", {"merged_thread": other_thread.title})
     return True
 
 
@@ -105,14 +104,14 @@ def merge_thread(request, thread, other_thread):
 def approve_thread(request, thread):
     if thread.is_unapproved:
         thread.first_post.is_unapproved = False
-        thread.first_post.save(update_fields=['is_unapproved'])
+        thread.first_post.save(update_fields=["is_unapproved"])
 
         thread.is_unapproved = False
 
         unapproved_post_qs = thread.post_set.filter(is_unapproved=True)
         thread.has_unapproved_posts = unapproved_post_qs.exists()
 
-        record_event(request, thread, 'approved')
+        record_event(request, thread, "approved")
         return True
     else:
         return False
@@ -122,7 +121,7 @@ def approve_thread(request, thread):
 def open_thread(request, thread):
     if thread.is_closed:
         thread.is_closed = False
-        record_event(request, thread, 'opened')
+        record_event(request, thread, "opened")
         return True
     else:
         return False
@@ -132,7 +131,7 @@ def open_thread(request, thread):
 def close_thread(request, thread):
     if not thread.is_closed:
         thread.is_closed = True
-        record_event(request, thread, 'closed')
+        record_event(request, thread, "closed")
         return True
     else:
         return False
@@ -142,10 +141,10 @@ def close_thread(request, thread):
 def unhide_thread(request, thread):
     if thread.is_hidden:
         thread.first_post.is_hidden = False
-        thread.first_post.save(update_fields=['is_hidden'])
+        thread.first_post.save(update_fields=["is_hidden"])
         thread.is_hidden = False
 
-        record_event(request, thread, 'unhid')
+        record_event(request, thread, "unhid")
 
         if thread.pk == thread.category.last_thread_id:
             thread.category.synchronize()
@@ -166,16 +165,16 @@ def hide_thread(request, thread):
         thread.first_post.hidden_on = timezone.now()
         thread.first_post.save(
             update_fields=[
-                'is_hidden',
-                'hidden_by',
-                'hidden_by_name',
-                'hidden_by_slug',
-                'hidden_on',
+                "is_hidden",
+                "hidden_by",
+                "hidden_by_name",
+                "hidden_by_slug",
+                "hidden_on",
             ]
         )
         thread.is_hidden = True
 
-        record_event(request, thread, 'hid')
+        record_event(request, thread, "hid")
 
         if thread.pk == thread.category.last_thread_id:
             thread.category.synchronize()

+ 41 - 42
misago/threads/participants.py

@@ -15,7 +15,7 @@ def has_participants(thread):
 
 
 def make_participants_aware(user, target):
-    if hasattr(target, '__iter__'):
+    if hasattr(target, "__iter__"):
         make_threads_participants_aware(user, target)
     else:
         make_thread_participants_aware(user, target)
@@ -28,8 +28,7 @@ def make_threads_participants_aware(user, threads):
         threads_dict[thread.pk] = thread
 
     participants_qs = ThreadParticipant.objects.filter(
-        user=user,
-        thread_id__in=threads_dict.keys(),
+        user=user, thread_id__in=threads_dict.keys()
     )
 
     for participant in participants_qs:
@@ -42,8 +41,8 @@ def make_thread_participants_aware(user, thread):
     thread.participant = None
 
     participants_qs = ThreadParticipant.objects.filter(thread=thread)
-    participants_qs = participants_qs.select_related('user')
-    for participant in participants_qs.order_by('-is_owner', 'user__slug'):
+    participants_qs = participants_qs.select_related("user")
+    for participant in participants_qs.order_by("-is_owner", "user__slug"):
         participant.thread = thread
         thread.participants_list.append(participant)
         if participant.user == user:
@@ -51,7 +50,9 @@ def make_thread_participants_aware(user, thread):
     return thread.participants_list
 
 
-def set_users_unread_private_threads_sync(users=None, participants=None, exclude_user=None):
+def set_users_unread_private_threads_sync(
+    users=None, participants=None, exclude_user=None
+):
     users_ids = []
     if users:
         users_ids += [u.pk for u in users]
@@ -63,7 +64,9 @@ def set_users_unread_private_threads_sync(users=None, participants=None, exclude
     if not users_ids:
         return
 
-    UserModel.objects.filter(id__in=set(users_ids)).update(sync_unread_private_threads=True)
+    UserModel.objects.filter(id__in=set(users_ids)).update(
+        sync_unread_private_threads=True
+    )
 
 
 def set_owner(thread, user):
@@ -73,25 +76,24 @@ def set_owner(thread, user):
 def change_owner(request, thread, new_owner):
     ThreadParticipant.objects.set_owner(thread, new_owner)
     set_users_unread_private_threads_sync(
-        participants=thread.participants_list,
-        exclude_user=request.user,
+        participants=thread.participants_list, exclude_user=request.user
     )
 
     if thread.participant and thread.participant.is_owner:
         record_event(
             request,
             thread,
-            'changed_owner',
+            "changed_owner",
             {
-                'user': {
-                    'id': new_owner.id,
-                    'username': new_owner.username,
-                    'url': new_owner.get_absolute_url(),
-                },
+                "user": {
+                    "id": new_owner.id,
+                    "username": new_owner.username,
+                    "url": new_owner.get_absolute_url(),
+                }
             },
         )
     else:
-        record_event(request, thread, 'tookover')
+        record_event(request, thread, "tookover")
 
 
 def add_participant(request, thread, new_participant):
@@ -99,18 +101,18 @@ def add_participant(request, thread, new_participant):
     add_participants(request, thread, [new_participant])
 
     if request.user == new_participant:
-        record_event(request, thread, 'entered_thread')
+        record_event(request, thread, "entered_thread")
     else:
         record_event(
             request,
             thread,
-            'added_participant',
+            "added_participant",
             {
-                'user': {
-                    'id': new_participant.id,
-                    'username': new_participant.username,
-                    'url': new_participant.get_absolute_url(),
-                },
+                "user": {
+                    "id": new_participant.id,
+                    "username": new_participant.username,
+                    "url": new_participant.get_absolute_url(),
+                }
             },
         )
 
@@ -128,9 +130,7 @@ def add_participants(request, thread, users):
         thread_participants = []
 
     set_users_unread_private_threads_sync(
-        users=users,
-        participants=thread_participants,
-        exclude_user=request.user,
+        users=users, participants=thread_participants, exclude_user=request.user
     )
 
     emails = []
@@ -142,18 +142,17 @@ def add_participants(request, thread, users):
 
 
 def build_noticiation_email(request, thread, user):
-    subject = _('%(user)s has invited you to participate in private thread "%(thread)s"')
-    subject_formats = {
-        'thread': thread.title,
-        'user': request.user.username,
-    }
+    subject = _(
+        '%(user)s has invited you to participate in private thread "%(thread)s"'
+    )
+    subject_formats = {"thread": thread.title, "user": request.user.username}
 
     return build_mail(
         user,
         subject % subject_formats,
-        'misago/emails/privatethread/added',
+        "misago/emails/privatethread/added",
         sender=request.user,
-        context={'settings': request.settings, 'thread': thread},
+        context={"settings": request.settings, "thread": thread},
     )
 
 
@@ -180,24 +179,24 @@ def remove_participant(request, thread, user):
             thread.is_closed = True  # flag thread to close
 
             if request.user == user:
-                event_type = 'owner_left'
+                event_type = "owner_left"
             else:
-                event_type = 'removed_owner'
+                event_type = "removed_owner"
         else:
             if request.user == user:
-                event_type = 'participant_left'
+                event_type = "participant_left"
             else:
-                event_type = 'removed_participant'
+                event_type = "removed_participant"
 
         record_event(
             request,
             thread,
             event_type,
             {
-                'user': {
-                    'id': user.id,
-                    'username': user.username,
-                    'url': user.get_absolute_url(),
-                },
+                "user": {
+                    "id": user.id,
+                    "username": user.username,
+                    "url": user.get_absolute_url(),
+                }
             },
         )

+ 16 - 14
misago/threads/permissions/attachments.py

@@ -15,24 +15,28 @@ class PermissionsForm(forms.Form):
         label=_("Max attached file size (in kb)"),
         help_text=_("Enter 0 to don't allow uploading end deleting attachments."),
         initial=500,
-        min_value=0
+        min_value=0,
     )
 
     can_download_other_users_attachments = YesNoSwitch(
         label=_("Can download other users attachments")
     )
-    can_delete_other_users_attachments = YesNoSwitch(label=_("Can delete other users attachments"))
+    can_delete_other_users_attachments = YesNoSwitch(
+        label=_("Can delete other users attachments")
+    )
 
 
 class AnonymousPermissionsForm(forms.Form):
     legend = _("Attachments")
 
-    can_download_other_users_attachments = YesNoSwitch(label=_("Can download attachments"))
+    can_download_other_users_attachments = YesNoSwitch(
+        label=_("Can download attachments")
+    )
 
 
 def change_permissions_form(role):
     if isinstance(role, Role):
-        if role.special_role != 'anonymous':
+        if role.special_role != "anonymous":
             return PermissionsForm
         else:
             return AnonymousPermissionsForm
@@ -42,9 +46,9 @@ def change_permissions_form(role):
 
 def build_acl(acl, roles, key_name):
     new_acl = {
-        'max_attachment_size': 0,
-        'can_download_other_users_attachments': False,
-        'can_delete_other_users_attachments': False,
+        "max_attachment_size": 0,
+        "can_download_other_users_attachments": False,
+        "can_delete_other_users_attachments": False,
     }
     new_acl.update(acl)
 
@@ -60,14 +64,12 @@ def build_acl(acl, roles, key_name):
 
 def add_acl_to_attachment(user_acl, attachment):
     if user_acl["is_authenticated"] and user_acl["user_id"] == attachment.uploader_id:
-        attachment.acl.update({
-            'can_delete': True,
-        })
+        attachment.acl.update({"can_delete": True})
     else:
-        user_can_delete = user_acl['can_delete_other_users_attachments']
-        attachment.acl.update({
-            'can_delete': user_acl["is_authenticated"] and user_can_delete,
-        })
+        user_can_delete = user_acl["can_delete_other_users_attachments"]
+        attachment.acl.update(
+            {"can_delete": user_acl["is_authenticated"] and user_can_delete}
+        )
 
 
 def register_with(registry):

+ 102 - 100
misago/threads/permissions/bestanswers.py

@@ -12,16 +12,16 @@ from misago.threads.models import Post, Thread
 
 
 __all__nope = [
-    'allow_mark_best_answer',
-    'can_mark_best_answer',
-    'allow_mark_as_best_answer',
-    'can_mark_as_best_answer',
-    'allow_unmark_best_answer',
-    'can_unmark_best_answer',
-    'allow_hide_best_answer',
-    'can_hide_best_answer',
-    'allow_delete_best_answer',
-    'can_delete_best_answer',
+    "allow_mark_best_answer",
+    "can_mark_best_answer",
+    "allow_mark_as_best_answer",
+    "can_mark_as_best_answer",
+    "allow_unmark_best_answer",
+    "can_unmark_best_answer",
+    "allow_hide_best_answer",
+    "can_hide_best_answer",
+    "allow_delete_best_answer",
+    "can_delete_best_answer",
 ]
 
 
@@ -32,25 +32,21 @@ class CategoryPermissionsForm(forms.Form):
         label=_("Can mark posts as best answers"),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Own threads")),
-            (2, _("All threads")),
-        ],
+        choices=[(0, _("No")), (1, _("Own threads")), (2, _("All threads"))],
     )
     can_change_marked_answers = forms.TypedChoiceField(
         label=_("Can change marked answers"),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Own threads")),
-            (2, _("All threads")),
-        ],
+        choices=[(0, _("No")), (1, _("Own threads")), (2, _("All threads"))],
     )
     best_answer_change_time = forms.IntegerField(
-        label=_("Time limit for changing marked best answer in owned thread, in minutes"),
-        help_text=_("Enter 0 to don't limit time for changing marked best answer in owned thread."),
+        label=_(
+            "Time limit for changing marked best answer in owned thread, in minutes"
+        ),
+        help_text=_(
+            "Enter 0 to don't limit time for changing marked best answer in owned thread."
+        ),
         initial=0,
         min_value=0,
     )
@@ -68,20 +64,22 @@ def build_acl(acl, roles, key_name):
     categories = list(Category.objects.all_categories(include_root=True))
 
     for category in categories:
-        category_acl = acl['categories'].get(category.pk, {'can_browse': 0})
-        if category_acl['can_browse']:
-            acl['categories'][category.pk] = build_category_acl(
+        category_acl = acl["categories"].get(category.pk, {"can_browse": 0})
+        if category_acl["can_browse"]:
+            acl["categories"][category.pk] = build_category_acl(
                 category_acl, category, categories_roles, key_name
             )
 
     private_category = Category.objects.private_threads()
-    private_threads_acl = acl['categories'].get(private_category.pk)
+    private_threads_acl = acl["categories"].get(private_category.pk)
     if private_threads_acl:
-        private_threads_acl.update({
-            'can_mark_best_answers': 0,
-            'can_change_marked_answers': 0,
-            'best_answer_change_time': 0,
-        })
+        private_threads_acl.update(
+            {
+                "can_mark_best_answers": 0,
+                "can_change_marked_answers": 0,
+                "best_answer_change_time": 0,
+            }
+        )
 
     return acl
 
@@ -90,9 +88,9 @@ def build_category_acl(acl, category, categories_roles, key_name):
     category_roles = categories_roles.get(category.pk, [])
 
     final_acl = {
-        'can_mark_best_answers': 0,
-        'can_change_marked_answers': 0,
-        'best_answer_change_time': 0,
+        "can_mark_best_answers": 0,
+        "can_change_marked_answers": 0,
+        "best_answer_change_time": 0,
     }
     final_acl.update(acl)
 
@@ -109,19 +107,23 @@ def build_category_acl(acl, category, categories_roles, key_name):
 
 
 def add_acl_to_thread(user_acl, thread):
-    thread.acl.update({
-        '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),
-    })
-    
+    thread.acl.update(
+        {
+            "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_acl, post):
-    post.acl.update({
-        '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),
-    })
+    post.acl.update(
+        {
+            "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),
+        }
+    )
 
 
 def register_with(registry):
@@ -133,34 +135,35 @@ def allow_mark_best_answer(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to mark best answers."))
 
-    category_acl = user_acl['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(
             _(
                 'You don\'t have permission to mark best answers in the "%(category)s" category.'
-            ) % {
-                'category': target.category,
-            }
+            )
+            % {"category": target.category}
         )
 
-    if category_acl['can_mark_best_answers'] == 1 and user_acl["user_id"] != target.starter_id:
+    if (
+        category_acl["can_mark_best_answers"] == 1
+        and user_acl["user_id"] != target.starter_id
+    ):
         raise PermissionDenied(
             _(
                 "You don't have permission to mark best answer in this thread because you didn't "
                 "start it."
             )
         )
-    
-    if not category_acl['can_close_threads']:
+
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
             raise PermissionDenied(
                 _(
-                    'You don\'t have permission to mark best answer in this thread because its '
+                    "You don't have permission to mark best answer in this thread because its "
                     'category "%(category)s" is closed.'
-                ) % {
-                    'category': target.category,
-                }
+                )
+                % {"category": target.category}
             )
         if target.is_closed:
             raise PermissionDenied(
@@ -176,21 +179,20 @@ can_mark_best_answer = return_boolean(allow_mark_best_answer)
 
 def allow_change_best_answer(user_acl, target):
     if not target.has_best_answer:
-        return # shortcircut permission test
+        return  # shortcircut permission test
 
-    category_acl = user_acl['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(
             _(
-                'You don\'t have permission to change this thread\'s marked answer because it\'s '
+                "You don't have permission to change this thread's marked answer because it's "
                 'in the "%(category)s" category.'
-            ) % {
-                'category': target.category,
-            }
+            )
+            % {"category": target.category}
         )
 
-    if category_acl['can_change_marked_answers'] == 1:
+    if category_acl["can_change_marked_answers"] == 1:
         if user_acl["user_id"] != target.starter_id:
             raise PermissionDenied(
                 _(
@@ -209,13 +211,12 @@ def allow_change_best_answer(user_acl, target):
                         "You don't have permission to change best answer that was marked for more "
                         "than %(minutes)s minutes."
                     ),
-                    category_acl['best_answer_change_time'],
-                ) % {
-                    'minutes': category_acl['best_answer_change_time'],
-                }
+                    category_acl["best_answer_change_time"],
+                )
+                % {"minutes": category_acl["best_answer_change_time"]}
             )
 
-    if target.best_answer_is_protected and not category_acl['can_protect_posts']:
+    if target.best_answer_is_protected and not category_acl["can_protect_posts"]:
         raise PermissionDenied(
             _(
                 "You don't have permission to change this thread's best answer because "
@@ -232,21 +233,20 @@ def allow_unmark_best_answer(user_acl, target):
         raise PermissionDenied(_("You have to sign in to unmark best answers."))
 
     if not target.has_best_answer:
-        return # shortcircut test
+        return  # shortcircut test
 
-    category_acl = user_acl['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(
             _(
                 'You don\'t have permission to unmark threads answers in the "%(category)s" '
-                'category.'
-            ) % {
-                'category': target.category,
-            }
+                "category."
+            )
+            % {"category": target.category}
         )
 
-    if category_acl['can_change_marked_answers'] == 1:
+    if category_acl["can_change_marked_answers"] == 1:
         if user_acl["user_id"] != target.starter_id:
             raise PermissionDenied(
                 _(
@@ -265,21 +265,19 @@ def allow_unmark_best_answer(user_acl, target):
                         "You don't have permission to unmark best answer that was marked for more "
                         "than %(minutes)s minutes."
                     ),
-                    category_acl['best_answer_change_time'],
-                ) % {
-                    'minutes': category_acl['best_answer_change_time'],
-                }
+                    category_acl["best_answer_change_time"],
+                )
+                % {"minutes": category_acl["best_answer_change_time"]}
             )
-        
-    if not category_acl['can_close_threads']:
+
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
             raise PermissionDenied(
                 _(
-                    'You don\'t have permission to unmark this best answer because its category '
+                    "You don't have permission to unmark this best answer because its category "
                     '"%(category)s" is closed.'
-                ) % {
-                    'category': target.category,
-                }
+                )
+                % {"category": target.category}
             )
         if target.is_closed:
             raise PermissionDenied(
@@ -289,7 +287,7 @@ def allow_unmark_best_answer(user_acl, target):
                 )
             )
 
-    if target.best_answer_is_protected and not category_acl['can_protect_posts']:
+    if target.best_answer_is_protected and not category_acl["can_protect_posts"]:
         raise PermissionDenied(
             _(
                 "You don't have permission to unmark this thread's best answer because a "
@@ -308,18 +306,20 @@ def allow_mark_as_best_answer(user_acl, target):
     if target.is_event:
         raise PermissionDenied(_("Events can't be marked as best answers."))
 
-    category_acl = user_acl['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(
             _(
                 'You don\'t have permission to mark best answers in the "%(category)s" category.'
-            ) % {
-                'category': target.category,
-            }
+            )
+            % {"category": target.category}
         )
 
-    if category_acl['can_mark_best_answers'] == 1 and user_acl["user_id"] != target.thread.starter_id:
+    if (
+        category_acl["can_mark_best_answers"] == 1
+        and user_acl["user_id"] != target.thread.starter_id
+    ):
         raise PermissionDenied(
             _(
                 "You don't have permission to mark best answer in this thread because you "
@@ -328,15 +328,17 @@ def allow_mark_as_best_answer(user_acl, target):
         )
 
     if target.is_first_post:
-        raise PermissionDenied(_("First post in a thread can't be marked as best answer."))
+        raise PermissionDenied(
+            _("First post in a thread can't be marked as best answer.")
+        )
 
     if target.is_hidden:
         raise PermissionDenied(_("Hidden posts can't be marked as best answers."))
 
     if target.is_unapproved:
         raise PermissionDenied(_("Unapproved posts can't be marked as best answers."))
-        
-    if target.is_protected and not category_acl['can_protect_posts']:
+
+    if target.is_protected and not category_acl["can_protect_posts"]:
         raise PermissionDenied(
             _(
                 "You don't have permission to mark this post as best answer because a moderator "
@@ -369,8 +371,8 @@ can_delete_best_answer = return_boolean(allow_delete_best_answer)
 
 
 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)
+    category_acl = user_acl["categories"].get(target.category_id, {})
+    change_time = category_acl.get("best_answer_change_time", 0)
 
     if change_time:
         diff = timezone.now() - target.best_answer_marked_on

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

@@ -12,16 +12,16 @@ from misago.threads.models import Poll, Thread
 
 
 __all__ = [
-    'allow_start_poll',
-    'can_start_poll',
-    'allow_edit_poll',
-    'can_edit_poll',
-    'allow_delete_poll',
-    'can_delete_poll',
-    'allow_vote_poll',
-    'can_vote_poll',
-    'allow_see_poll_votes',
-    'can_see_poll_votes',
+    "allow_start_poll",
+    "can_start_poll",
+    "allow_edit_poll",
+    "can_edit_poll",
+    "allow_delete_poll",
+    "can_delete_poll",
+    "allow_vote_poll",
+    "can_vote_poll",
+    "allow_see_poll_votes",
+    "can_see_poll_votes",
 ]
 
 
@@ -32,31 +32,19 @@ class RolePermissionsForm(forms.Form):
         label=_("Can start polls"),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Own threads")),
-            (2, _("All threads")),
-        ],
+        choices=[(0, _("No")), (1, _("Own threads")), (2, _("All threads"))],
     )
     can_edit_polls = forms.TypedChoiceField(
         label=_("Can edit polls"),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Own polls")),
-            (2, _("All polls")),
-        ],
+        choices=[(0, _("No")), (1, _("Own polls")), (2, _("All polls"))],
     )
     can_delete_polls = forms.TypedChoiceField(
         label=_("Can delete polls"),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Own polls")),
-            (2, _("All polls")),
-        ],
+        choices=[(0, _("No")), (1, _("Own polls")), (2, _("All polls"))],
     )
     poll_edit_time = forms.IntegerField(
         label=_("Time limit for own polls edits, in minutes"),
@@ -66,25 +54,29 @@ class RolePermissionsForm(forms.Form):
     )
     can_always_see_poll_voters = YesNoSwitch(
         label=_("Can always see polls voters"),
-        help_text=_("Allows users to see who voted in poll even if poll votes are secret."),
+        help_text=_(
+            "Allows users to see who voted in poll even if poll votes are secret."
+        ),
     )
 
 
 def change_permissions_form(role):
-    if isinstance(role, Role) and role.special_role != 'anonymous':
+    if isinstance(role, Role) and role.special_role != "anonymous":
         return RolePermissionsForm
     else:
         return None
 
 
 def build_acl(acl, roles, key_name):
-    acl.update({
-        'can_start_polls': 0,
-        'can_edit_polls': 0,
-        'can_delete_polls': 0,
-        'poll_edit_time': 0,
-        'can_always_see_poll_voters': 0,
-    })
+    acl.update(
+        {
+            "can_start_polls": 0,
+            "can_edit_polls": 0,
+            "can_delete_polls": 0,
+            "poll_edit_time": 0,
+            "can_always_see_poll_voters": 0,
+        }
+    )
 
     return algebra.sum_acls(
         acl,
@@ -94,23 +86,23 @@ def build_acl(acl, roles, key_name):
         can_edit_polls=algebra.greater,
         can_delete_polls=algebra.greater,
         poll_edit_time=algebra.greater_or_zero,
-        can_always_see_poll_voters=algebra.greater
+        can_always_see_poll_voters=algebra.greater,
     )
 
 
 def add_acl_to_poll(user_acl, poll):
-    poll.acl.update({
-        '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),
-    })
+    poll.acl.update(
+        {
+            "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_acl, thread):
-    thread.acl.update({
-        'can_start_poll': can_start_poll(user_acl, thread),
-    })
+    thread.acl.update({"can_start_poll": can_start_poll(user_acl, thread)})
 
 
 def register_with(registry):
@@ -122,22 +114,24 @@ def allow_start_poll(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to start polls."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_close_threads': False,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_close_threads": False}
     )
 
-    if not user_acl.get('can_start_polls'):
+    if not user_acl.get("can_start_polls"):
         raise PermissionDenied(_("You can't start polls."))
-    if user_acl.get('can_start_polls') < 2 and user_acl["user_id"] != target.starter_id:
+    if user_acl.get("can_start_polls") < 2 and user_acl["user_id"] != target.starter_id:
         raise PermissionDenied(_("You can't start polls in other users threads."))
 
-    if not category_acl.get('can_close_threads'):
+    if not category_acl.get("can_close_threads"):
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't start polls in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't start polls in it.")
+            )
         if target.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't start polls in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't start polls in it.")
+            )
 
 
 can_start_poll = return_boolean(allow_start_poll)
@@ -147,34 +141,38 @@ def allow_edit_poll(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to edit polls."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_close_threads': False,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_close_threads": False}
     )
 
-    if not user_acl.get('can_edit_polls'):
+    if not user_acl.get("can_edit_polls"):
         raise PermissionDenied(_("You can't edit polls."))
 
-    if user_acl.get('can_edit_polls') < 2:
+    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_acl, target):
             message = ngettext(
                 "You can't edit polls that are older than %(minutes)s minute.",
                 "You can't edit polls that are older than %(minutes)s minutes.",
-                user_acl['poll_edit_time']
+                user_acl["poll_edit_time"],
             )
-            raise PermissionDenied(message % {'minutes': user_acl['poll_edit_time']})
+            raise PermissionDenied(message % {"minutes": user_acl["poll_edit_time"]})
 
         if target.is_over:
             raise PermissionDenied(_("This poll is over. You can't edit it."))
 
-    if not category_acl.get('can_close_threads'):
+    if not category_acl.get("can_close_threads"):
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't edit polls in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't edit polls in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't edit polls in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't edit polls in it.")
+            )
 
 
 can_edit_poll = return_boolean(allow_edit_poll)
@@ -184,33 +182,37 @@ def allow_delete_poll(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to delete polls."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_close_threads': False,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_close_threads": False}
     )
 
-    if not user_acl.get('can_delete_polls'):
+    if not user_acl.get("can_delete_polls"):
         raise PermissionDenied(_("You can't delete polls."))
 
-    if user_acl.get('can_delete_polls') < 2:
+    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_acl, target):
             message = ngettext(
                 "You can't delete polls that are older than %(minutes)s minute.",
                 "You can't delete polls that are older than %(minutes)s minutes.",
-                user_acl['poll_edit_time']
+                user_acl["poll_edit_time"],
             )
-            raise PermissionDenied(message % {'minutes': user_acl['poll_edit_time']})
+            raise PermissionDenied(message % {"minutes": user_acl["poll_edit_time"]})
         if target.is_over:
             raise PermissionDenied(_("This poll is over. You can't delete it."))
 
-    if not category_acl.get('can_close_threads'):
+    if not category_acl.get("can_close_threads"):
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't delete polls in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't delete polls in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't delete polls in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't delete polls in it.")
+            )
 
 
 can_delete_poll = return_boolean(allow_delete_poll)
@@ -225,13 +227,11 @@ def allow_vote_poll(user_acl, target):
     if target.is_over:
         raise PermissionDenied(_("This poll is over. You can't vote in it."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_close_threads': False,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_close_threads": False}
     )
 
-    if not category_acl.get('can_close_threads'):
+    if not category_acl.get("can_close_threads"):
         if target.category.is_closed:
             raise PermissionDenied(_("This category is closed. You can't vote in it."))
         if target.thread.is_closed:
@@ -242,7 +242,7 @@ can_vote_poll = return_boolean(allow_vote_poll)
 
 
 def allow_see_poll_votes(user_acl, target):
-    if not target.is_public and not user_acl['can_always_see_poll_voters']:
+    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."))
 
 
@@ -250,7 +250,7 @@ can_see_poll_votes = return_boolean(allow_see_poll_votes)
 
 
 def has_time_to_edit_poll(user_acl, target):
-    edit_time = user_acl['poll_edit_time']
+    edit_time = user_acl["poll_edit_time"]
     if edit_time:
         diff = timezone.now() - target.posted_on
         diff_minutes = int(diff.total_seconds() / 60)

+ 114 - 93
misago/threads/permissions/privatethreads.py

@@ -13,20 +13,20 @@ from misago.threads.models import Thread
 
 
 __all__ = [
-    'allow_use_private_threads',
-    'can_use_private_threads',
-    'allow_see_private_thread',
-    'can_see_private_thread',
-    'allow_change_owner',
-    'can_change_owner',
-    'allow_add_participants',
-    'can_add_participants',
-    'allow_remove_participant',
-    'can_remove_participant',
-    'allow_add_participant',
-    'can_add_participant',
-    'allow_message_user',
-    'can_message_user',
+    "allow_use_private_threads",
+    "can_use_private_threads",
+    "allow_see_private_thread",
+    "can_see_private_thread",
+    "allow_change_owner",
+    "can_change_owner",
+    "allow_add_participants",
+    "can_add_participants",
+    "allow_remove_participant",
+    "can_remove_participant",
+    "allow_add_participant",
+    "can_add_participant",
+    "allow_message_user",
+    "can_message_user",
 ]
 
 
@@ -43,7 +43,9 @@ class PermissionsForm(forms.Form):
     )
     can_add_everyone_to_private_threads = YesNoSwitch(
         label=_("Can add everyone to threads"),
-        help_text=_("Allows user to add users that are blocking him to private threads."),
+        help_text=_(
+            "Allows user to add users that are blocking him to private threads."
+        ),
     )
     can_report_private_threads = YesNoSwitch(
         label=_("Can report private threads"),
@@ -62,7 +64,7 @@ class PermissionsForm(forms.Form):
 
 
 def change_permissions_form(role):
-    if isinstance(role, Role) and role.special_role != 'anonymous':
+    if isinstance(role, Role) and role.special_role != "anonymous":
         return PermissionsForm
     else:
         return None
@@ -70,12 +72,12 @@ def change_permissions_form(role):
 
 def build_acl(acl, roles, key_name):
     new_acl = {
-        'can_use_private_threads': 0,
-        'can_start_private_threads': 0,
-        'max_private_thread_participants': 3,
-        'can_add_everyone_to_private_threads': 0,
-        'can_report_private_threads': 0,
-        'can_moderate_private_threads': 0,
+        "can_use_private_threads": 0,
+        "can_start_private_threads": 0,
+        "max_private_thread_participants": 3,
+        "can_add_everyone_to_private_threads": 0,
+        "can_report_private_threads": 0,
+        "can_moderate_private_threads": 0,
     }
 
     new_acl.update(acl)
@@ -89,65 +91,67 @@ def build_acl(acl, roles, key_name):
         max_private_thread_participants=algebra.greater_or_zero,
         can_add_everyone_to_private_threads=algebra.greater,
         can_report_private_threads=algebra.greater,
-        can_moderate_private_threads=algebra.greater
+        can_moderate_private_threads=algebra.greater,
     )
 
-    if not new_acl['can_use_private_threads']:
-        new_acl['can_start_private_threads'] = 0
+    if not new_acl["can_use_private_threads"]:
+        new_acl["can_start_private_threads"] = 0
         return new_acl
 
     private_category = Category.objects.private_threads()
 
-    new_acl['visible_categories'].append(private_category.pk)
-    new_acl['browseable_categories'].append(private_category.pk)
+    new_acl["visible_categories"].append(private_category.pk)
+    new_acl["browseable_categories"].append(private_category.pk)
 
-    if new_acl['can_moderate_private_threads']:
-        new_acl['can_see_reports'].append(private_category.pk)
+    if new_acl["can_moderate_private_threads"]:
+        new_acl["can_see_reports"].append(private_category.pk)
 
     category_acl = {
-        'can_see': 1,
-        'can_browse': 1,
-        'can_see_all_threads': 1,
-        'can_see_own_threads': 0,
-        'can_start_threads': new_acl['can_start_private_threads'],
-        'can_reply_threads': 1,
-        'can_edit_threads': 1,
-        'can_edit_posts': 1,
-        'can_hide_own_threads': 0,
-        'can_hide_own_posts': 1,
-        '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': new_acl['can_report_private_threads'],
-        'can_see_reports': 0,
-        'can_see_posts_likes': 0,
-        'can_like_posts': 0,
-        'can_hide_events': 0,
+        "can_see": 1,
+        "can_browse": 1,
+        "can_see_all_threads": 1,
+        "can_see_own_threads": 0,
+        "can_start_threads": new_acl["can_start_private_threads"],
+        "can_reply_threads": 1,
+        "can_edit_threads": 1,
+        "can_edit_posts": 1,
+        "can_hide_own_threads": 0,
+        "can_hide_own_posts": 1,
+        "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": new_acl["can_report_private_threads"],
+        "can_see_reports": 0,
+        "can_see_posts_likes": 0,
+        "can_like_posts": 0,
+        "can_hide_events": 0,
     }
 
-    if new_acl['can_moderate_private_threads']:
-        category_acl.update({
-            'can_edit_threads': 2,
-            'can_edit_posts': 2,
-            'can_hide_threads': 2,
-            'can_hide_posts': 2,
-            'can_protect_posts': 1,
-            'can_merge_posts': 1,
-            'can_see_reports': 1,
-            'can_close_threads': 1,
-            'can_hide_events': 2,
-        })
-
-    new_acl['categories'][private_category.pk] = category_acl
+    if new_acl["can_moderate_private_threads"]:
+        category_acl.update(
+            {
+                "can_edit_threads": 2,
+                "can_edit_posts": 2,
+                "can_hide_threads": 2,
+                "can_hide_posts": 2,
+                "can_protect_posts": 1,
+                "can_merge_posts": 1,
+                "can_see_reports": 1,
+                "can_close_threads": 1,
+                "can_hide_events": 2,
+            }
+        )
+
+    new_acl["categories"][private_category.pk] = category_acl
 
     return new_acl
 
@@ -156,15 +160,17 @@ def add_acl_to_thread(user_acl, thread):
     if thread.thread_type.root_name != PRIVATE_THREADS_ROOT_NAME:
         return
 
-    if not hasattr(thread, 'participant'):
+    if not hasattr(thread, "participant"):
         thread.participants_list = []
         thread.participant = None
 
-    thread.acl.update({
-        'can_start_poll': False,
-        'can_change_owner': can_change_owner(user_acl, thread),
-        'can_add_participants': can_add_participants(user_acl, thread),
-    })
+    thread.acl.update(
+        {
+            "can_start_poll": False,
+            "can_change_owner": can_change_owner(user_acl, thread),
+            "can_add_participants": can_add_participants(user_acl, thread),
+        }
+    )
 
 
 def register_with(registry):
@@ -174,7 +180,7 @@ def register_with(registry):
 def allow_use_private_threads(user_acl):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to use private threads."))
-    if not user_acl['can_use_private_threads']:
+    if not user_acl["can_use_private_threads"]:
         raise PermissionDenied(_("You can't use private threads."))
 
 
@@ -182,12 +188,14 @@ can_use_private_threads = return_boolean(allow_use_private_threads)
 
 
 def allow_see_private_thread(user_acl, target):
-    if user_acl['can_moderate_private_threads']:
+    if user_acl["can_moderate_private_threads"]:
         can_see_reported = target.has_reported_posts
     else:
         can_see_reported = False
 
-    can_see_participating = user_acl["user_id"] in [p.user_id for p in target.participants_list]
+    can_see_participating = user_acl["user_id"] in [
+        p.user_id for p in target.participants_list
+    ]
 
     if not (can_see_participating or can_see_reported):
         raise Http404()
@@ -197,11 +205,13 @@ can_see_private_thread = return_boolean(allow_see_private_thread)
 
 
 def allow_change_owner(user_acl, target):
-    is_moderator = user_acl['can_moderate_private_threads']
+    is_moderator = user_acl["can_moderate_private_threads"]
     is_owner = target.participant and target.participant.is_owner
 
     if not (is_owner or is_moderator):
-        raise PermissionDenied(_("Only thread owner and moderators can change threads owners."))
+        raise PermissionDenied(
+            _("Only thread owner and moderators can change threads owners.")
+        )
 
     if not is_moderator and target.is_closed:
         raise PermissionDenied(_("Only moderators can change closed threads owners."))
@@ -211,16 +221,20 @@ can_change_owner = return_boolean(allow_change_owner)
 
 
 def allow_add_participants(user_acl, target):
-    is_moderator = user_acl['can_moderate_private_threads']
+    is_moderator = user_acl["can_moderate_private_threads"]
 
     if not is_moderator:
         if not target.participant or not target.participant.is_owner:
-            raise PermissionDenied(_("You have to be thread owner to add new participants to it."))
+            raise PermissionDenied(
+                _("You have to be thread owner to add new participants to it.")
+            )
 
         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['max_private_thread_participants']
+    max_participants = user_acl["max_private_thread_participants"]
     current_participants = len(target.participants_list) - 1
 
     if current_participants >= max_participants:
@@ -231,42 +245,49 @@ can_add_participants = return_boolean(allow_add_participants)
 
 
 def allow_remove_participant(user_acl, thread, target):
-    if user_acl['can_moderate_private_threads']:
+    if user_acl["can_moderate_private_threads"]:
         return
 
     if user_acl["user_id"] == target.id:
         return  # we can always remove ourselves
 
     if thread.is_closed:
-        raise PermissionDenied(_("Only moderators can remove participants from closed threads."))
+        raise PermissionDenied(
+            _("Only moderators can remove participants from closed threads.")
+        )
 
     if not thread.participant or not thread.participant.is_owner:
-        raise PermissionDenied(_("You have to be thread owner to remove participants from it."))
+        raise PermissionDenied(
+            _("You have to be thread owner to remove participants from it.")
+        )
 
 
 can_remove_participant = return_boolean(allow_remove_participant)
 
 
 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_acl):
         raise PermissionDenied(
             _("%(user)s can't participate in private threads.") % message_format
         )
 
-    if user_acl['can_add_everyone_to_private_threads']:
+    if user_acl["can_add_everyone_to_private_threads"]:
         return
 
-    if user_acl['can_be_blocked'] and target.is_blocking(user_acl["user_id"]):
+    if user_acl["can_be_blocked"] and target.is_blocking(user_acl["user_id"]):
         raise PermissionDenied(_("%(user)s is blocking you.") % message_format)
 
     if target.can_be_messaged_by_nobody:
         raise PermissionDenied(
-            _("%(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_acl["user_id"]):
+    if target.can_be_messaged_by_followed and not target.is_following(
+        user_acl["user_id"]
+    ):
         message = _("%(user)s limits invitations to private threads to followed users.")
         raise PermissionDenied(message % message_format)
 

+ 528 - 472
misago/threads/permissions/threads.py

@@ -16,56 +16,56 @@ from misago.threads.models import Post, Thread
 
 
 __all__ = [
-    'allow_see_thread',
-    'can_see_thread',
-    'allow_start_thread',
-    'can_start_thread',
-    'allow_reply_thread',
-    'can_reply_thread',
-    'allow_edit_thread',
-    'can_edit_thread',
-    'allow_pin_thread',
-    'can_pin_thread',
-    'allow_unhide_thread',
-    'can_unhide_thread',
-    'allow_hide_thread',
-    'can_hide_thread',
-    'allow_delete_thread',
-    'can_delete_thread',
-    'allow_move_thread',
-    'can_move_thread',
-    'allow_merge_thread',
-    'can_merge_thread',
-    'allow_approve_thread',
-    'can_approve_thread',
-    'allow_see_post',
-    'can_see_post',
-    'allow_edit_post',
-    'can_edit_post',
-    'allow_unhide_post',
-    'can_unhide_post',
-    'allow_hide_post',
-    'can_hide_post',
-    'allow_delete_post',
-    'can_delete_post',
-    'allow_protect_post',
-    'can_protect_post',
-    'allow_approve_post',
-    'can_approve_post',
-    'allow_move_post',
-    'can_move_post',
-    'allow_merge_post',
-    'can_merge_post',
-    'allow_unhide_event',
-    'can_unhide_event',
-    'allow_split_post',
-    'can_split_post',
-    'allow_hide_event',
-    'can_hide_event',
-    'allow_delete_event',
-    'can_delete_event',
-    'exclude_invisible_threads',
-    'exclude_invisible_posts',
+    "allow_see_thread",
+    "can_see_thread",
+    "allow_start_thread",
+    "can_start_thread",
+    "allow_reply_thread",
+    "can_reply_thread",
+    "allow_edit_thread",
+    "can_edit_thread",
+    "allow_pin_thread",
+    "can_pin_thread",
+    "allow_unhide_thread",
+    "can_unhide_thread",
+    "allow_hide_thread",
+    "can_hide_thread",
+    "allow_delete_thread",
+    "can_delete_thread",
+    "allow_move_thread",
+    "can_move_thread",
+    "allow_merge_thread",
+    "can_merge_thread",
+    "allow_approve_thread",
+    "can_approve_thread",
+    "allow_see_post",
+    "can_see_post",
+    "allow_edit_post",
+    "can_edit_post",
+    "allow_unhide_post",
+    "can_unhide_post",
+    "allow_hide_post",
+    "can_hide_post",
+    "allow_delete_post",
+    "can_delete_post",
+    "allow_protect_post",
+    "can_protect_post",
+    "allow_approve_post",
+    "can_approve_post",
+    "allow_move_post",
+    "can_move_post",
+    "allow_merge_post",
+    "can_merge_post",
+    "allow_unhide_event",
+    "can_unhide_event",
+    "allow_split_post",
+    "can_split_post",
+    "allow_hide_event",
+    "can_hide_event",
+    "allow_delete_event",
+    "can_delete_event",
+    "exclude_invisible_threads",
+    "exclude_invisible_posts",
 ]
 
 
@@ -107,10 +107,7 @@ class CategoryPermissionsForm(forms.Form):
         label=_("Can see threads"),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("Started threads")),
-            (1, _("All threads")),
-        ],
+        choices=[(0, _("Started threads")), (1, _("All threads"))],
     )
 
     can_start_threads = YesNoSwitch(label=_("Can start threads"))
@@ -120,11 +117,7 @@ class CategoryPermissionsForm(forms.Form):
         label=_("Can edit threads"),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Own threads")),
-            (2, _("All threads")),
-        ],
+        choices=[(0, _("No")), (1, _("Own threads")), (2, _("All threads"))],
     )
     can_hide_own_threads = forms.TypedChoiceField(
         label=_("Can hide own threads"),
@@ -134,11 +127,7 @@ class CategoryPermissionsForm(forms.Form):
         ),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Hide threads")),
-            (2, _("Delete threads")),
-        ],
+        choices=[(0, _("No")), (1, _("Hide threads")), (2, _("Delete threads"))],
     )
     thread_edit_time = forms.IntegerField(
         label=_("Time limit for own threads edits, in minutes"),
@@ -150,22 +139,14 @@ class CategoryPermissionsForm(forms.Form):
         label=_("Can hide all threads"),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Hide threads")),
-            (2, _("Delete threads")),
-        ],
+        choices=[(0, _("No")), (1, _("Hide threads")), (2, _("Delete threads"))],
     )
 
     can_pin_threads = forms.TypedChoiceField(
         label=_("Can pin threads"),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Locally")),
-            (2, _("Globally")),
-        ],
+        choices=[(0, _("No")), (1, _("Locally")), (2, _("Globally"))],
     )
     can_close_threads = YesNoSwitch(label=_("Can close threads"))
     can_move_threads = YesNoSwitch(label=_("Can move threads"))
@@ -175,22 +156,16 @@ class CategoryPermissionsForm(forms.Form):
         label=_("Can edit posts"),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Own posts")),
-            (2, _("All posts")),
-        ],
+        choices=[(0, _("No")), (1, _("Own posts")), (2, _("All posts"))],
     )
     can_hide_own_posts = forms.TypedChoiceField(
         label=_("Can hide own posts"),
-        help_text=_("Only last posts to thread made within edit time limit can be hidden."),
+        help_text=_(
+            "Only last posts to thread made within edit time limit can be hidden."
+        ),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Hide posts")),
-            (2, _("Delete posts")),
-        ],
+        choices=[(0, _("No")), (1, _("Hide posts")), (2, _("Delete posts"))],
     )
     post_edit_time = forms.IntegerField(
         label=_("Time limit for own post edits, in minutes"),
@@ -202,11 +177,7 @@ class CategoryPermissionsForm(forms.Form):
         label=_("Can hide all posts"),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Hide posts")),
-            (2, _("Delete posts")),
-        ],
+        choices=[(0, _("No")), (1, _("Hide posts")), (2, _("Delete posts"))],
     )
 
     can_see_posts_likes = forms.TypedChoiceField(
@@ -244,11 +215,7 @@ class CategoryPermissionsForm(forms.Form):
         label=_("Can hide events"),
         coerce=int,
         initial=0,
-        choices=[
-            (0, _("No")),
-            (1, _("Hide events")),
-            (2, _("Delete events")),
-        ],
+        choices=[(0, _("No")), (1, _("Hide events")), (2, _("Delete events"))],
     )
 
     require_threads_approval = YesNoSwitch(label=_("Require threads approval"))
@@ -257,7 +224,7 @@ class CategoryPermissionsForm(forms.Form):
 
 
 def change_permissions_form(role):
-    if isinstance(role, Role) and role.special_role != 'anonymous':
+    if isinstance(role, Role) and role.special_role != "anonymous":
         return RolePermissionsForm
     elif isinstance(role, CategoryRole):
         return CategoryPermissionsForm
@@ -266,13 +233,15 @@ def change_permissions_form(role):
 
 
 def build_acl(acl, roles, key_name):
-    acl.update({
-        'can_see_unapproved_content_lists': False,
-        'can_see_reported_content_lists': False,
-        'can_omit_flood_protection': False,
-        'can_approve_content': [],
-        'can_see_reports': [],
-    })
+    acl.update(
+        {
+            "can_see_unapproved_content_lists": False,
+            "can_see_reported_content_lists": False,
+            "can_omit_flood_protection": False,
+            "can_approve_content": [],
+            "can_see_reports": [],
+        }
+    )
 
     acl = algebra.sum_acls(
         acl,
@@ -280,23 +249,23 @@ def build_acl(acl, roles, key_name):
         key=key_name,
         can_see_unapproved_content_lists=algebra.greater,
         can_see_reported_content_lists=algebra.greater,
-        can_omit_flood_protection=algebra.greater
+        can_omit_flood_protection=algebra.greater,
     )
 
     categories_roles = get_categories_roles(roles)
     categories = list(Category.objects.all_categories(include_root=True))
 
     for category in categories:
-        category_acl = acl['categories'].get(category.pk, {'can_browse': 0})
-        if category_acl['can_browse']:
-            category_acl = acl['categories'][category.pk] = build_category_acl(
+        category_acl = acl["categories"].get(category.pk, {"can_browse": 0})
+        if category_acl["can_browse"]:
+            category_acl = acl["categories"][category.pk] = build_category_acl(
                 category_acl, category, categories_roles, key_name
             )
 
-            if category_acl.get('can_approve_content'):
-                acl['can_approve_content'].append(category.pk)
-            if category_acl.get('can_see_reports'):
-                acl['can_see_reports'].append(category.pk)
+            if category_acl.get("can_approve_content"):
+                acl["can_approve_content"].append(category.pk)
+            if category_acl.get("can_see_reports"):
+                acl["can_see_reports"].append(category.pk)
 
     return acl
 
@@ -305,33 +274,33 @@ def build_category_acl(acl, category, categories_roles, key_name):
     category_roles = categories_roles.get(category.pk, [])
 
     final_acl = {
-        'can_see_all_threads': 0,
-        '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_report_content': 0,
-        'can_see_reports': 0,
-        'can_see_posts_likes': 0,
-        'can_like_posts': 0,
-        'can_approve_content': 0,
-        'require_threads_approval': 0,
-        'require_replies_approval': 0,
-        'require_edits_approval': 0,
-        'can_hide_events': 0,
+        "can_see_all_threads": 0,
+        "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_report_content": 0,
+        "can_see_reports": 0,
+        "can_see_posts_likes": 0,
+        "can_like_posts": 0,
+        "can_approve_content": 0,
+        "require_threads_approval": 0,
+        "require_replies_approval": 0,
+        "require_edits_approval": 0,
+        "can_hide_events": 0,
     }
     final_acl.update(acl)
 
@@ -372,38 +341,40 @@ def build_category_acl(acl, category, categories_roles, key_name):
 
 
 def add_acl_to_category(user_acl, category):
-    category_acl = user_acl['categories'].get(category.pk, {})
-
-    category.acl.update({
-        'can_see_all_threads': 0,
-        'can_see_own_threads': 0,
-        '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_report_content': 0,
-        'can_see_reports': 0,
-        'can_see_posts_likes': 0,
-        'can_like_posts': 0,
-        'can_approve_content': 0,
-        'require_threads_approval': category.require_threads_approval,
-        'require_replies_approval': category.require_replies_approval,
-        'require_edits_approval': category.require_edits_approval,
-        'can_hide_events': 0,
-    })
+    category_acl = user_acl["categories"].get(category.pk, {})
+
+    category.acl.update(
+        {
+            "can_see_all_threads": 0,
+            "can_see_own_threads": 0,
+            "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_report_content": 0,
+            "can_see_reports": 0,
+            "can_see_posts_likes": 0,
+            "can_like_posts": 0,
+            "can_approve_content": 0,
+            "require_threads_approval": category.require_threads_approval,
+            "require_replies_approval": category.require_replies_approval,
+            "require_edits_approval": category.require_edits_approval,
+            "can_hide_events": 0,
+        }
+    )
 
     algebra.sum_acls(
         category.acl,
@@ -443,38 +414,42 @@ def add_acl_to_category(user_acl, category):
             can_hide_events=algebra.greater,
         )
 
-    if user_acl['can_approve_content']:
-        category.acl.update({
-            'require_threads_approval': 0,
-            'require_replies_approval': 0,
-            'require_edits_approval': 0,
-        })
+    if user_acl["can_approve_content"]:
+        category.acl.update(
+            {
+                "require_threads_approval": 0,
+                "require_replies_approval": 0,
+                "require_edits_approval": 0,
+            }
+        )
 
-    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_acl, thread):
-    category_acl = user_acl['categories'].get(thread.category_id, {})
-
-    thread.acl.update({
-        'can_reply': can_reply_thread(user_acl, thread),
-        'can_edit': can_edit_thread(user_acl, thread),
-        'can_pin': can_pin_thread(user_acl, thread),
-        'can_pin_globally': False,
-        'can_hide': can_hide_thread(user_acl, thread),
-        'can_unhide': can_unhide_thread(user_acl, thread),
-        'can_delete': can_delete_thread(user_acl, thread),
-        'can_close': category_acl.get('can_close_threads', False),
-        'can_move': can_move_thread(user_acl, thread),
-        'can_merge': can_merge_thread(user_acl, thread),
-        'can_move_posts': category_acl.get('can_move_posts', False),
-        'can_merge_posts': category_acl.get('can_merge_posts', False),
-        'can_approve': can_approve_thread(user_acl, thread),
-        'can_see_reports': category_acl.get('can_see_reports', False),
-    })
-
-    if thread.acl['can_pin'] and category_acl.get('can_pin_threads') == 2:
-        thread.acl['can_pin_globally'] = True
+    category_acl = user_acl["categories"].get(thread.category_id, {})
+
+    thread.acl.update(
+        {
+            "can_reply": can_reply_thread(user_acl, thread),
+            "can_edit": can_edit_thread(user_acl, thread),
+            "can_pin": can_pin_thread(user_acl, thread),
+            "can_pin_globally": False,
+            "can_hide": can_hide_thread(user_acl, thread),
+            "can_unhide": can_unhide_thread(user_acl, thread),
+            "can_delete": can_delete_thread(user_acl, thread),
+            "can_close": category_acl.get("can_close_threads", False),
+            "can_move": can_move_thread(user_acl, thread),
+            "can_merge": can_merge_thread(user_acl, thread),
+            "can_move_posts": category_acl.get("can_move_posts", False),
+            "can_merge_posts": category_acl.get("can_merge_posts", False),
+            "can_approve": can_approve_thread(user_acl, thread),
+            "can_see_reports": category_acl.get("can_see_reports", False),
+        }
+    )
+
+    if thread.acl["can_pin"] and category_acl.get("can_pin_threads") == 2:
+        thread.acl["can_pin_globally"] = True
 
 
 def add_acl_to_post(user_acl, post):
@@ -488,45 +463,47 @@ def add_acl_to_event(user_acl, event):
     can_hide_events = 0
 
     if user_acl["is_authenticated"]:
-        category_acl = user_acl['categories'].get(
-            event.category_id, {
-                'can_hide_events': 0,
-            }
+        category_acl = user_acl["categories"].get(
+            event.category_id, {"can_hide_events": 0}
         )
 
-        can_hide_events = category_acl['can_hide_events']
+        can_hide_events = category_acl["can_hide_events"]
 
-    event.acl.update({
-        'can_see_hidden': can_hide_events > 0,
-        'can_hide': can_hide_event(user_acl, event),
-        'can_delete': can_delete_event(user_acl, event),
-    })
+    event.acl.update(
+        {
+            "can_see_hidden": can_hide_events > 0,
+            "can_hide": can_hide_event(user_acl, event),
+            "can_delete": can_delete_event(user_acl, event),
+        }
+    )
 
 
 def add_acl_to_reply(user_acl, post):
-    category_acl = user_acl['categories'].get(post.category_id, {})
-
-    post.acl.update({
-        'can_reply': can_reply_thread(user_acl, post.thread),
-        'can_edit': can_edit_post(user_acl, post),
-        'can_see_hidden': post.is_first_post or category_acl.get('can_hide_posts'),
-        'can_unhide': can_unhide_post(user_acl, post),
-        'can_hide': can_hide_post(user_acl, post),
-        'can_delete': can_delete_post(user_acl, post),
-        'can_protect': can_protect_post(user_acl, post),
-        'can_approve': can_approve_post(user_acl, post),
-        'can_move': can_move_post(user_acl, post),
-        'can_merge': can_merge_post(user_acl, post),
-        'can_report': category_acl.get('can_report_content', False),
-        'can_see_reports': category_acl.get('can_see_reports', False),
-        'can_see_likes': category_acl.get('can_see_posts_likes', 0),
-        'can_like': False,
-    })
-
-    if not post.acl['can_see_hidden']:
-        post.acl['can_see_hidden'] = post.id == post.thread.first_post_id
-    if user_acl["is_authenticated"] and post.acl['can_see_likes']:
-        post.acl['can_like'] = category_acl.get('can_like_posts', False)
+    category_acl = user_acl["categories"].get(post.category_id, {})
+
+    post.acl.update(
+        {
+            "can_reply": can_reply_thread(user_acl, post.thread),
+            "can_edit": can_edit_post(user_acl, post),
+            "can_see_hidden": post.is_first_post or category_acl.get("can_hide_posts"),
+            "can_unhide": can_unhide_post(user_acl, post),
+            "can_hide": can_hide_post(user_acl, post),
+            "can_delete": can_delete_post(user_acl, post),
+            "can_protect": can_protect_post(user_acl, post),
+            "can_approve": can_approve_post(user_acl, post),
+            "can_move": can_move_post(user_acl, post),
+            "can_merge": can_merge_post(user_acl, post),
+            "can_report": category_acl.get("can_report_content", False),
+            "can_see_reports": category_acl.get("can_see_reports", False),
+            "can_see_likes": category_acl.get("can_see_posts_likes", 0),
+            "can_like": False,
+        }
+    )
+
+    if not post.acl["can_see_hidden"]:
+        post.acl["can_see_hidden"] = post.id == post.thread.first_post_id
+    if user_acl["is_authenticated"] and post.acl["can_see_likes"]:
+        post.acl["can_like"] = category_acl.get("can_like_posts", False)
 
 
 def register_with(registry):
@@ -536,24 +513,23 @@ def register_with(registry):
 
 
 def allow_see_thread(user_acl, target):
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_see': False,
-            'can_browse': False,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_see": False, "can_browse": False}
     )
 
-    if not (category_acl['can_see'] and category_acl['can_browse']):
+    if not (category_acl["can_see"] and category_acl["can_browse"]):
         raise Http404()
 
-    if target.is_hidden and (user_acl["is_anonymous"] or not category_acl['can_hide_threads']):
+    if target.is_hidden and (
+        user_acl["is_anonymous"] or not category_acl["can_hide_threads"]
+    ):
         raise Http404()
 
     if user_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()
 
-        if target.is_unapproved and not category_acl['can_approve_content']:
+        if target.is_unapproved and not category_acl["can_approve_content"]:
             raise Http404()
 
 
@@ -564,19 +540,17 @@ def allow_start_thread(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to start threads."))
 
-    category_acl = user_acl['categories'].get(
-        target.pk, {
-            'can_start_threads': False,
-        }
-    )
+    category_acl = user_acl["categories"].get(target.pk, {"can_start_threads": False})
 
-    if not category_acl['can_start_threads']:
+    if not category_acl["can_start_threads"]:
         raise PermissionDenied(
             _("You don't have permission to start new threads in this category.")
         )
 
-    if target.is_closed and not category_acl['can_close_threads']:
-        raise PermissionDenied(_("This category is closed. You can't start new threads in it."))
+    if target.is_closed and not category_acl["can_close_threads"]:
+        raise PermissionDenied(
+            _("This category is closed. You can't start new threads in it.")
+        )
 
 
 can_start_thread = return_boolean(allow_start_thread)
@@ -586,20 +560,22 @@ def allow_reply_thread(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to reply threads."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_reply_threads': False,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_reply_threads": False}
     )
 
-    if not category_acl['can_reply_threads']:
+    if not category_acl["can_reply_threads"]:
         raise PermissionDenied(_("You can't reply to threads in this category."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't reply to threads in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't reply to threads in it.")
+            )
         if target.is_closed:
-            raise PermissionDenied(_("You can't reply to closed threads in this category."))
+            raise PermissionDenied(
+                _("You can't reply to closed threads in this category.")
+            )
 
 
 can_reply_thread = return_boolean(allow_reply_thread)
@@ -609,30 +585,34 @@ def allow_edit_thread(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to edit threads."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_edit_threads': False,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_edit_threads": False}
     )
 
-    if not category_acl['can_edit_threads']:
+    if not category_acl["can_edit_threads"]:
         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 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_acl, target):
             message = ngettext(
                 "You can't edit threads that are older than %(minutes)s minute.",
                 "You can't edit threads that are older than %(minutes)s minutes.",
-                category_acl['thread_edit_time']
+                category_acl["thread_edit_time"],
+            )
+            raise PermissionDenied(
+                message % {"minutes": category_acl["thread_edit_time"]}
             )
-            raise PermissionDenied(message % {'minutes': category_acl['thread_edit_time']})
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't edit threads in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't edit threads in it.")
+            )
         if target.is_closed:
             raise PermissionDenied(_("This thread is closed. You can't edit it."))
 
@@ -644,20 +624,22 @@ def allow_pin_thread(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to change threads weights."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_pin_threads': 0,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_pin_threads": 0}
     )
 
-    if not category_acl['can_pin_threads']:
+    if not category_acl["can_pin_threads"]:
         raise PermissionDenied(_("You can't change threads weights in this category."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't change threads weights in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't change threads weights in it.")
+            )
         if target.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't change its weight."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't change its weight.")
+            )
 
 
 can_pin_thread = return_boolean(allow_pin_thread)
@@ -667,15 +649,15 @@ def allow_unhide_thread(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to hide threads."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_close_threads': False,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_close_threads": False}
     )
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't reveal threads in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't reveal threads in it.")
+            )
         if target.is_closed:
             raise PermissionDenied(_("This thread is closed. You can't reveal it."))
 
@@ -687,31 +669,37 @@ def allow_hide_thread(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to hide threads."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_hide_threads': 0,
-            'can_hide_own_threads': 0,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_hide_threads": 0, "can_hide_own_threads": 0}
     )
 
-    if not category_acl['can_hide_threads'] and not category_acl['can_hide_own_threads']:
+    if (
+        not category_acl["can_hide_threads"]
+        and not category_acl["can_hide_own_threads"]
+    ):
         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_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_acl, target):
             message = ngettext(
                 "You can't hide threads that are older than %(minutes)s minute.",
                 "You can't hide threads that are older than %(minutes)s minutes.",
-                category_acl['thread_edit_time'],
+                category_acl["thread_edit_time"],
+            )
+            raise PermissionDenied(
+                message % {"minutes": category_acl["thread_edit_time"]}
             )
-            raise PermissionDenied(message % {'minutes': category_acl['thread_edit_time']})
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't hide threads in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't hide threads in it.")
+            )
         if target.is_closed:
             raise PermissionDenied(_("This thread is closed. You can't hide it."))
 
@@ -723,31 +711,40 @@ def allow_delete_thread(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to delete threads."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_hide_threads': 0,
-            'can_hide_own_threads': 0,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_hide_threads": 0, "can_hide_own_threads": 0}
     )
 
-    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
+    ):
         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_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_acl, target):
             message = ngettext(
                 "You can't delete threads that are older than %(minutes)s minute.",
                 "You can't delete threads that are older than %(minutes)s minutes.",
-                category_acl['thread_edit_time'],
+                category_acl["thread_edit_time"],
+            )
+            raise PermissionDenied(
+                message % {"minutes": category_acl["thread_edit_time"]}
             )
-            raise PermissionDenied(message % {'minutes': category_acl['thread_edit_time']})
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't delete threads in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't delete threads in it.")
+            )
         if target.is_closed:
             raise PermissionDenied(_("This thread is closed. You can't delete it."))
 
@@ -759,18 +756,18 @@ def allow_move_thread(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to move threads."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_move_threads': 0,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_move_threads": 0}
     )
 
-    if not category_acl['can_move_threads']:
+    if not category_acl["can_move_threads"]:
         raise PermissionDenied(_("You can't move threads in this category."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't move it's threads."))
+            raise PermissionDenied(
+                _("This category is closed. You can't move it's threads.")
+            )
         if target.is_closed:
             raise PermissionDenied(_("This thread is closed. You can't move it."))
 
@@ -782,26 +779,32 @@ def allow_merge_thread(user_acl, target, otherthread=False):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to merge threads."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_merge_threads': 0,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_merge_threads": 0}
     )
 
-    if not category_acl['can_merge_threads']:
+    if not category_acl["can_merge_threads"]:
         if otherthread:
             raise PermissionDenied(_("Other thread can't be merged with."))
         raise PermissionDenied(_("You can't merge threads in this category."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
             if otherthread:
-                raise PermissionDenied(_("Other thread's category is closed. You can't merge with it."))
-            raise PermissionDenied(_("This category is closed. You can't merge it's threads."))
+                raise PermissionDenied(
+                    _("Other thread's category is closed. You can't merge with it.")
+                )
+            raise PermissionDenied(
+                _("This category is closed. You can't merge it's threads.")
+            )
         if target.is_closed:
             if otherthread:
-                raise PermissionDenied(_("Other thread is closed and can't be merged with."))
-            raise PermissionDenied(_("This thread is closed. You can't merge it with other threads."))
+                raise PermissionDenied(
+                    _("Other thread is closed and can't be merged with.")
+                )
+            raise PermissionDenied(
+                _("This thread is closed. You can't merge it with other threads.")
+            )
 
 
 can_merge_thread = return_boolean(allow_merge_thread)
@@ -811,18 +814,18 @@ def allow_approve_thread(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to approve threads."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_approve_content': 0,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_approve_content": 0}
     )
 
-    if not category_acl['can_approve_content']:
+    if not category_acl["can_approve_content"]:
         raise PermissionDenied(_("You can't approve threads in this category."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't approve threads in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't approve threads in it.")
+            )
         if target.is_closed:
             raise PermissionDenied(_("This thread is closed. You can't approve it."))
 
@@ -831,21 +834,21 @@ can_approve_thread = return_boolean(allow_approve_thread)
 
 
 def allow_see_post(user_acl, target):
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_approve_content': False,
-            'can_hide_events': False,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_approve_content": False, "can_hide_events": False}
     )
 
     if not target.is_event and target.is_unapproved:
         if user_acl["is_anonymous"]:
             raise Http404()
 
-        if not category_acl['can_approve_content'] and user_acl["user_id"] != target.poster_id:
+        if (
+            not category_acl["can_approve_content"]
+            and user_acl["user_id"] != target.poster_id
+        ):
             raise Http404()
 
-    if target.is_event and target.is_hidden and not category_acl['can_hide_events']:
+    if target.is_event and target.is_hidden and not category_acl["can_hide_events"]:
         raise Http404()
 
 
@@ -859,34 +862,48 @@ def allow_edit_post(user_acl, target):
     if target.is_event:
         raise PermissionDenied(_("Events can't be edited."))
 
-    category_acl = user_acl['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."))
 
-    if target.is_hidden and not target.is_first_post and not category_acl['can_hide_posts']:
+    if (
+        target.is_hidden
+        and not target.is_first_post
+        and not category_acl["can_hide_posts"]
+    ):
         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_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."))
 
         if not has_time_to_edit_post(user_acl, target):
             message = ngettext(
                 "You can't edit posts that are older than %(minutes)s minute.",
                 "You can't edit posts that are older than %(minutes)s minutes.",
-                category_acl['post_edit_time'],
+                category_acl["post_edit_time"],
+            )
+            raise PermissionDenied(
+                message % {"minutes": category_acl["post_edit_time"]}
             )
-            raise PermissionDenied(message % {'minutes': category_acl['post_edit_time']})
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't edit posts in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't edit posts in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't edit posts in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't edit posts in it.")
+            )
 
 
 can_edit_post = return_boolean(allow_edit_post)
@@ -896,39 +913,44 @@ def allow_unhide_post(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to reveal posts."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_hide_posts': 0,
-            'can_hide_own_posts': 0,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_hide_posts": 0, "can_hide_own_posts": 0}
     )
 
-    if not category_acl['can_hide_posts']:
-        if not category_acl['can_hide_own_posts']:
+    if not category_acl["can_hide_posts"]:
+        if not category_acl["can_hide_own_posts"]:
             raise PermissionDenied(_("You can't reveal posts in this category."))
 
         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."))
 
         if not has_time_to_edit_post(user_acl, target):
             message = ngettext(
                 "You can't reveal posts that are older than %(minutes)s minute.",
                 "You can't reveal posts that are older than %(minutes)s minutes.",
-                category_acl['post_edit_time'],
+                category_acl["post_edit_time"],
+            )
+            raise PermissionDenied(
+                message % {"minutes": category_acl["post_edit_time"]}
             )
-            raise PermissionDenied(message % {'minutes': category_acl['post_edit_time']})
 
     if target.is_first_post:
         raise PermissionDenied(_("You can't reveal thread's first post."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't reveal posts in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't reveal posts in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't reveal posts in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't reveal posts in it.")
+            )
 
 
 can_unhide_post = return_boolean(allow_unhide_post)
@@ -938,39 +960,44 @@ def allow_hide_post(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to hide posts."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_hide_posts': 0,
-            'can_hide_own_posts': 0,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_hide_posts": 0, "can_hide_own_posts": 0}
     )
 
-    if not category_acl['can_hide_posts']:
-        if not category_acl['can_hide_own_posts']:
+    if not category_acl["can_hide_posts"]:
+        if not category_acl["can_hide_own_posts"]:
             raise PermissionDenied(_("You can't hide posts in this category."))
 
         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."))
 
         if not has_time_to_edit_post(user_acl, target):
             message = ngettext(
                 "You can't hide posts that are older than %(minutes)s minute.",
                 "You can't hide posts that are older than %(minutes)s minutes.",
-                category_acl['post_edit_time'],
+                category_acl["post_edit_time"],
+            )
+            raise PermissionDenied(
+                message % {"minutes": category_acl["post_edit_time"]}
             )
-            raise PermissionDenied(message % {'minutes': category_acl['post_edit_time']})
 
     if target.is_first_post:
         raise PermissionDenied(_("You can't hide thread's first post."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't hide posts in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't hide posts in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't hide posts in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't hide posts in it.")
+            )
 
 
 can_hide_post = return_boolean(allow_hide_post)
@@ -980,39 +1007,44 @@ def allow_delete_post(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to delete posts."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_hide_posts': 0,
-            'can_hide_own_posts': 0,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_hide_posts": 0, "can_hide_own_posts": 0}
     )
 
-    if category_acl['can_hide_posts'] != 2:
-        if category_acl['can_hide_own_posts'] != 2:
+    if category_acl["can_hide_posts"] != 2:
+        if category_acl["can_hide_own_posts"] != 2:
             raise PermissionDenied(_("You can't delete posts in this category."))
 
         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."))
 
         if not has_time_to_edit_post(user_acl, target):
             message = ngettext(
                 "You can't delete posts that are older than %(minutes)s minute.",
                 "You can't delete posts that are older than %(minutes)s minutes.",
-                category_acl['post_edit_time'],
+                category_acl["post_edit_time"],
+            )
+            raise PermissionDenied(
+                message % {"minutes": category_acl["post_edit_time"]}
             )
-            raise PermissionDenied(message % {'minutes': category_acl['post_edit_time']})
 
     if target.is_first_post:
         raise PermissionDenied(_("You can't delete thread's first post."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't delete posts in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't delete posts in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't delete posts in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't delete posts in it.")
+            )
 
 
 can_delete_post = return_boolean(allow_delete_post)
@@ -1022,11 +1054,11 @@ def allow_protect_post(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to protect posts."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {'can_protect_posts': False}
+    category_acl = user_acl["categories"].get(
+        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."))
     if not can_edit_post(user_acl, target):
         raise PermissionDenied(_("You can't protect posts you can't edit."))
@@ -1039,22 +1071,30 @@ def allow_approve_post(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to approve posts."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {'can_approve_content': False}
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_approve_content": False}
     )
 
-    if not category_acl['can_approve_content']:
+    if not category_acl["can_approve_content"]:
         raise PermissionDenied(_("You can't approve posts in this category."))
     if target.is_first_post:
         raise PermissionDenied(_("You can't approve thread's first post."))
-    if not target.is_first_post and not category_acl['can_hide_posts'] and target.is_hidden:
+    if (
+        not target.is_first_post
+        and not category_acl["can_hide_posts"]
+        and target.is_hidden
+    ):
         raise PermissionDenied(_("You can't approve posts the content you can't see."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't approve posts in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't approve posts in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't approve posts in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't approve posts in it.")
+            )
 
 
 can_approve_post = return_boolean(allow_approve_post)
@@ -1064,26 +1104,28 @@ def allow_move_post(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to move posts."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_move_posts': False,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_move_posts": False}
     )
 
-    if not category_acl['can_move_posts']:
+    if not category_acl["can_move_posts"]:
         raise PermissionDenied(_("You can't move posts in this category."))
     if target.is_event:
         raise PermissionDenied(_("Events can't be moved."))
     if target.is_first_post:
         raise PermissionDenied(_("You can't move thread's first post."))
-    if not category_acl['can_hide_posts'] and target.is_hidden:
+    if not category_acl["can_hide_posts"] and target.is_hidden:
         raise PermissionDenied(_("You can't move posts the content you can't see."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't move posts in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't move posts in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't move posts in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't move posts in it.")
+            )
 
 
 can_move_post = return_boolean(allow_move_post)
@@ -1093,24 +1135,30 @@ def allow_merge_post(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to merge posts."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_merge_posts': False,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_merge_posts": False}
     )
 
-    if not category_acl['can_merge_posts']:
+    if not category_acl["can_merge_posts"]:
         raise PermissionDenied(_("You can't merge posts in this category."))
     if target.is_event:
         raise PermissionDenied(_("Events can't be merged."))
-    if target.is_hidden and not category_acl['can_hide_posts'] and not target.is_first_post:
+    if (
+        target.is_hidden
+        and not category_acl["can_hide_posts"]
+        and not target.is_first_post
+    ):
         raise PermissionDenied(_("You can't merge posts the content you can't see."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't merge posts in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't merge posts in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't merge posts in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't merge posts in it.")
+            )
 
 
 can_merge_post = return_boolean(allow_merge_post)
@@ -1120,26 +1168,29 @@ def allow_split_post(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to split posts."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_move_posts': False,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_move_posts": False}
     )
 
-    if not category_acl['can_move_posts']:
+    if not category_acl["can_move_posts"]:
         raise PermissionDenied(_("You can't split posts in this category."))
     if target.is_event:
         raise PermissionDenied(_("Events can't be split."))
     if target.is_first_post:
         raise PermissionDenied(_("You can't split thread's first post."))
-    if not category_acl['can_hide_posts'] and target.is_hidden:
+    if not category_acl["can_hide_posts"] and target.is_hidden:
         raise PermissionDenied(_("You can't split posts the content you can't see."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't split posts in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't split posts in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't split posts in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't split posts in it.")
+            )
+
 
 can_split_post = return_boolean(allow_split_post)
 
@@ -1148,20 +1199,22 @@ def allow_unhide_event(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to reveal events."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_hide_events': 0,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_hide_events": 0}
     )
 
-    if not category_acl['can_hide_events']:
+    if not category_acl["can_hide_events"]:
         raise PermissionDenied(_("You can't reveal events in this category."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't reveal events in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't reveal events in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't reveal events in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't reveal events in it.")
+            )
 
 
 can_unhide_event = return_boolean(allow_unhide_event)
@@ -1171,20 +1224,22 @@ def allow_hide_event(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to hide events."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_hide_events': 0,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_hide_events": 0}
     )
 
-    if not category_acl['can_hide_events']:
+    if not category_acl["can_hide_events"]:
         raise PermissionDenied(_("You can't hide events in this category."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't hide events in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't hide events in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't hide events in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't hide events in it.")
+            )
 
 
 can_hide_event = return_boolean(allow_hide_event)
@@ -1194,20 +1249,22 @@ def allow_delete_event(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to delete events."))
 
-    category_acl = user_acl['categories'].get(
-        target.category_id, {
-            'can_hide_events': 0,
-        }
+    category_acl = user_acl["categories"].get(
+        target.category_id, {"can_hide_events": 0}
     )
 
-    if category_acl['can_hide_events'] != 2:
+    if category_acl["can_hide_events"] != 2:
         raise PermissionDenied(_("You can't delete events in this category."))
 
-    if not category_acl['can_close_threads']:
+    if not category_acl["can_close_threads"]:
         if target.category.is_closed:
-            raise PermissionDenied(_("This category is closed. You can't delete events in it."))
+            raise PermissionDenied(
+                _("This category is closed. You can't delete events in it.")
+            )
         if target.thread.is_closed:
-            raise PermissionDenied(_("This thread is closed. You can't delete events in it."))
+            raise PermissionDenied(
+                _("This thread is closed. You can't delete events in it.")
+            )
 
 
 can_delete_event = return_boolean(allow_delete_event)
@@ -1224,7 +1281,9 @@ def can_change_owned_thread(user_acl, target):
 
 
 def has_time_to_edit_thread(user_acl, target):
-    edit_time = user_acl['categories'].get(target.category_id, {}).get('thread_edit_time', 0)
+    edit_time = (
+        user_acl["categories"].get(target.category_id, {}).get("thread_edit_time", 0)
+    )
     if edit_time:
         diff = timezone.now() - target.started_on
         diff_minutes = int(diff.total_seconds() / 60)
@@ -1234,7 +1293,9 @@ def has_time_to_edit_thread(user_acl, target):
 
 
 def has_time_to_edit_post(user_acl, target):
-    edit_time = user_acl['categories'].get(target.category_id, {}).get('post_edit_time', 0)
+    edit_time = (
+        user_acl["categories"].get(target.category_id, {}).get("post_edit_time", 0)
+    )
     if edit_time:
         diff = timezone.now() - target.posted_on
         diff_minutes = int(diff.total_seconds() / 60)
@@ -1254,12 +1315,12 @@ def exclude_invisible_threads(user_acl, categories, queryset):
     for category in categories:
         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
 
-        can_hide = category.acl['can_hide_threads']
-        if category.acl['can_see_all_threads']:
-            can_mod = category.acl['can_approve_content']
+        can_hide = category.acl["can_hide_threads"]
+        if category.acl["can_see_all_threads"]:
+            can_mod = category.acl["can_approve_content"]
 
             if can_mod and can_hide:
                 show_all.append(category)
@@ -1291,9 +1352,7 @@ def exclude_invisible_threads(user_acl, categories, queryset):
             )
         else:
             condition = Q(
-                category__in=show_accepted_visible,
-                is_hidden=False,
-                is_unapproved=False,
+                category__in=show_accepted_visible, is_hidden=False, is_unapproved=False
             )
 
         if conditions:
@@ -1347,7 +1406,7 @@ def exclude_invisible_threads(user_acl, 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_acl, categories, queryset)
     else:
         return exclude_invisible_posts_in_category(user_acl, categories, queryset)
@@ -1363,7 +1422,7 @@ def exclude_invisible_posts_in_categories(user_acl, categories, queryset):
     for category in categories:
         add_acl_to_obj(user_acl, category)
 
-        if category.acl['can_approve_content']:
+        if category.acl["can_approve_content"]:
             show_all.append(category.pk)
         else:
             if user_acl["is_authenticated"]:
@@ -1371,7 +1430,7 @@ def exclude_invisible_posts_in_categories(user_acl, categories, queryset):
             else:
                 show_approved.append(category.pk)
 
-        if not category.acl['can_hide_events']:
+        if not category.acl["can_hide_events"]:
             hide_invisible_events.append(category.pk)
 
     conditions = None
@@ -1379,10 +1438,7 @@ def exclude_invisible_posts_in_categories(user_acl, categories, queryset):
         conditions = Q(category__in=show_all)
 
     if show_approved:
-        condition = Q(
-            category__in=show_approved,
-            is_unapproved=False,
-        )
+        condition = Q(category__in=show_approved, is_unapproved=False)
 
         if conditions:
             conditions = conditions | condition
@@ -1402,9 +1458,7 @@ def exclude_invisible_posts_in_categories(user_acl, categories, queryset):
 
     if hide_invisible_events:
         queryset = queryset.exclude(
-            category__in=hide_invisible_events,
-            is_event=True,
-            is_hidden=True,
+            category__in=hide_invisible_events, is_event=True, is_hidden=True
         )
 
     if conditions:
@@ -1416,13 +1470,15 @@ def exclude_invisible_posts_in_categories(user_acl, categories, queryset):
 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_acl["is_authenticated"]:
-            queryset = queryset.filter(Q(is_unapproved=False) | Q(poster_id=user_acl["user_id"]))
+            queryset = queryset.filter(
+                Q(is_unapproved=False) | Q(poster_id=user_acl["user_id"])
+            )
         else:
             queryset = queryset.exclude(is_unapproved=True)
 
-    if not category.acl['can_hide_events']:
+    if not category.acl["can_hide_events"]:
         queryset = queryset.exclude(is_event=True, is_hidden=True)
 
     return queryset

+ 22 - 22
misago/threads/search.py

@@ -18,8 +18,8 @@ HITS_CEILING = settings.MISAGO_POSTS_PER_PAGE * 5
 
 class SearchThreads(SearchProvider):
     name = _("Threads")
-    icon = 'forum'
-    url = 'threads'
+    icon = "forum"
+    url = "threads"
 
     def search(self, query, page=1):
         root_category = ThreadsRootCategory(self.request)
@@ -44,21 +44,23 @@ class SearchThreads(SearchProvider):
 
         posts = []
         threads = []
-        if paginator['count']:
-            posts = list(list_page.object_list.select_related(
-                'thread', 'poster', 'poster__rank'
-            ))
+        if paginator["count"]:
+            posts = list(
+                list_page.object_list.select_related("thread", "poster", "poster__rank")
+            )
 
             threads = []
             for post in posts:
                 threads.append(post.thread)
 
-            add_categories_to_items(root_category.unwrap(), threads_categories, posts + threads)
+            add_categories_to_items(
+                root_category.unwrap(), threads_categories, posts + threads
+            )
 
         results = {
-            'results': FeedSerializer(posts, many=True, context={
-                'user': self.request.user,
-            }).data,
+            "results": FeedSerializer(
+                posts, many=True, context={"user": self.request.user}
+            ).data
         }
         results.update(paginator)
 
@@ -67,27 +69,25 @@ class SearchThreads(SearchProvider):
 
 def search_threads(request, query, visible_threads):
     search_query = SearchQuery(
-        filter_search(query),
-        config=settings.MISAGO_SEARCH_CONFIG,
+        filter_search(query), config=settings.MISAGO_SEARCH_CONFIG
     )
     search_vector = SearchVector(
-        'search_document',
-        config=settings.MISAGO_SEARCH_CONFIG,
+        "search_document", config=settings.MISAGO_SEARCH_CONFIG
     )
 
     queryset = Post.objects.filter(
         is_event=False,
         is_hidden=False,
         is_unapproved=False,
-        thread_id__in=visible_threads.values('id'),
+        thread_id__in=visible_threads.values("id"),
         search_vector=search_query,
     )
 
-    if queryset[:HITS_CEILING + 1].count() > HITS_CEILING:
-        queryset = queryset.order_by('-id')[:HITS_CEILING]
+    if queryset[: HITS_CEILING + 1].count() > HITS_CEILING:
+        queryset = queryset.order_by("-id")[:HITS_CEILING]
 
-    return Post.objects.filter(
-        id__in=queryset.values('id'),
-    ).annotate(
-        rank=SearchRank(search_vector, search_query),
-    ).order_by('-rank', '-id')
+    return (
+        Post.objects.filter(id__in=queryset.values("id"))
+        .annotate(rank=SearchRank(search_vector, search_query))
+        .order_by("-rank", "-id")
+    )

+ 15 - 18
misago/threads/serializers/attachment.py

@@ -5,7 +5,7 @@ from django.urls import reverse
 from misago.threads.models import Attachment
 
 
-__all__ = ['AttachmentSerializer']
+__all__ = ["AttachmentSerializer"]
 
 
 class AttachmentSerializer(serializers.ModelSerializer):
@@ -20,16 +20,16 @@ class AttachmentSerializer(serializers.ModelSerializer):
     class Meta:
         model = Attachment
         fields = [
-            'id',
-            'filetype',
-            'post',
-            'uploaded_on',
-            'uploader_name',
-            'filename',
-            'size',
-            'acl',
-            'is_image',
-            'url',
+            "id",
+            "filetype",
+            "post",
+            "uploaded_on",
+            "uploader_name",
+            "filename",
+            "size",
+            "acl",
+            "is_image",
+            "url",
         ]
 
     def get_acl(self, obj):
@@ -46,18 +46,15 @@ class AttachmentSerializer(serializers.ModelSerializer):
 
     def get_url(self, obj):
         return {
-            'index': obj.get_absolute_url(),
-            'thumb': obj.get_thumbnail_url(),
-            'uploader': self.get_uploader_url(obj),
+            "index": obj.get_absolute_url(),
+            "thumb": obj.get_thumbnail_url(),
+            "uploader": self.get_uploader_url(obj),
         }
 
     def get_uploader_url(self, obj):
         if obj.uploader_id:
             return reverse(
-                'misago:user', kwargs={
-                    'slug': obj.uploader_slug,
-                    'pk': obj.uploader_id,
-                }
+                "misago:user", kwargs={"slug": obj.uploader_slug, "pk": obj.uploader_id}
             )
         else:
             return None

+ 6 - 16
misago/threads/serializers/feed.py

@@ -8,20 +8,13 @@ from misago.users.serializers import UserSerializer
 from .post import PostSerializer
 
 
-__all__ = [
-    'FeedSerializer',
-]
+__all__ = ["FeedSerializer"]
 
 FeedUserSerializer = UserSerializer.subset_fields(
-    'id',
-    'username',
-    'avatars',
-    'url',
-    'title',
-    'rank',
+    "id", "username", "avatars", "url", "title", "rank"
 )
 
-FeedCategorySerializer = CategorySerializer.subset_fields('name', 'css_class', 'url')
+FeedCategorySerializer = CategorySerializer.subset_fields("name", "css_class", "url")
 
 
 class FeedSerializer(PostSerializer, MutableFields):
@@ -32,13 +25,10 @@ class FeedSerializer(PostSerializer, MutableFields):
 
     class Meta:
         model = Post
-        fields = PostSerializer.Meta.fields + ['category', 'thread']
+        fields = PostSerializer.Meta.fields + ["category", "thread"]
 
     def get_thread(self, obj):
-        return {
-            'title': obj.thread.title,
-            'url': obj.thread.get_absolute_url(),
-        }
+        return {"title": obj.thread.title, "url": obj.thread.get_absolute_url()}
 
 
-FeedSerializer = FeedSerializer.exclude_fields('is_liked', 'is_new', 'is_read')
+FeedSerializer = FeedSerializer.exclude_fields("is_liked", "is_new", "is_read")

+ 177 - 140
misago/threads/serializers/moderation.py

@@ -10,11 +10,19 @@ from misago.conf import settings
 from misago.threads.mergeconflict import MergeConflict
 from misago.threads.models import Thread
 from misago.threads.permissions import (
-    allow_delete_best_answer, allow_delete_event, allow_delete_post, allow_delete_thread,
-    allow_merge_post, allow_merge_thread,
-    allow_move_post, allow_split_post,
-    can_reply_thread, can_see_thread,
-    can_start_thread, exclude_invisible_posts)
+    allow_delete_best_answer,
+    allow_delete_event,
+    allow_delete_post,
+    allow_delete_thread,
+    allow_merge_post,
+    allow_merge_thread,
+    allow_move_post,
+    allow_split_post,
+    can_reply_thread,
+    can_see_thread,
+    can_start_thread,
+    exclude_invisible_posts,
+)
 from misago.threads.threadtypes import trees_map
 from misago.threads.utils import get_thread_id_from_url
 from misago.threads.validators import validate_category, validate_thread_title
@@ -25,31 +33,33 @@ THREADS_LIMIT = settings.MISAGO_THREADS_PER_PAGE + settings.MISAGO_THREADS_TAIL
 
 
 __all__ = [
-    'DeletePostsSerializer',
-    'DeleteThreadsSerializer',
-    'MergePostsSerializer',
-    'MergeThreadSerializer',
-    'MergeThreadsSerializer',
-    'MovePostsSerializer',
-    'NewThreadSerializer',
-    'SplitPostsSerializer',
+    "DeletePostsSerializer",
+    "DeleteThreadsSerializer",
+    "MergePostsSerializer",
+    "MergeThreadSerializer",
+    "MergeThreadsSerializer",
+    "MovePostsSerializer",
+    "NewThreadSerializer",
+    "SplitPostsSerializer",
 ]
 
 
 class DeletePostsSerializer(serializers.Serializer):
-    error_empty_or_required = gettext_lazy("You have to specify at least one post to delete.")
+    error_empty_or_required = gettext_lazy(
+        "You have to specify at least one post to delete."
+    )
 
     posts = serializers.ListField(
         allow_empty=False,
         child=serializers.IntegerField(
             error_messages={
-                'invalid': gettext_lazy("One or more post ids received were invalid."),
-            },
+                "invalid": gettext_lazy("One or more post ids received were invalid.")
+            }
         ),
         error_messages={
-            'required': error_empty_or_required,
-            'null': error_empty_or_required,
-            'empty': error_empty_or_required,
+            "required": error_empty_or_required,
+            "null": error_empty_or_required,
+            "empty": error_empty_or_required,
         },
     )
 
@@ -60,13 +70,15 @@ class DeletePostsSerializer(serializers.Serializer):
                 "No more than %(limit)s posts can be deleted at single time.",
                 POSTS_LIMIT,
             )
-            raise ValidationError(message % {'limit': POSTS_LIMIT})
+            raise ValidationError(message % {"limit": POSTS_LIMIT})
 
-        user_acl = self.context['user_acl']
-        thread = self.context['thread']
+        user_acl = self.context["user_acl"]
+        thread = self.context["thread"]
 
-        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 = exclude_invisible_posts(
+            user_acl, thread.category, thread.post_set
+        )
+        posts_queryset = posts_queryset.filter(id__in=data).order_by("id")
 
         posts = []
         for post in posts_queryset:
@@ -88,17 +100,19 @@ class DeletePostsSerializer(serializers.Serializer):
 
 
 class MergePostsSerializer(serializers.Serializer):
-    error_empty_or_required = gettext_lazy("You have to select at least two posts to merge.")
+    error_empty_or_required = gettext_lazy(
+        "You have to select at least two posts to merge."
+    )
 
     posts = serializers.ListField(
         child=serializers.IntegerField(
             error_messages={
-                'invalid': gettext_lazy("One or more post ids received were invalid."),
-            },
+                "invalid": gettext_lazy("One or more post ids received were invalid.")
+            }
         ),
         error_messages={
-            'null': error_empty_or_required,
-            'required': error_empty_or_required,
+            "null": error_empty_or_required,
+            "required": error_empty_or_required,
         },
     )
 
@@ -113,13 +127,15 @@ class MergePostsSerializer(serializers.Serializer):
                 "No more than %(limit)s posts can be merged at single time.",
                 POSTS_LIMIT,
             )
-            raise serializers.ValidationError(message % {'limit': POSTS_LIMIT})
+            raise serializers.ValidationError(message % {"limit": POSTS_LIMIT})
 
-        user_acl = self.context['user_acl']
-        thread = self.context['thread']
+        user_acl = self.context["user_acl"]
+        thread = self.context["thread"]
 
-        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 = exclude_invisible_posts(
+            user_acl, thread.category, thread.post_set
+        )
+        posts_queryset = posts_queryset.filter(id__in=data).order_by("id")
 
         posts = []
         for post in posts_queryset:
@@ -134,22 +150,29 @@ class MergePostsSerializer(serializers.Serializer):
             if not posts:
                 posts.append(post)
                 continue
-            
+
             authorship_error = _("Posts made by different users can't be merged.")
             if post.poster_id != posts[0].poster_id:
                 raise serializers.ValidationError(authorship_error)
-            elif (post.poster_id is None and posts[0].poster_id is None and 
-                    post.poster_name != posts[0].poster_name):
+            elif (
+                post.poster_id is None
+                and posts[0].poster_id is None
+                and post.poster_name != posts[0].poster_name
+            ):
                 raise serializers.ValidationError(authorship_error)
 
             if posts[0].is_first_post and post.is_best_answer:
                 raise serializers.ValidationError(
-                    _("Post marked as best answer can't be merged with thread's first post.")
+                    _(
+                        "Post marked as best answer can't be merged with thread's first post."
+                    )
                 )
 
             if not posts[0].is_first_post:
-                if (posts[0].is_hidden != post.is_hidden or
-                        posts[0].is_unapproved != post.is_unapproved):
+                if (
+                    posts[0].is_hidden != post.is_hidden
+                    or posts[0].is_unapproved != post.is_unapproved
+                ):
                     raise serializers.ValidationError(
                         _("Posts with different visibility can't be merged.")
                     )
@@ -157,43 +180,47 @@ class MergePostsSerializer(serializers.Serializer):
             posts.append(post)
 
         if len(posts) != len(data):
-            raise serializers.ValidationError(_("One or more posts to merge could not be found."))
+            raise serializers.ValidationError(
+                _("One or more posts to merge could not be found.")
+            )
 
         return posts
 
 
 class MovePostsSerializer(serializers.Serializer):
-    error_empty_or_required = gettext_lazy("You have to specify at least one post to move.")
+    error_empty_or_required = gettext_lazy(
+        "You have to specify at least one post to move."
+    )
 
     new_thread = serializers.CharField(
-        error_messages={
-            'required': gettext_lazy("Enter link to new thread."),
-        },
+        error_messages={"required": gettext_lazy("Enter link to new thread.")}
     )
     posts = serializers.ListField(
         allow_empty=False,
         child=serializers.IntegerField(
             error_messages={
-                'invalid': gettext_lazy("One or more post ids received were invalid."),
-            },
+                "invalid": gettext_lazy("One or more post ids received were invalid.")
+            }
         ),
         error_messages={
-            'empty': error_empty_or_required,
-            'null': error_empty_or_required,
-            'required': error_empty_or_required,
+            "empty": error_empty_or_required,
+            "null": error_empty_or_required,
+            "required": error_empty_or_required,
         },
     )
 
     def validate_new_thread(self, data):
-        request = self.context['request']
-        thread = self.context['thread']
-        viewmodel = self.context['viewmodel']
+        request = self.context["request"]
+        thread = self.context["thread"]
+        viewmodel = self.context["viewmodel"]
 
         new_thread_id = get_thread_id_from_url(request, data)
         if not new_thread_id:
             raise serializers.ValidationError(_("This is not a valid thread link."))
         if new_thread_id == thread.pk:
-            raise serializers.ValidationError(_("Thread to move posts to is same as current one."))
+            raise serializers.ValidationError(
+                _("Thread to move posts to is same as current one.")
+            )
 
         try:
             new_thread = viewmodel(request, new_thread_id).unwrap()
@@ -205,8 +232,10 @@ class MovePostsSerializer(serializers.Serializer):
                 )
             )
 
-        if not new_thread.acl['can_reply']:
-            raise serializers.ValidationError(_("You can't move posts to threads you can't reply."))
+        if not new_thread.acl["can_reply"]:
+            raise serializers.ValidationError(
+                _("You can't move posts to threads you can't reply.")
+            )
 
         return new_thread
 
@@ -218,13 +247,15 @@ class MovePostsSerializer(serializers.Serializer):
                 "No more than %(limit)s posts can be moved at single time.",
                 POSTS_LIMIT,
             )
-            raise serializers.ValidationError(message % {'limit': POSTS_LIMIT})
+            raise serializers.ValidationError(message % {"limit": POSTS_LIMIT})
 
-        request = self.context['request']
-        thread = self.context['thread']
+        request = self.context["request"]
+        thread = self.context["thread"]
 
-        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 = exclude_invisible_posts(
+            request.user_acl, thread.category, thread.post_set
+        )
+        posts_queryset = posts_queryset.filter(id__in=data).order_by("id")
 
         posts = []
         for post in posts_queryset:
@@ -238,7 +269,9 @@ class MovePostsSerializer(serializers.Serializer):
                 raise serializers.ValidationError(e)
 
         if len(posts) != len(data):
-            raise serializers.ValidationError(_("One or more posts to move could not be found."))
+            raise serializers.ValidationError(
+                _("One or more posts to move could not be found.")
+            )
 
         return posts
 
@@ -261,22 +294,26 @@ class NewThreadSerializer(serializers.Serializer):
         return title
 
     def validate_category(self, category_id):
-        user_acl = self.context['user_acl']
+        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
 
     def validate_weight(self, weight):
         try:
-            add_acl_to_obj(self.context['user_acl'], self.category)
+            add_acl_to_obj(self.context["user_acl"], self.category)
         except AttributeError:
             return weight  # don't validate weight further if category failed
 
-        if weight > self.category.acl.get('can_pin_threads', 0):
+        if weight > self.category.acl.get("can_pin_threads", 0):
             if weight == 2:
                 raise ValidationError(
-                    _("You don't have permission to pin threads globally in this category.")
+                    _(
+                        "You don't have permission to pin threads globally in this category."
+                    )
                 )
             else:
                 raise ValidationError(
@@ -286,21 +323,23 @@ class NewThreadSerializer(serializers.Serializer):
 
     def validate_is_hidden(self, is_hidden):
         try:
-            add_acl_to_obj(self.context['user_acl'], self.category)
+            add_acl_to_obj(self.context["user_acl"], self.category)
         except AttributeError:
             return is_hidden  # don't validate hidden further if category failed
 
-        if is_hidden and not self.category.acl.get('can_hide_threads'):
-            raise ValidationError(_("You don't have permission to hide threads in this category."))
+        if is_hidden and not self.category.acl.get("can_hide_threads"):
+            raise ValidationError(
+                _("You don't have permission to hide threads in this category.")
+            )
         return is_hidden
 
     def validate_is_closed(self, is_closed):
         try:
-            add_acl_to_obj(self.context['user_acl'], self.category)
+            add_acl_to_obj(self.context["user_acl"], self.category)
         except AttributeError:
             return is_closed  # don't validate closed further if category failed
 
-        if is_closed and not self.category.acl.get('can_close_threads'):
+        if is_closed and not self.category.acl.get("can_close_threads"):
             raise ValidationError(
                 _("You don't have permission to close threads in this category.")
             )
@@ -308,19 +347,21 @@ class NewThreadSerializer(serializers.Serializer):
 
 
 class SplitPostsSerializer(NewThreadSerializer):
-    error_empty_or_required = gettext_lazy("You have to specify at least one post to split.")
+    error_empty_or_required = gettext_lazy(
+        "You have to specify at least one post to split."
+    )
 
     posts = serializers.ListField(
         allow_empty=False,
         child=serializers.IntegerField(
             error_messages={
-                'invalid': gettext_lazy("One or more post ids received were invalid."),
-            },
+                "invalid": gettext_lazy("One or more post ids received were invalid.")
+            }
         ),
         error_messages={
-            'empty': error_empty_or_required,
-            'null': error_empty_or_required,
-            'required': error_empty_or_required,
+            "empty": error_empty_or_required,
+            "null": error_empty_or_required,
+            "required": error_empty_or_required,
         },
     )
 
@@ -331,13 +372,15 @@ class SplitPostsSerializer(NewThreadSerializer):
                 "No more than %(limit)s posts can be split at single time.",
                 POSTS_LIMIT,
             )
-            raise ValidationError(message % {'limit': POSTS_LIMIT})
+            raise ValidationError(message % {"limit": POSTS_LIMIT})
 
-        thread = self.context['thread']
-        user_acl = self.context['user_acl']
+        thread = self.context["thread"]
+        user_acl = self.context["user_acl"]
 
-        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 = exclude_invisible_posts(
+            user_acl, thread.category, thread.post_set
+        )
+        posts_queryset = posts_queryset.filter(id__in=data).order_by("id")
 
         posts = []
         for post in posts_queryset:
@@ -358,19 +401,21 @@ class SplitPostsSerializer(NewThreadSerializer):
 
 
 class DeleteThreadsSerializer(serializers.Serializer):
-    error_empty_or_required = gettext_lazy("You have to specify at least one thread to delete.")
+    error_empty_or_required = gettext_lazy(
+        "You have to specify at least one thread to delete."
+    )
 
     threads = serializers.ListField(
         allow_empty=False,
         child=serializers.IntegerField(
             error_messages={
-                'invalid': gettext_lazy("One or more thread ids received were invalid."),
-            },
+                "invalid": gettext_lazy("One or more thread ids received were invalid.")
+            }
         ),
         error_messages={
-            'required': error_empty_or_required,
-            'null': error_empty_or_required,
-            'empty': error_empty_or_required,
+            "required": error_empty_or_required,
+            "null": error_empty_or_required,
+            "empty": error_empty_or_required,
         },
     )
 
@@ -381,10 +426,10 @@ class DeleteThreadsSerializer(serializers.Serializer):
                 "No more than %(limit)s threads can be deleted at single time.",
                 THREADS_LIMIT,
             )
-            raise ValidationError(message % {'limit': THREADS_LIMIT})
+            raise ValidationError(message % {"limit": THREADS_LIMIT})
 
-        request = self.context['request']
-        viewmodel = self.context['viewmodel']
+        request = self.context["request"]
+        viewmodel = self.context["viewmodel"]
 
         threads = []
         errors = []
@@ -395,48 +440,41 @@ class DeleteThreadsSerializer(serializers.Serializer):
                 allow_delete_thread(request.user_acl, thread)
                 threads.append(thread)
             except PermissionDenied as e:
-                errors.append({
-                    'thread': {
-                        'id': thread.id,
-                        'title': thread.title
-                    },
-                    'error': str(e)
-                })
+                errors.append(
+                    {
+                        "thread": {"id": thread.id, "title": thread.title},
+                        "error": str(e),
+                    }
+                )
             except Http404 as e:
-                pass # skip invisible threads
+                pass  # skip invisible threads
 
         if errors:
-            raise serializers.ValidationError({'details': errors})
+            raise serializers.ValidationError({"details": errors})
 
         if len(threads) != len(data):
-            raise ValidationError(_("One or more threads to delete could not be found."))
+            raise ValidationError(
+                _("One or more threads to delete could not be found.")
+            )
 
         return threads
 
 
 class MergeThreadSerializer(serializers.Serializer):
     other_thread = serializers.CharField(
-        error_messages={
-            'required': gettext_lazy("Enter link to new thread."),
-        },
+        error_messages={"required": gettext_lazy("Enter link to new thread.")}
     )
     best_answer = serializers.IntegerField(
-        required=False,
-        error_messages={
-            'invalid': gettext_lazy("Invalid choice."),
-        },
+        required=False, error_messages={"invalid": gettext_lazy("Invalid choice.")}
     )
     poll = serializers.IntegerField(
-        required=False,
-        error_messages={
-            'invalid': gettext_lazy("Invalid choice."),
-        },
+        required=False, error_messages={"invalid": gettext_lazy("Invalid choice.")}
     )
 
     def validate_other_thread(self, data):
-        request = self.context['request']
-        thread = self.context['thread']
-        viewmodel = self.context['viewmodel']
+        request = self.context["request"]
+        thread = self.context["thread"]
+        viewmodel = self.context["viewmodel"]
 
         other_thread_id = get_thread_id_from_url(request, data)
         if not other_thread_id:
@@ -458,13 +496,15 @@ class MergeThreadSerializer(serializers.Serializer):
             )
 
         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
 
     def validate(self, data):
-        thread = self.context['thread']
-        other_thread = data['other_thread']
+        thread = self.context["thread"]
+        other_thread = data["other_thread"]
 
         merge_conflict = MergeConflict(data, [thread, other_thread])
         merge_conflict.is_valid(raise_exception=True)
@@ -475,34 +515,30 @@ class MergeThreadSerializer(serializers.Serializer):
 
 
 class MergeThreadsSerializer(NewThreadSerializer):
-    error_empty_or_required = gettext_lazy("You have to select at least two threads to merge.")
+    error_empty_or_required = gettext_lazy(
+        "You have to select at least two threads to merge."
+    )
 
     threads = serializers.ListField(
         allow_empty=False,
         min_length=2,
         child=serializers.IntegerField(
             error_messages={
-                'invalid': gettext_lazy("One or more thread ids received were invalid."),
-            },
+                "invalid": gettext_lazy("One or more thread ids received were invalid.")
+            }
         ),
         error_messages={
-            'empty': error_empty_or_required,
-            'null': error_empty_or_required,
-            'required': error_empty_or_required,
-            'min_length': error_empty_or_required,
+            "empty": error_empty_or_required,
+            "null": error_empty_or_required,
+            "required": error_empty_or_required,
+            "min_length": error_empty_or_required,
         },
     )
     best_answer = serializers.IntegerField(
-        required=False,
-        error_messages={
-            'invalid': gettext_lazy("Invalid choice."),
-        },
+        required=False, error_messages={"invalid": gettext_lazy("Invalid choice.")}
     )
     poll = serializers.IntegerField(
-        required=False,
-        error_messages={
-            'invalid': gettext_lazy("Invalid choice."),
-        },
+        required=False, error_messages={"invalid": gettext_lazy("Invalid choice.")}
     )
 
     def validate_threads(self, data):
@@ -512,16 +548,17 @@ class MergeThreadsSerializer(NewThreadSerializer):
                 "No more than %(limit)s threads can be merged at single time.",
                 POSTS_LIMIT,
             )
-            raise ValidationError(message % {'limit': THREADS_LIMIT})
+            raise ValidationError(message % {"limit": THREADS_LIMIT})
 
         threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
 
-        threads_queryset = Thread.objects.filter(
-            id__in=data,
-            category__tree_id=threads_tree_id,
-        ).select_related('category').order_by('-id')
+        threads_queryset = (
+            Thread.objects.filter(id__in=data, category__tree_id=threads_tree_id)
+            .select_related("category")
+            .order_by("-id")
+        )
 
-        user_acl = self.context['user_acl']
+        user_acl = self.context["user_acl"]
 
         threads = []
         for thread in threads_queryset:

+ 52 - 72
misago/threads/serializers/poll.py

@@ -9,10 +9,10 @@ from misago.threads.models import Poll
 
 
 __all__ = [
-    'PollSerializer',
-    'EditPollSerializer',
-    'NewPollSerializer',
-    'PollChoiceSerializer',
+    "PollSerializer",
+    "EditPollSerializer",
+    "NewPollSerializer",
+    "PollChoiceSerializer",
 ]
 
 MAX_POLL_OPTIONS = 16
@@ -28,39 +28,31 @@ class PollSerializer(serializers.ModelSerializer):
     class Meta:
         model = Poll
         fields = [
-            'id',
-            'poster_name',
-            'posted_on',
-            'length',
-            'question',
-            'allowed_choices',
-            'allow_revotes',
-            'votes',
-            'is_public',
-            'acl',
-            'choices',
-            'api',
-            'url',
+            "id",
+            "poster_name",
+            "posted_on",
+            "length",
+            "question",
+            "allowed_choices",
+            "allow_revotes",
+            "votes",
+            "is_public",
+            "acl",
+            "choices",
+            "api",
+            "url",
         ]
 
     def get_api(self, obj):
-        return {
-            'index': obj.get_api_url(),
-            'votes': obj.get_votes_api_url(),
-        }
+        return {"index": obj.get_api_url(), "votes": obj.get_votes_api_url()}
 
     def get_url(self, obj):
-        return {
-            'poster': self.get_poster_url(obj),
-        }
+        return {"poster": self.get_poster_url(obj)}
 
     def get_poster_url(self, obj):
         if obj.poster_id:
             return reverse(
-                'misago:user', kwargs={
-                    'slug': obj.poster_slug,
-                    'pk': obj.poster_id,
-                }
+                "misago:user", kwargs={"slug": obj.poster_slug, "pk": obj.poster_id}
             )
         else:
             return None
@@ -79,20 +71,11 @@ class EditPollSerializer(serializers.ModelSerializer):
     length = serializers.IntegerField(required=True, min_value=0, max_value=180)
     question = serializers.CharField(required=True, max_length=255)
     allowed_choices = serializers.IntegerField(required=True, min_value=1)
-    choices = serializers.ListField(
-        allow_empty=False,
-        child=serializers.DictField(),
-    )
+    choices = serializers.ListField(allow_empty=False, child=serializers.DictField())
 
     class Meta:
         model = Poll
-        fields = [
-            'length',
-            'question',
-            'allowed_choices',
-            'allow_revotes',
-            'choices',
-        ]
+        fields = ["length", "question", "allowed_choices", "allow_revotes", "choices"]
 
     def validate_choices(self, choices):
         clean_choices = list(map(self.clean_choice, choices))
@@ -100,18 +83,15 @@ class EditPollSerializer(serializers.ModelSerializer):
         # generate hashes for added choices
         choices_map = {}
         for choice in self.instance.choices:
-            choices_map[choice['hash']] = choice
+            choices_map[choice["hash"]] = choice
 
         final_choices = []
         for choice in clean_choices:
-            if choice['hash'] in choices_map:
-                choices_map[choice['hash']].update({'label': choice['label']})
-                final_choices.append(choices_map[choice['hash']])
+            if choice["hash"] in choices_map:
+                choices_map[choice["hash"]].update({"label": choice["label"]})
+                final_choices.append(choices_map[choice["hash"]])
             else:
-                choice.update({
-                    'hash': get_random_string(12),
-                    'votes': 0,
-                })
+                choice.update({"hash": get_random_string(12), "votes": 0})
                 final_choices.append(choice)
 
         self.validate_choices_num(final_choices)
@@ -120,13 +100,15 @@ class EditPollSerializer(serializers.ModelSerializer):
 
     def clean_choice(self, choice):
         clean_choice = {
-            'hash': choice.get('hash', get_random_string(12)),
-            'label': choice.get('label', ''),
+            "hash": choice.get("hash", get_random_string(12)),
+            "label": choice.get("label", ""),
         }
 
         serializer = PollChoiceSerializer(data=clean_choice)
         if not serializer.is_valid():
-            raise serializers.ValidationError(_("One or more poll choices are invalid."))
+            raise serializers.ValidationError(
+                _("One or more poll choices are invalid.")
+            )
 
         return serializer.data
 
@@ -134,7 +116,9 @@ class EditPollSerializer(serializers.ModelSerializer):
         total_choices = len(choices)
 
         if total_choices < 2:
-            raise serializers.ValidationError(_("You need to add at least two choices to a poll."))
+            raise serializers.ValidationError(
+                _("You need to add at least two choices to a poll.")
+            )
 
         if total_choices > MAX_POLL_OPTIONS:
             message = ngettext(
@@ -143,33 +127,32 @@ class EditPollSerializer(serializers.ModelSerializer):
                 MAX_POLL_OPTIONS,
             )
             raise serializers.ValidationError(
-                message % {
-                    'limit_value': MAX_POLL_OPTIONS,
-                    'show_value': total_choices,
-                }
+                message % {"limit_value": MAX_POLL_OPTIONS, "show_value": total_choices}
             )
 
     def validate(self, data):
-        if data['allowed_choices'] > len(data['choices']):
+        if data["allowed_choices"] > len(data["choices"]):
             raise serializers.ValidationError(
-                _("Number of allowed choices can't be greater than number of all choices.")
+                _(
+                    "Number of allowed choices can't be greater than number of all choices."
+                )
             )
         return data
 
     def update(self, instance, validated_data):
         if instance.choices:
-            self.update_choices(instance, validated_data['choices'])
+            self.update_choices(instance, validated_data["choices"])
 
         return super().update(instance, validated_data)
 
     def update_choices(self, instance, cleaned_choices):
         removed_hashes = []
 
-        final_hashes = [c['hash'] for c in cleaned_choices]
+        final_hashes = [c["hash"] for c in cleaned_choices]
         for choice in instance.choices:
-            if choice['hash'] not in final_hashes:
-                instance.votes -= choice['votes']
-                removed_hashes.append(choice['hash'])
+            if choice["hash"] not in final_hashes:
+                instance.votes -= choice["votes"]
+                removed_hashes.append(choice["hash"])
 
         if removed_hashes:
             instance.pollvote_set.filter(choice_hash__in=removed_hashes).delete()
@@ -179,12 +162,12 @@ class NewPollSerializer(EditPollSerializer):
     class Meta:
         model = Poll
         fields = [
-            'length',
-            'question',
-            'allowed_choices',
-            'allow_revotes',
-            'is_public',
-            'choices',
+            "length",
+            "question",
+            "allowed_choices",
+            "allow_revotes",
+            "is_public",
+            "choices",
         ]
 
     def validate_choices(self, choices):
@@ -193,10 +176,7 @@ class NewPollSerializer(EditPollSerializer):
         self.validate_choices_num(clean_choices)
 
         for choice in clean_choices:
-            choice.update({
-                'hash': get_random_string(12),
-                'votes': 0,
-            })
+            choice.update({"hash": get_random_string(12), "votes": 0})
 
         return clean_choices
 

+ 12 - 26
misago/threads/serializers/pollvote.py

@@ -5,29 +5,24 @@ from django.utils.translation import gettext as _
 from django.utils.translation import ngettext
 
 
-__all__ = [
-    'NewVoteSerializer',
-    'PollVoteSerializer',
-]
+__all__ = ["NewVoteSerializer", "PollVoteSerializer"]
 
 
 class NewVoteSerializer(serializers.Serializer):
-    choices = serializers.ListField(
-        child=serializers.CharField(),
-    )
+    choices = serializers.ListField(child=serializers.CharField())
 
     def validate_choices(self, data):
-        if len(data) > self.context['allowed_choices']:
+        if len(data) > self.context["allowed_choices"]:
             message = ngettext(
                 "This poll disallows voting for more than %(choices)s choice.",
                 "This poll disallows voting for more than %(choices)s choices.",
-                self.context['allowed_choices']
+                self.context["allowed_choices"],
             )
             raise serializers.ValidationError(
-                message % {'choices': self.context['allowed_choices']},
+                message % {"choices": self.context["allowed_choices"]}
             )
 
-        valid_choices = [c['hash'] for c in self.context['choices']]
+        valid_choices = [c["hash"] for c in self.context["choices"]]
         clean_choices = []
 
         for choice in data:
@@ -36,12 +31,10 @@ class NewVoteSerializer(serializers.Serializer):
 
         if len(clean_choices) != len(data):
             raise serializers.ValidationError(
-                _("One or more of poll choices were invalid."),
+                _("One or more of poll choices were invalid.")
             )
         if not len(clean_choices):
-            raise serializers.ValidationError(
-                _("You have to make a choice."),
-            )
+            raise serializers.ValidationError(_("You have to make a choice."))
 
         return clean_choices
 
@@ -53,20 +46,13 @@ class PollVoteSerializer(serializers.Serializer):
     url = serializers.SerializerMethodField()
 
     class Meta:
-        fields = [
-            'voted_on',
-            'username',
-            'url',
-        ]
+        fields = ["voted_on", "username", "url"]
 
     def get_username(self, obj):
-        return obj['voter_name']
+        return obj["voter_name"]
 
     def get_url(self, obj):
-        if obj['voter_id']:
+        if obj["voter_id"]:
             return reverse(
-                'misago:user', kwargs={
-                    'pk': obj['voter_id'],
-                    'slug': obj['voter_slug'],
-                }
+                "misago:user", kwargs={"pk": obj["voter_id"], "slug": obj["voter_slug"]}
             )

+ 60 - 60
misago/threads/serializers/post.py

@@ -7,19 +7,19 @@ from misago.threads.models import Post
 from misago.users.serializers import UserSerializer as BaseUserSerializer
 
 
-__all__ = ['PostSerializer']
+__all__ = ["PostSerializer"]
 
 UserSerializer = BaseUserSerializer.subset_fields(
-    'id',
-    'username',
-    'real_name',
-    'rank',
-    'avatars',
-    'signature',
-    'title',
-    'status',
-    'posts',
-    'url',
+    "id",
+    "username",
+    "real_name",
+    "rank",
+    "avatars",
+    "signature",
+    "title",
+    "status",
+    "posts",
+    "url",
 )
 
 
@@ -43,39 +43,43 @@ class PostSerializer(serializers.ModelSerializer, MutableFields):
     class Meta:
         model = Post
         fields = [
-            'id',
-            'poster',
-            'poster_name',
-            'content',
-            'attachments',
-            'posted_on',
-            'updated_on',
-            'hidden_on',
-            'edits',
-            'last_editor',
-            'last_editor_name',
-            'last_editor_slug',
-            'hidden_by',
-            'hidden_by_name',
-            'hidden_by_slug',
-            'is_unapproved',
-            'is_hidden',
-            'is_protected',
-            'is_event',
-            'event_type',
-            'event_context',
-            'acl',
-            'is_liked',
-            'is_new',
-            'is_read',
-            'last_likes',
-            'likes',
-            'api',
-            'url',
+            "id",
+            "poster",
+            "poster_name",
+            "content",
+            "attachments",
+            "posted_on",
+            "updated_on",
+            "hidden_on",
+            "edits",
+            "last_editor",
+            "last_editor_name",
+            "last_editor_slug",
+            "hidden_by",
+            "hidden_by_name",
+            "hidden_by_slug",
+            "is_unapproved",
+            "is_hidden",
+            "is_protected",
+            "is_event",
+            "event_type",
+            "event_context",
+            "acl",
+            "is_liked",
+            "is_new",
+            "is_read",
+            "last_likes",
+            "likes",
+            "api",
+            "url",
         ]
 
     def get_content(self, obj):
-        if obj.is_valid and not obj.is_event and (not obj.is_hidden or obj.acl['can_see_hidden']):
+        if (
+            obj.is_valid
+            and not obj.is_event
+            and (not obj.is_hidden or obj.acl["can_see_hidden"])
+        ):
             return obj.content
         else:
             return None
@@ -112,7 +116,7 @@ class PostSerializer(serializers.ModelSerializer, MutableFields):
             return None
 
         try:
-            if obj.acl['can_see_likes']:
+            if obj.acl["can_see_likes"]:
                 return obj.last_likes
         except AttributeError:
             return None
@@ -122,39 +126,37 @@ class PostSerializer(serializers.ModelSerializer, MutableFields):
             return None
 
         try:
-            if obj.acl['can_see_likes']:
+            if obj.acl["can_see_likes"]:
                 return obj.likes
         except AttributeError:
             return None
 
     def get_api(self, obj):
         api_links = {
-            'index': obj.get_api_url(),
-            'likes': obj.get_likes_api_url(),
-            'editor': obj.get_editor_api_url(),
-            'edits': obj.get_edits_api_url(),
-            'read': obj.get_read_api_url(),
+            "index": obj.get_api_url(),
+            "likes": obj.get_likes_api_url(),
+            "editor": obj.get_editor_api_url(),
+            "edits": obj.get_edits_api_url(),
+            "read": obj.get_read_api_url(),
         }
 
         if obj.is_event:
-            del api_links['likes']
+            del api_links["likes"]
 
         return api_links
 
     def get_url(self, obj):
         return {
-            'index': obj.get_absolute_url(),
-            'last_editor': self.get_last_editor_url(obj),
-            'hidden_by': self.get_hidden_by_url(obj),
+            "index": obj.get_absolute_url(),
+            "last_editor": self.get_last_editor_url(obj),
+            "hidden_by": self.get_hidden_by_url(obj),
         }
 
     def get_last_editor_url(self, obj):
         if obj.last_editor_id:
             return reverse(
-                'misago:user', kwargs={
-                    'pk': obj.last_editor_id,
-                    'slug': obj.last_editor_slug,
-                }
+                "misago:user",
+                kwargs={"pk": obj.last_editor_id, "slug": obj.last_editor_slug},
             )
         else:
             return None
@@ -162,10 +164,8 @@ class PostSerializer(serializers.ModelSerializer, MutableFields):
     def get_hidden_by_url(self, obj):
         if obj.hidden_by_id:
             return reverse(
-                'misago:user', kwargs={
-                    'pk': obj.hidden_by_id,
-                    'slug': obj.hidden_by_slug,
-                }
+                "misago:user",
+                kwargs={"pk": obj.hidden_by_id, "slug": obj.hidden_by_slug},
             )
         else:
             return None

+ 4 - 18
misago/threads/serializers/postedit.py

@@ -5,9 +5,7 @@ from django.urls import reverse
 from misago.threads.models import PostEdit
 
 
-__all__ = [
-    'PostEditSerializer',
-]
+__all__ = ["PostEditSerializer"]
 
 
 class PostEditSerializer(serializers.ModelSerializer):
@@ -17,30 +15,18 @@ class PostEditSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = PostEdit
-        fields = [
-            'id',
-            'edited_on',
-            'editor_name',
-            'editor_slug',
-            'diff',
-            'url',
-        ]
+        fields = ["id", "edited_on", "editor_name", "editor_slug", "diff", "url"]
 
     def get_diff(self, obj):
         return obj.get_diff()
 
     def get_url(self, obj):
-        return {
-            'editor': self.get_editor_url(obj),
-        }
+        return {"editor": self.get_editor_url(obj)}
 
     def get_editor_url(self, obj):
         if obj.editor_id:
             return reverse(
-                'misago:user', kwargs={
-                    'slug': obj.editor_slug,
-                    'pk': obj.editor_id,
-                }
+                "misago:user", kwargs={"slug": obj.editor_slug, "pk": obj.editor_id}
             )
         else:
             return None

+ 7 - 19
misago/threads/serializers/postlike.py

@@ -5,9 +5,7 @@ from django.urls import reverse
 from misago.threads.models import PostLike
 
 
-__all__ = [
-    'PostLikeSerializer',
-]
+__all__ = ["PostLikeSerializer"]
 
 
 class PostLikeSerializer(serializers.ModelSerializer):
@@ -19,31 +17,21 @@ class PostLikeSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = PostLike
-        fields = [
-            'id',
-            'avatars',
-            'liked_on',
-            'liker_id',
-            'username',
-            'url',
-        ]
+        fields = ["id", "avatars", "liked_on", "liker_id", "username", "url"]
 
     def get_liker_id(self, obj):
-        return obj['liker_id']
+        return obj["liker_id"]
 
     def get_username(self, obj):
-        return obj['liker_name']
+        return obj["liker_name"]
 
     def get_avatars(self, obj):
-        return obj.get('liker__avatars')
+        return obj.get("liker__avatars")
 
     def get_url(self, obj):
-        if obj['liker_id']:
+        if obj["liker_id"]:
             return reverse(
-                'misago:user', kwargs={
-                    'slug': obj['liker_slug'],
-                    'pk': obj['liker_id'],
-                }
+                "misago:user", kwargs={"slug": obj["liker_slug"], "pk": obj["liker_id"]}
             )
         else:
             return None

+ 72 - 78
misago/threads/serializers/thread.py

@@ -10,15 +10,20 @@ from .poll import PollSerializer
 from .threadparticipant import ThreadParticipantSerializer
 
 
-__all__ = [
-    'ThreadSerializer',
-    'PrivateThreadSerializer',
-    'ThreadsListSerializer',
-]
+__all__ = ["ThreadSerializer", "PrivateThreadSerializer", "ThreadsListSerializer"]
 
 BasicCategorySerializer = CategorySerializer.subset_fields(
-    'id', 'parent', 'name', 'description', 'is_closed', 'css_class',
-    'level', 'lft', 'rght', 'is_read', 'url'
+    "id",
+    "parent",
+    "name",
+    "description",
+    "is_closed",
+    "css_class",
+    "level",
+    "lft",
+    "rght",
+    "is_read",
+    "url",
 )
 
 
@@ -41,35 +46,35 @@ class ThreadSerializer(serializers.ModelSerializer, MutableFields):
     class Meta:
         model = Thread
         fields = [
-            'id',
-            'category',
-            'title',
-            'replies',
-            'has_unapproved_posts',
-            'started_on',
-            'starter_name',
-            'last_post_on',
-            'last_post_is_event',
-            'last_post',
-            'last_poster_name',
-            'is_unapproved',
-            'is_hidden',
-            'is_closed',
-            'weight',
-            'best_answer',
-            'best_answer_is_protected',
-            'best_answer_marked_on',
-            'best_answer_marked_by',
-            'best_answer_marked_by_name',
-            'best_answer_marked_by_slug',
-            'acl',
-            'is_new',
-            'is_read',
-            'path',
-            'poll',
-            'subscription',
-            'api',
-            'url',
+            "id",
+            "category",
+            "title",
+            "replies",
+            "has_unapproved_posts",
+            "started_on",
+            "starter_name",
+            "last_post_on",
+            "last_post_is_event",
+            "last_post",
+            "last_poster_name",
+            "is_unapproved",
+            "is_hidden",
+            "is_closed",
+            "weight",
+            "best_answer",
+            "best_answer_is_protected",
+            "best_answer_marked_on",
+            "best_answer_marked_by",
+            "best_answer_marked_by_name",
+            "best_answer_marked_by_slug",
+            "acl",
+            "is_new",
+            "is_read",
+            "path",
+            "poll",
+            "subscription",
+            "api",
+            "url",
         ]
 
     def get_acl(self, obj):
@@ -83,7 +88,7 @@ class ThreadSerializer(serializers.ModelSerializer, MutableFields):
             acl = obj.acl
         except AttributeError:
             return False
-        return acl.get('can_approve') and obj.has_unapproved_posts
+        return acl.get("can_approve") and obj.has_unapproved_posts
 
     def get_is_new(self, obj):
         try:
@@ -108,46 +113,41 @@ class ThreadSerializer(serializers.ModelSerializer, MutableFields):
 
     def get_api(self, obj):
         return {
-            'index': obj.get_api_url(),
-            'editor': obj.get_editor_api_url(),
-            'merge': obj.get_merge_api_url(),
-            'poll': obj.get_poll_api_url(),
-            'posts': {
-                'index': obj.get_posts_api_url(),
-                'merge': obj.get_post_merge_api_url(),
-                'move': obj.get_post_move_api_url(),
-                'split': obj.get_post_split_api_url(),
+            "index": obj.get_api_url(),
+            "editor": obj.get_editor_api_url(),
+            "merge": obj.get_merge_api_url(),
+            "poll": obj.get_poll_api_url(),
+            "posts": {
+                "index": obj.get_posts_api_url(),
+                "merge": obj.get_post_merge_api_url(),
+                "move": obj.get_post_move_api_url(),
+                "split": obj.get_post_split_api_url(),
             },
         }
 
     def get_url(self, obj):
         return {
-            'index': obj.get_absolute_url(),
-            'new_post': obj.get_new_post_url(),
-            'last_post': obj.get_last_post_url(),
-            'best_answer': obj.get_best_answer_url(),
-            'unapproved_post': obj.get_unapproved_post_url(),
-            'starter': self.get_starter_url(obj),
-            'last_poster': self.get_last_poster_url(obj),
+            "index": obj.get_absolute_url(),
+            "new_post": obj.get_new_post_url(),
+            "last_post": obj.get_last_post_url(),
+            "best_answer": obj.get_best_answer_url(),
+            "unapproved_post": obj.get_unapproved_post_url(),
+            "starter": self.get_starter_url(obj),
+            "last_poster": self.get_last_poster_url(obj),
         }
 
     def get_starter_url(self, obj):
         if obj.starter_id:
             return reverse(
-                'misago:user', kwargs={
-                    'slug': obj.starter_slug,
-                    'pk': obj.starter_id,
-                }
+                "misago:user", kwargs={"slug": obj.starter_slug, "pk": obj.starter_id}
             )
         return None
 
     def get_last_poster_url(self, obj):
         if obj.last_poster_id:
             return reverse(
-                'misago:user', kwargs={
-                    'slug': obj.last_poster_slug,
-                    'pk': obj.last_poster_id,
-                }
+                "misago:user",
+                kwargs={"slug": obj.last_poster_slug, "pk": obj.last_poster_id},
             )
         return None
 
@@ -157,9 +157,7 @@ class PrivateThreadSerializer(ThreadSerializer):
 
     class Meta:
         model = Thread
-        fields = ThreadSerializer.Meta.fields + [
-            'participants',
-        ]
+        fields = ThreadSerializer.Meta.fields + ["participants"]
 
 
 class ThreadsListSerializer(ThreadSerializer):
@@ -170,31 +168,27 @@ class ThreadsListSerializer(ThreadSerializer):
 
     class Meta:
         model = Thread
-        fields = ThreadSerializer.Meta.fields + [
-            'has_poll',
-            'starter',
-            'last_poster',
-        ]
+        fields = ThreadSerializer.Meta.fields + ["has_poll", "starter", "last_poster"]
 
     def get_starter(self, obj):
         if obj.starter_id:
             return {
-                'id': obj.starter_id,
-                'username': obj.starter.username,
-                'real_name': obj.starter.get_real_name(),
-                'avatars': obj.starter.avatars,
+                "id": obj.starter_id,
+                "username": obj.starter.username,
+                "real_name": obj.starter.get_real_name(),
+                "avatars": obj.starter.avatars,
             }
         return None
 
     def get_last_poster(self, obj):
         if obj.last_poster_id:
             return {
-                'id': obj.last_poster_id,
-                'username': obj.last_poster.username,
-                'real_name': obj.last_poster.get_real_name(),
-                'avatars': obj.last_poster.avatars,
+                "id": obj.last_poster_id,
+                "username": obj.last_poster.username,
+                "real_name": obj.last_poster.get_real_name(),
+                "avatars": obj.last_poster.avatars,
             }
         return None
 
 
-ThreadsListSerializer = ThreadsListSerializer.exclude_fields('path', 'poll')
+ThreadsListSerializer = ThreadsListSerializer.exclude_fields("path", "poll")

+ 2 - 2
misago/threads/serializers/threadparticipant.py

@@ -3,7 +3,7 @@ from rest_framework import serializers
 from misago.threads.models import ThreadParticipant
 
 
-__all__ = ['ThreadParticipantSerializer']
+__all__ = ["ThreadParticipantSerializer"]
 
 
 class ThreadParticipantSerializer(serializers.ModelSerializer):
@@ -15,7 +15,7 @@ class ThreadParticipantSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = ThreadParticipant
-        fields = ['id', 'username', 'avatars', 'url', 'is_owner']
+        fields = ["id", "username", "avatars", "url", "is_owner"]
 
     def get_id(self, obj):
         return obj.user.id

+ 45 - 61
misago/threads/signals.py

@@ -10,7 +10,11 @@ from misago.categories.models import Category
 from misago.categories.signals import delete_category_content, move_category_content
 from misago.core.pgutils import chunk_queryset
 from misago.users.signals import (
-    anonymize_user_data, archive_user_data, delete_user_content, username_changed)
+    anonymize_user_data,
+    archive_user_data,
+    delete_user_content,
+    username_changed,
+)
 
 from .anonymize import ANONYMIZABLE_EVENTS, anonymize_event, anonymize_post_last_likes
 from .models import Attachment, Poll, PollVote, Post, PostEdit, PostLike, Thread
@@ -26,32 +30,20 @@ move_thread = Signal()
 
 @receiver(merge_thread)
 def merge_threads(sender, **kwargs):
-    other_thread = kwargs['other_thread']
+    other_thread = kwargs["other_thread"]
 
-    other_thread.post_set.update(
-        category=sender.category,
-        thread=sender,
-    )
-    other_thread.postedit_set.update(
-        category=sender.category,
-        thread=sender,
-    )
-    other_thread.postlike_set.update(
-        category=sender.category,
-        thread=sender,
-    )
+    other_thread.post_set.update(category=sender.category, thread=sender)
+    other_thread.postedit_set.update(category=sender.category, thread=sender)
+    other_thread.postlike_set.update(category=sender.category, thread=sender)
 
     other_thread.subscription_set.exclude(
-        user__in=sender.subscription_set.values('user'),
-    ).update(
-        category=sender.category,
-        thread=sender,
-    )
+        user__in=sender.subscription_set.values("user")
+    ).update(category=sender.category, thread=sender)
 
 
 @receiver(merge_post)
 def merge_posts(sender, **kwargs):
-    other_post = kwargs['other_post']
+    other_post = kwargs["other_post"]
     for user in sender.mentions.iterator():
         other_post.mentions.add(user)
 
@@ -80,7 +72,7 @@ def delete_category_threads(sender, **kwargs):
 
 @receiver(move_category_content)
 def move_category_threads(sender, **kwargs):
-    new_category = kwargs['new_category']
+    new_category = kwargs["new_category"]
 
     sender.thread_set.update(category=new_category)
     sender.post_set.filter(category=sender).update(category=new_category)
@@ -97,11 +89,11 @@ def delete_user_threads(sender, **kwargs):
     recount_threads = set()
 
     for post in chunk_queryset(sender.liked_post_set):
-        cleaned_likes = list(filter(lambda i: i['id'] != sender.id, post.last_likes))
+        cleaned_likes = list(filter(lambda i: i["id"] != sender.id, post.last_likes))
         if cleaned_likes != post.last_likes:
             post.last_likes = cleaned_likes
-            post.save(update_fields=['last_likes'])
-            
+            post.save(update_fields=["last_likes"])
+
     for thread in chunk_queryset(sender.thread_set):
         recount_categories.add(thread.category_id)
         with transaction.atomic():
@@ -127,56 +119,58 @@ def delete_user_threads(sender, **kwargs):
 
 @receiver(archive_user_data)
 def archive_user_attachments(sender, archive=None, **kwargs):
-    queryset = sender.attachment_set.order_by('id')
+    queryset = sender.attachment_set.order_by("id")
     for attachment in chunk_queryset(queryset):
         archive.add_model_file(
             attachment.file,
-            prefix=attachment.uploaded_on.strftime('%H%M%S-file'),
+            prefix=attachment.uploaded_on.strftime("%H%M%S-file"),
             date=attachment.uploaded_on,
         )
         archive.add_model_file(
             attachment.image,
-            prefix=attachment.uploaded_on.strftime('%H%M%S-image'),
+            prefix=attachment.uploaded_on.strftime("%H%M%S-image"),
             date=attachment.uploaded_on,
         )
         archive.add_model_file(
             attachment.thumbnail,
-            prefix=attachment.uploaded_on.strftime('%H%M%S-thumbnail'),
+            prefix=attachment.uploaded_on.strftime("%H%M%S-thumbnail"),
             date=attachment.uploaded_on,
         )
 
 
 @receiver(archive_user_data)
 def archive_user_posts(sender, archive=None, **kwargs):
-    queryset = sender.post_set.order_by('id')
+    queryset = sender.post_set.order_by("id")
     for post in chunk_queryset(queryset):
-        item_name = post.posted_on.strftime('%H%M%S-post')
+        item_name = post.posted_on.strftime("%H%M%S-post")
         archive.add_text(item_name, post.parsed, date=post.posted_on)
 
 
 @receiver(archive_user_data)
 def archive_user_posts_edits(sender, archive=None, **kwargs):
-    queryset = PostEdit.objects.filter(post__poster=sender).order_by('id')
+    queryset = PostEdit.objects.filter(post__poster=sender).order_by("id")
     for post_edit in chunk_queryset(queryset):
-        item_name = post_edit.edited_on.strftime('%H%M%S-post-edit')
+        item_name = post_edit.edited_on.strftime("%H%M%S-post-edit")
         archive.add_text(item_name, post_edit.edited_from, date=post_edit.edited_on)
-    queryset = sender.postedit_set.exclude(id__in=queryset.values('id')).order_by('id')
+    queryset = sender.postedit_set.exclude(id__in=queryset.values("id")).order_by("id")
     for post_edit in chunk_queryset(queryset):
-        item_name = post_edit.edited_on.strftime('%H%M%S-post-edit')
+        item_name = post_edit.edited_on.strftime("%H%M%S-post-edit")
         archive.add_text(item_name, post_edit.edited_from, date=post_edit.edited_on)
 
 
 @receiver(archive_user_data)
 def archive_user_polls(sender, archive=None, **kwargs):
-    queryset = sender.poll_set.order_by('id')
+    queryset = sender.poll_set.order_by("id")
     for poll in chunk_queryset(queryset):
-        item_name = poll.posted_on.strftime('%H%M%S-poll')
+        item_name = poll.posted_on.strftime("%H%M%S-poll")
         archive.add_dict(
             item_name,
-            OrderedDict([
-                (_("Question"), poll.question),
-                (_("Choices"), ', '.join([c['label'] for c in poll.choices])),
-            ]),
+            OrderedDict(
+                [
+                    (_("Question"), poll.question),
+                    (_("Choices"), ", ".join([c["label"] for c in poll.choices])),
+                ]
+            ),
             date=poll.posted_on,
         )
 
@@ -202,58 +196,48 @@ def anonymize_user_in_likes(sender, **kwargs):
 @receiver([anonymize_user_data, username_changed])
 def update_usernames(sender, **kwargs):
     Thread.objects.filter(starter=sender).update(
-        starter_name=sender.username,
-        starter_slug=sender.slug,
+        starter_name=sender.username, starter_slug=sender.slug
     )
 
     Thread.objects.filter(last_poster=sender).update(
-        last_poster_name=sender.username,
-        last_poster_slug=sender.slug,
+        last_poster_name=sender.username, last_poster_slug=sender.slug
     )
-    
+
     Thread.objects.filter(best_answer_marked_by=sender).update(
         best_answer_marked_by_name=sender.username,
         best_answer_marked_by_slug=sender.slug,
     )
 
-    Post.objects.filter(poster=sender).update(
-        poster_name=sender.username,
-    )
+    Post.objects.filter(poster=sender).update(poster_name=sender.username)
 
     Post.objects.filter(last_editor=sender).update(
-        last_editor_name=sender.username,
-        last_editor_slug=sender.slug,
+        last_editor_name=sender.username, last_editor_slug=sender.slug
     )
 
     PostEdit.objects.filter(editor=sender).update(
-        editor_name=sender.username,
-        editor_slug=sender.slug,
+        editor_name=sender.username, editor_slug=sender.slug
     )
 
     PostLike.objects.filter(liker=sender).update(
-        liker_name=sender.username,
-        liker_slug=sender.slug,
+        liker_name=sender.username, liker_slug=sender.slug
     )
 
     Attachment.objects.filter(uploader=sender).update(
-        uploader_name=sender.username,
-        uploader_slug=sender.slug,
+        uploader_name=sender.username, uploader_slug=sender.slug
     )
 
     Poll.objects.filter(poster=sender).update(
-        poster_name=sender.username,
-        poster_slug=sender.slug,
+        poster_name=sender.username, poster_slug=sender.slug
     )
 
     PollVote.objects.filter(voter=sender).update(
-        voter_name=sender.username,
-        voter_slug=sender.slug,
+        voter_name=sender.username, voter_slug=sender.slug
     )
 
 
 @receiver(pre_delete, sender=get_user_model())
 def remove_unparticipated_private_threads(sender, **kwargs):
-    threads_qs = kwargs['instance'].privatethread_set.all()
+    threads_qs = kwargs["instance"].privatethread_set.all()
     for thread in chunk_queryset(threads_qs):
         if thread.participants.count() == 1:
             with transaction.atomic():

+ 2 - 2
misago/threads/subscriptions.py

@@ -2,7 +2,7 @@ from .models import Subscription
 
 
 def make_subscription_aware(user, target):
-    if hasattr(target, '__iter__'):
+    if hasattr(target, "__iter__"):
         make_threads_subscription_aware(user, target)
     else:
         make_thread_subscription_aware(user, target)
@@ -22,7 +22,7 @@ def make_threads_subscription_aware(user, threads):
             threads_dict[thread.pk] = thread
 
         subscriptions_queryset = user.subscription_set.filter(
-            thread_id__in=threads_dict.keys(),
+            thread_id__in=threads_dict.keys()
         )
 
         for subscription in subscriptions_queryset.iterator():

+ 14 - 11
misago/threads/templatetags/misago_poststags.py

@@ -12,30 +12,33 @@ def likes_label(post):
 
     usernames = []
     for like in last_likes[:3]:
-        usernames.append(like['username'])
+        usernames.append(like["username"])
 
     if len(usernames) == 1:
-        return _("%(user)s likes this.") % {'user': usernames[0]}
+        return _("%(user)s likes this.") % {"user": usernames[0]}
 
     hidden_likes = post.likes - len(usernames)
     if len(last_likes) < 4:
         usernames_string = humanize_usernames_list(usernames)
     else:
-        usernames_string = ', '.join(usernames)
+        usernames_string = ", ".join(usernames)
 
     if not hidden_likes:
-        return _("%(users)s like this.") % {'users': usernames_string}
+        return _("%(users)s like this.") % {"users": usernames_string}
 
-    formats = {'users': usernames_string, 'likes': hidden_likes}
+    formats = {"users": usernames_string, "likes": hidden_likes}
 
-    return ngettext(
-        "%(users)s and %(likes)s other user like this.",
-        "%(users)s and %(likes)s other users like this.",
-        hidden_likes,
-    ) % formats
+    return (
+        ngettext(
+            "%(users)s and %(likes)s other user like this.",
+            "%(users)s and %(likes)s other users like this.",
+            hidden_likes,
+        )
+        % formats
+    )
 
 
 def humanize_usernames_list(usernames):
-    formats = {'users': ', '.join(usernames[:-1]), 'last_user': usernames[-1]}
+    formats = {"users": ", ".join(usernames[:-1]), "last_user": usernames[-1]}
 
     return _("%(users)s and %(last_user)s") % formats

+ 25 - 25
misago/threads/test.py

@@ -2,24 +2,24 @@ 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,
+    "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 = user_acl["categories"][category.id]
         category_acl.update(default_category_acl)
         if acl_patch:
             category_acl.update(acl_patch)
@@ -34,7 +34,7 @@ def patch_other_user_category_acl(acl_patch=None):
             return
 
         category = Category.objects.get(slug="first-category")
-        category_acl = user_acl['categories'][category.id]
+        category_acl = user_acl["categories"][category.id]
         category_acl.update(default_category_acl)
         if acl_patch:
             category_acl.update(acl_patch)
@@ -46,10 +46,10 @@ def patch_other_user_category_acl(acl_patch=None):
 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()
+        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
+        user_acl["categories"][dst_category.id] = category_acl
 
         category_acl.update(default_category_acl)
         if acl_patch:
@@ -63,7 +63,7 @@ def patch_other_category_acl(acl_patch=None):
 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 = user_acl["categories"][category.id]
         category_acl.update(default_category_acl)
         if acl_patch:
             category_acl.update(acl_patch)
@@ -80,30 +80,30 @@ def other_user_cant_use_private_threads(user, user_acl):
 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 = 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']
+    visible_categories = user_acl["visible_categories"]
+    browseable_categories = user_acl["browseable_categories"]
 
-    if not category_acl['can_see'] and category.id in visible_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:
+    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:
+    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:
+    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:
+    if category_acl["can_browse"] and category.id not in browseable_categories:
         browseable_categories.append(category.id)

+ 89 - 67
misago/threads/tests/test_anonymize_data.py

@@ -11,7 +11,12 @@ from misago.threads import testutils
 from misago.threads.api.postendpoints.patch_post import patch_is_liked
 from misago.threads.models import Post
 from misago.threads.participants import (
-    add_participant, change_owner, make_participants_aware, remove_participant, set_owner)
+    add_participant,
+    change_owner,
+    make_participants_aware,
+    remove_participant,
+    set_owner,
+)
 
 
 UserModel = get_user_model()
@@ -19,7 +24,9 @@ UserModel = get_user_model()
 
 def get_mock_user():
     seed = UserModel.objects.count() + 1
-    return UserModel.objects.create_user('bob%s' % seed, 'user%s@test.com' % seed, 'Pass.123')
+    return UserModel.objects.create_user(
+        "bob%s" % seed, "user%s@test.com" % seed, "Pass.123"
+    )
 
 
 class AnonymizeEventsTests(AuthenticatedUserTestCase):
@@ -27,13 +34,13 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
         super().setUp()
         self.factory = RequestFactory()
 
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.thread = testutils.post_thread(category)
 
     def get_request(self, user=None):
-        request = self.factory.get('/customer/details')
+        request = self.factory.get("/customer/details")
         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
@@ -52,14 +59,17 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
 
         user.anonymize_data()
 
-        event = Post.objects.get(event_type='changed_owner')
-        self.assertEqual(event.event_context, {
-            'user': {
-                'id': None,
-                'username': user.username,
-                'url': reverse('misago:index'),
+        event = Post.objects.get(event_type="changed_owner")
+        self.assertEqual(
+            event.event_context,
+            {
+                "user": {
+                    "id": None,
+                    "username": user.username,
+                    "url": reverse("misago:index"),
+                }
             },
-        })
+        )
 
     def test_anonymize_added_participant_event(self):
         """added participant event is anonymized by user.anonymize_data"""
@@ -72,14 +82,17 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
 
         user.anonymize_data()
 
-        event = Post.objects.get(event_type='added_participant')
-        self.assertEqual(event.event_context, {
-            'user': {
-                'id': None,
-                'username': user.username,
-                'url': reverse('misago:index'),
+        event = Post.objects.get(event_type="added_participant")
+        self.assertEqual(
+            event.event_context,
+            {
+                "user": {
+                    "id": None,
+                    "username": user.username,
+                    "url": reverse("misago:index"),
+                }
             },
-        })
+        )
 
     def test_anonymize_owner_left_event(self):
         """owner left event is anonymized by user.anonymize_data"""
@@ -95,14 +108,17 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
 
         user.anonymize_data()
 
-        event = Post.objects.get(event_type='owner_left')
-        self.assertEqual(event.event_context, {
-            'user': {
-                'id': None,
-                'username': user.username,
-                'url': reverse('misago:index'),
+        event = Post.objects.get(event_type="owner_left")
+        self.assertEqual(
+            event.event_context,
+            {
+                "user": {
+                    "id": None,
+                    "username": user.username,
+                    "url": reverse("misago:index"),
+                }
             },
-        })
+        )
 
     def test_anonymize_removed_owner_event(self):
         """removed owner event is anonymized by user.anonymize_data"""
@@ -112,20 +128,23 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
         set_owner(self.thread, user)
         make_participants_aware(user, self.thread)
         add_participant(request, self.thread, self.user)
-        
+
         make_participants_aware(user, self.thread)
         remove_participant(request, self.thread, user)
 
         user.anonymize_data()
 
-        event = Post.objects.get(event_type='removed_owner')
-        self.assertEqual(event.event_context, {
-            'user': {
-                'id': None,
-                'username': user.username,
-                'url': reverse('misago:index'),
+        event = Post.objects.get(event_type="removed_owner")
+        self.assertEqual(
+            event.event_context,
+            {
+                "user": {
+                    "id": None,
+                    "username": user.username,
+                    "url": reverse("misago:index"),
+                }
             },
-        })
+        )
 
     def test_anonymize_participant_left_event(self):
         """participant left event is anonymized by user.anonymize_data"""
@@ -141,15 +160,18 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
 
         user.anonymize_data()
 
-        event = Post.objects.get(event_type='participant_left')
-        self.assertEqual(event.event_context, {
-            'user': {
-                'id': None,
-                'username': user.username,
-                'url': reverse('misago:index'),
+        event = Post.objects.get(event_type="participant_left")
+        self.assertEqual(
+            event.event_context,
+            {
+                "user": {
+                    "id": None,
+                    "username": user.username,
+                    "url": reverse("misago:index"),
+                }
             },
-        })
-        
+        )
+
     def test_anonymize_removed_participant_event(self):
         """removed participant event is anonymized by user.anonymize_data"""
         user = get_mock_user()
@@ -164,14 +186,17 @@ class AnonymizeEventsTests(AuthenticatedUserTestCase):
 
         user.anonymize_data()
 
-        event = Post.objects.get(event_type='removed_participant')
-        self.assertEqual(event.event_context, {
-            'user': {
-                'id': None,
-                'username': user.username,
-                'url': reverse('misago:index'),
+        event = Post.objects.get(event_type="removed_participant")
+        self.assertEqual(
+            event.event_context,
+            {
+                "user": {
+                    "id": None,
+                    "username": user.username,
+                    "url": reverse("misago:index"),
+                }
             },
-        })
+        )
 
 
 class AnonymizeLikesTests(AuthenticatedUserTestCase):
@@ -180,18 +205,18 @@ class AnonymizeLikesTests(AuthenticatedUserTestCase):
         self.factory = RequestFactory()
 
     def get_request(self, user=None):
-        request = self.factory.get('/customer/details')
+        request = self.factory.get("/customer/details")
         request.user = user or self.user
-        request.user_ip = '127.0.0.1'
+        request.user_ip = "127.0.0.1"
 
         return request
 
     def test_anonymize_user_likes(self):
         """post's last like is anonymized by user.anonymize_data"""
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         thread = testutils.post_thread(category)
         post = testutils.reply_thread(thread)
-        post.acl = {'can_like': True}
+        post.acl = {"can_like": True}
 
         user = get_mock_user()
 
@@ -201,16 +226,13 @@ class AnonymizeLikesTests(AuthenticatedUserTestCase):
         user.anonymize_data()
 
         last_likes = Post.objects.get(pk=post.pk).last_likes
-        self.assertEqual(last_likes, [
-            {
-                'id': None,
-                'username': user.username,
-            },
-            {
-                'id': self.user.id,
-                'username': self.user.username,
-            },
-        ])
+        self.assertEqual(
+            last_likes,
+            [
+                {"id": None, "username": user.username},
+                {"id": self.user.id, "username": self.user.username},
+            ],
+        )
 
 
 class AnonymizePostsTests(AuthenticatedUserTestCase):
@@ -219,15 +241,15 @@ class AnonymizePostsTests(AuthenticatedUserTestCase):
         self.factory = RequestFactory()
 
     def get_request(self, user=None):
-        request = self.factory.get('/customer/details')
+        request = self.factory.get("/customer/details")
         request.user = user or self.user
-        request.user_ip = '127.0.0.1'
+        request.user_ip = "127.0.0.1"
 
         return request
 
     def test_anonymize_user_posts(self):
         """post is anonymized by user.anonymize_data"""
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         thread = testutils.post_thread(category)
 
         user = get_mock_user()
@@ -235,4 +257,4 @@ class AnonymizePostsTests(AuthenticatedUserTestCase):
         user.anonymize_data()
 
         anonymized_post = Post.objects.get(pk=post.pk)
-        self.assertTrue(anonymized_post.is_valid)
+        self.assertTrue(anonymized_post.is_valid)

+ 27 - 47
misago/threads/tests/test_attachmentadmin_views.py

@@ -10,12 +10,12 @@ class AttachmentAdminViewsTests(AdminTestCase):
     def setUp(self):
         super().setUp()
 
-        self.category = Category.objects.get(slug='first-category')
+        self.category = Category.objects.get(slug="first-category")
         self.post = testutils.post_thread(category=self.category).first_post
 
-        self.filetype = AttachmentType.objects.order_by('id').first()
+        self.filetype = AttachmentType.objects.order_by("id").first()
 
-        self.admin_link = reverse('misago:admin:system:attachments:index')
+        self.admin_link = reverse("misago:admin:system:attachments:index")
 
     def mock_attachment(self, post=None, file=None, image=None, thumbnail=None):
         return Attachment.objects.create(
@@ -26,7 +26,7 @@ class AttachmentAdminViewsTests(AdminTestCase):
             uploader=self.user,
             uploader_name=self.user.username,
             uploader_slug=self.user.slug,
-            filename='testfile_%s.zip' % (Attachment.objects.count() + 1),
+            filename="testfile_%s.zip" % (Attachment.objects.count() + 1),
             file=None,
             image=None,
             thumbnail=None,
@@ -34,23 +34,21 @@ class AttachmentAdminViewsTests(AdminTestCase):
 
     def test_link_registered(self):
         """admin nav contains attachments link"""
-        response = self.client.get(reverse('misago:admin:system:settings:index'))
+        response = self.client.get(reverse("misago:admin:system:settings:index"))
         self.assertContains(response, self.admin_link)
 
     def test_list_view(self):
         """attachments list returns 200 and renders all attachments"""
-        final_link = self.client.get(self.admin_link)['location']
+        final_link = self.client.get(self.admin_link)["location"]
 
         response = self.client.get(final_link)
         self.assertEqual(response.status_code, 200)
 
         attachments = [
-            self.mock_attachment(self.post, file='somefile.pdf'),
-            self.mock_attachment(image='someimage.jpg'),
+            self.mock_attachment(self.post, file="somefile.pdf"),
+            self.mock_attachment(image="someimage.jpg"),
             self.mock_attachment(
-                self.post,
-                image='somelargeimage.png',
-                thumbnail='somethumb.png',
+                self.post, image="somelargeimage.png", thumbnail="somethumb.png"
             ),
         ]
 
@@ -59,9 +57,7 @@ class AttachmentAdminViewsTests(AdminTestCase):
 
         for attachment in attachments:
             delete_link = reverse(
-                'misago:admin:system:attachments:delete', kwargs={
-                    'pk': attachment.pk,
-                }
+                "misago:admin:system:attachments:delete", kwargs={"pk": attachment.pk}
             )
             self.assertContains(response, attachment.filename)
             self.assertContains(response, delete_link)
@@ -75,74 +71,58 @@ class AttachmentAdminViewsTests(AdminTestCase):
     def test_delete_multiple(self):
         """mass delete tool on list works"""
         attachments = [
-            self.mock_attachment(self.post, file='somefile.pdf'),
-            self.mock_attachment(image='someimage.jpg'),
+            self.mock_attachment(self.post, file="somefile.pdf"),
+            self.mock_attachment(image="someimage.jpg"),
             self.mock_attachment(
-                self.post,
-                image='somelargeimage.png',
-                thumbnail='somethumb.png',
+                self.post, image="somelargeimage.png", thumbnail="somethumb.png"
             ),
         ]
 
-        self.post.attachments_cache = [{'id': attachments[-1].pk}]
+        self.post.attachments_cache = [{"id": attachments[-1].pk}]
         self.post.save()
 
         response = self.client.post(
             self.admin_link,
-            data={
-                'action': 'delete',
-                'selected_items': [a.pk for a in attachments],
-            }
+            data={"action": "delete", "selected_items": [a.pk for a in attachments]},
         )
         self.assertEqual(response.status_code, 302)
 
         self.assertEqual(Attachment.objects.count(), 0)
 
         # assert attachments were removed from post's cache
-        attachments_cache = self.category.post_set.get(pk=self.post.pk).attachments_cache
+        attachments_cache = self.category.post_set.get(
+            pk=self.post.pk
+        ).attachments_cache
         self.assertIsNone(attachments_cache)
 
     def test_delete_view(self):
         """delete attachment view has no showstoppers"""
         attachment = self.mock_attachment(self.post)
         self.post.attachments_cache = [
-            {
-                'id': attachment.pk + 1
-            },
-            {
-                'id': attachment.pk
-            },
-            {
-                'id': attachment.pk + 2
-            },
+            {"id": attachment.pk + 1},
+            {"id": attachment.pk},
+            {"id": attachment.pk + 2},
         ]
         self.post.save()
 
         action_link = reverse(
-            'misago:admin:system:attachments:delete', kwargs={
-                'pk': attachment.pk,
-            }
+            "misago:admin:system:attachments:delete", kwargs={"pk": attachment.pk}
         )
 
         response = self.client.post(action_link)
         self.assertEqual(response.status_code, 302)
 
         # clean alert about item, grab final list url
-        final_link = self.client.get(self.admin_link)['location']
+        final_link = self.client.get(self.admin_link)["location"]
 
         response = self.client.get(final_link)
         self.assertEqual(response.status_code, 200)
         self.assertNotContains(response, action_link)
 
         # assert it was removed from post's attachments cache
-        attachments_cache = self.category.post_set.get(pk=self.post.pk).attachments_cache
+        attachments_cache = self.category.post_set.get(
+            pk=self.post.pk
+        ).attachments_cache
         self.assertEqual(
-            attachments_cache, [
-                {
-                    'id': attachment.pk + 1,
-                },
-                {
-                    'id': attachment.pk + 2,
-                },
-            ]
+            attachments_cache, [{"id": attachment.pk + 1}, {"id": attachment.pk + 2}]
         )

+ 128 - 186
misago/threads/tests/test_attachments_api.py

@@ -10,12 +10,12 @@ from misago.conf import settings
 from misago.threads.models import Attachment, AttachmentType
 from misago.users.testutils import AuthenticatedUserTestCase
 
-TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
-TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
-TEST_LARGEPNG_PATH = os.path.join(TESTFILES_DIR, 'large.png')
-TEST_SMALLJPG_PATH = os.path.join(TESTFILES_DIR, 'small.jpg')
-TEST_ANIMATEDGIF_PATH = os.path.join(TESTFILES_DIR, 'animated.gif')
-TEST_CORRUPTEDIMG_PATH = os.path.join(TESTFILES_DIR, 'corrupted.gif')
+TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testfiles")
+TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, "document.pdf")
+TEST_LARGEPNG_PATH = os.path.join(TESTFILES_DIR, "large.png")
+TEST_SMALLJPG_PATH = os.path.join(TESTFILES_DIR, "small.jpg")
+TEST_ANIMATEDGIF_PATH = os.path.join(TESTFILES_DIR, "animated.gif")
+TEST_CORRUPTEDIMG_PATH = os.path.join(TESTFILES_DIR, "corrupted.gif")
 
 
 class AttachmentsApiTestCase(AuthenticatedUserTestCase):
@@ -24,7 +24,7 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
 
         AttachmentType.objects.all().delete()
 
-        self.api_link = reverse('misago:api:attachment-list')
+        self.api_link = reverse("misago:api:attachment-list")
 
     def test_anonymous(self):
         """user has to be authenticated to be able to upload files"""
@@ -38,201 +38,157 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         """user needs permission to upload files"""
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You don't have permission to upload new files.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You don't have permission to upload new files."},
+        )
 
     def test_no_file_uploaded(self):
         """no file uploaded scenario is handled"""
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'detail': "No file has been uploaded.",
-        })
+        self.assertEqual(response.json(), {"detail": "No file has been uploaded."})
 
     def test_invalid_extension(self):
         """uploaded file's extension is rejected as invalid"""
         AttachmentType.objects.create(
-            name="Test extension",
-            extensions='jpg,jpeg',
-            mimetypes=None,
+            name="Test extension", extensions="jpg,jpeg", mimetypes=None
         )
 
-        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_DOCUMENT_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
             self.assertEqual(response.status_code, 400)
-            self.assertEqual(response.json(), {
-                'detail': "You can't upload files of this type.",
-            })
+            self.assertEqual(
+                response.json(), {"detail": "You can't upload files of this type."}
+            )
 
     def test_invalid_mime(self):
         """uploaded file's mimetype is rejected as invalid"""
         AttachmentType.objects.create(
-            name="Test extension",
-            extensions='png',
-            mimetypes='loremipsum',
+            name="Test extension", extensions="png", mimetypes="loremipsum"
         )
 
-        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_DOCUMENT_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
             self.assertEqual(response.status_code, 400)
-            self.assertEqual(response.json(), {
-                'detail': "You can't upload files of this type.",
-            })
+            self.assertEqual(
+                response.json(), {"detail": "You can't upload files of this type."}
+            )
 
     def test_no_perm_to_type(self):
         """user needs permission to upload files of this type"""
         attachment_type = AttachmentType.objects.create(
-            name="Test extension",
-            extensions='png',
-            mimetypes='application/pdf',
+            name="Test extension", extensions="png", mimetypes="application/pdf"
         )
 
         user_roles = (r.pk for r in self.user.get_roles())
         attachment_type.limit_uploads_to.set(Role.objects.exclude(id__in=user_roles))
 
-        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_DOCUMENT_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
             self.assertEqual(response.status_code, 400)
-            self.assertEqual(response.json(), {
-                'detail': "You can't upload files of this type.",
-            })
+            self.assertEqual(
+                response.json(), {"detail": "You can't upload files of this type."}
+            )
 
     def test_type_is_locked(self):
         """new uploads for this filetype are locked"""
         AttachmentType.objects.create(
             name="Test extension",
-            extensions='png',
-            mimetypes='application/pdf',
+            extensions="png",
+            mimetypes="application/pdf",
             status=AttachmentType.LOCKED,
         )
 
-        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_DOCUMENT_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
             self.assertEqual(response.status_code, 400)
-            self.assertEqual(response.json(), {
-                'detail': "You can't upload files of this type.",
-            })
+            self.assertEqual(
+                response.json(), {"detail": "You can't upload files of this type."}
+            )
 
     def test_type_is_disabled(self):
         """new uploads for this filetype are disabled"""
         AttachmentType.objects.create(
             name="Test extension",
-            extensions='png',
-            mimetypes='application/pdf',
+            extensions="png",
+            mimetypes="application/pdf",
             status=AttachmentType.DISABLED,
         )
 
-        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_DOCUMENT_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
             self.assertEqual(response.status_code, 400)
-            self.assertEqual(response.json(), {
-                'detail': "You can't upload files of this type.",
-            })
+            self.assertEqual(
+                response.json(), {"detail": "You can't upload files of this type."}
+            )
 
     def test_upload_too_big_for_type(self):
         """too big uploads are rejected"""
         AttachmentType.objects.create(
             name="Test extension",
-            extensions='png',
-            mimetypes='image/png',
+            extensions="png",
+            mimetypes="image/png",
             size_limit=100,
         )
 
-        with open(TEST_LARGEPNG_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_LARGEPNG_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
             self.assertEqual(response.status_code, 400)
-            self.assertEqual(response.json(), {
-                'detail': (
-                    "You can't upload files of this type larger "
-                    "than 100.0\xa0KB (your file has 253.9\xa0KB)."
-                ),
-            })
+            self.assertEqual(
+                response.json(),
+                {
+                    "detail": (
+                        "You can't upload files of this type larger "
+                        "than 100.0\xa0KB (your file has 253.9\xa0KB)."
+                    )
+                },
+            )
 
     @patch_user_acl({"max_attachment_size": 100})
     def test_upload_too_big_for_user(self):
         """too big uploads are rejected"""
         AttachmentType.objects.create(
-            name="Test extension",
-            extensions='png',
-            mimetypes='image/png',
+            name="Test extension", extensions="png", mimetypes="image/png"
         )
 
-        with open(TEST_LARGEPNG_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_LARGEPNG_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
             self.assertEqual(response.status_code, 400)
-            self.assertEqual(response.json(), {
-                'detail': (
-                    "You can't upload files larger than 100.0\xa0KB (your file has 253.9\xa0KB)."
-                ),
-            })
+            self.assertEqual(
+                response.json(),
+                {
+                    "detail": (
+                        "You can't upload files larger than 100.0\xa0KB (your file has 253.9\xa0KB)."
+                    )
+                },
+            )
 
     def test_corrupted_image_upload(self):
         """corrupted image upload is handled"""
-        AttachmentType.objects.create(
-            name="Test extension",
-            extensions='gif',
-        )
+        AttachmentType.objects.create(name="Test extension", extensions="gif")
 
-        with open(TEST_CORRUPTEDIMG_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_CORRUPTEDIMG_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
             self.assertEqual(response.status_code, 400)
-            self.assertEqual(response.json(), {
-                'detail': "Uploaded image was corrupted or invalid.",
-            })
+            self.assertEqual(
+                response.json(), {"detail": "Uploaded image was corrupted or invalid."}
+            )
 
     def test_document_upload(self):
         """successful upload creates orphan attachment"""
         AttachmentType.objects.create(
-            name="Test extension",
-            extensions='pdf',
-            mimetypes='application/pdf',
+            name="Test extension", extensions="pdf", mimetypes="application/pdf"
         )
 
-        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_DOCUMENT_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        attachment = Attachment.objects.get(id=response_json['id'])
+        attachment = Attachment.objects.get(id=response_json["id"])
 
-        self.assertEqual(attachment.filename, 'document.pdf')
+        self.assertEqual(attachment.filename, "document.pdf")
         self.assertTrue(attachment.is_file)
         self.assertFalse(attachment.is_image)
 
@@ -240,13 +196,13 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         self.assertTrue(not attachment.image)
         self.assertTrue(not attachment.thumbnail)
 
-        self.assertTrue(str(attachment.file).endswith('document.pdf'))
+        self.assertTrue(str(attachment.file).endswith("document.pdf"))
 
-        self.assertIsNone(response_json['post'])
-        self.assertEqual(response_json['uploader_name'], self.user.username)
-        self.assertEqual(response_json['url']['index'], attachment.get_absolute_url())
-        self.assertIsNone(response_json['url']['thumb'])
-        self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
+        self.assertIsNone(response_json["post"])
+        self.assertEqual(response_json["uploader_name"], self.user.username)
+        self.assertEqual(response_json["url"]["index"], attachment.get_absolute_url())
+        self.assertIsNone(response_json["url"]["thumb"])
+        self.assertEqual(response_json["url"]["uploader"], self.user.get_absolute_url())
 
         self.assertEqual(self.user.audittrail_set.count(), 1)
 
@@ -259,23 +215,17 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
     def test_small_image_upload(self):
         """successful small image upload creates orphan attachment without thumbnail"""
         AttachmentType.objects.create(
-            name="Test extension",
-            extensions='jpeg,jpg',
-            mimetypes='image/jpeg',
+            name="Test extension", extensions="jpeg,jpg", mimetypes="image/jpeg"
         )
 
-        with open(TEST_SMALLJPG_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_SMALLJPG_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        attachment = Attachment.objects.get(id=response_json['id'])
+        attachment = Attachment.objects.get(id=response_json["id"])
 
-        self.assertEqual(attachment.filename, 'small.jpg')
+        self.assertEqual(attachment.filename, "small.jpg")
         self.assertFalse(attachment.is_file)
         self.assertTrue(attachment.is_image)
 
@@ -283,13 +233,13 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         self.assertIsNotNone(attachment.image)
         self.assertTrue(not attachment.thumbnail)
 
-        self.assertTrue(str(attachment.image).endswith('small.jpg'))
+        self.assertTrue(str(attachment.image).endswith("small.jpg"))
 
-        self.assertIsNone(response_json['post'])
-        self.assertEqual(response_json['uploader_name'], self.user.username)
-        self.assertEqual(response_json['url']['index'], attachment.get_absolute_url())
-        self.assertIsNone(response_json['url']['thumb'])
-        self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
+        self.assertIsNone(response_json["post"])
+        self.assertEqual(response_json["uploader_name"], self.user.username)
+        self.assertEqual(response_json["url"]["index"], attachment.get_absolute_url())
+        self.assertIsNone(response_json["url"]["thumb"])
+        self.assertEqual(response_json["url"]["uploader"], self.user.get_absolute_url())
 
         self.assertEqual(self.user.audittrail_set.count(), 1)
 
@@ -297,23 +247,17 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
     def test_large_image_upload(self):
         """successful large image upload creates orphan attachment with thumbnail"""
         AttachmentType.objects.create(
-            name="Test extension",
-            extensions='png',
-            mimetypes='image/png',
+            name="Test extension", extensions="png", mimetypes="image/png"
         )
 
-        with open(TEST_LARGEPNG_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_LARGEPNG_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        attachment = Attachment.objects.get(id=response_json['id'])
+        attachment = Attachment.objects.get(id=response_json["id"])
 
-        self.assertEqual(attachment.filename, 'large.png')
+        self.assertEqual(attachment.filename, "large.png")
         self.assertFalse(attachment.is_file)
         self.assertTrue(attachment.is_image)
 
@@ -321,21 +265,25 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         self.assertIsNotNone(attachment.image)
         self.assertIsNotNone(attachment.thumbnail)
 
-        self.assertTrue(str(attachment.image).endswith('large.png'))
-        self.assertTrue(str(attachment.thumbnail).endswith('large.png'))
+        self.assertTrue(str(attachment.image).endswith("large.png"))
+        self.assertTrue(str(attachment.thumbnail).endswith("large.png"))
+
+        self.assertIsNone(response_json["post"])
+        self.assertEqual(response_json["uploader_name"], self.user.username)
+        self.assertEqual(response_json["url"]["index"], attachment.get_absolute_url())
+        self.assertEqual(response_json["url"]["thumb"], attachment.get_thumbnail_url())
+        self.assertEqual(response_json["url"]["uploader"], self.user.get_absolute_url())
 
-        self.assertIsNone(response_json['post'])
-        self.assertEqual(response_json['uploader_name'], self.user.username)
-        self.assertEqual(response_json['url']['index'], attachment.get_absolute_url())
-        self.assertEqual(response_json['url']['thumb'], attachment.get_thumbnail_url())
-        self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
-        
         self.assertEqual(self.user.audittrail_set.count(), 1)
 
         # thumbnail was scaled down
         thumbnail = Image.open(attachment.thumbnail.path)
-        self.assertEqual(thumbnail.size[0], settings.MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT[0])
-        self.assertLess(thumbnail.size[1], settings.MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT[1])
+        self.assertEqual(
+            thumbnail.size[0], settings.MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT[0]
+        )
+        self.assertLess(
+            thumbnail.size[1], settings.MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT[1]
+        )
 
         # files associated with attachment are deleted on its deletion
         image_path = attachment.image.path
@@ -352,23 +300,17 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
     def test_animated_image_upload(self):
         """successful gif upload creates orphan attachment with thumbnail"""
         AttachmentType.objects.create(
-            name="Test extension",
-            extensions='gif',
-            mimetypes='image/gif',
+            name="Test extension", extensions="gif", mimetypes="image/gif"
         )
 
-        with open(TEST_ANIMATEDGIF_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_ANIMATEDGIF_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        attachment = Attachment.objects.get(id=response_json['id'])
+        attachment = Attachment.objects.get(id=response_json["id"])
 
-        self.assertEqual(attachment.filename, 'animated.gif')
+        self.assertEqual(attachment.filename, "animated.gif")
         self.assertFalse(attachment.is_file)
         self.assertTrue(attachment.is_image)
 
@@ -376,13 +318,13 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         self.assertIsNotNone(attachment.image)
         self.assertIsNotNone(attachment.thumbnail)
 
-        self.assertTrue(str(attachment.image).endswith('animated.gif'))
-        self.assertTrue(str(attachment.thumbnail).endswith('animated.gif'))
+        self.assertTrue(str(attachment.image).endswith("animated.gif"))
+        self.assertTrue(str(attachment.thumbnail).endswith("animated.gif"))
+
+        self.assertIsNone(response_json["post"])
+        self.assertEqual(response_json["uploader_name"], self.user.username)
+        self.assertEqual(response_json["url"]["index"], attachment.get_absolute_url())
+        self.assertEqual(response_json["url"]["thumb"], attachment.get_thumbnail_url())
+        self.assertEqual(response_json["url"]["uploader"], self.user.get_absolute_url())
 
-        self.assertIsNone(response_json['post'])
-        self.assertEqual(response_json['uploader_name'], self.user.username)
-        self.assertEqual(response_json['url']['index'], attachment.get_absolute_url())
-        self.assertEqual(response_json['url']['thumb'], attachment.get_thumbnail_url())
-        self.assertEqual(response_json['url']['uploader'], self.user.get_absolute_url())
-        
         self.assertEqual(self.user.audittrail_set.count(), 1)

+ 45 - 49
misago/threads/tests/test_attachments_middleware.py

@@ -10,7 +10,9 @@ from misago.conftest import get_cache_versions
 from misago.threads import testutils
 from misago.threads.api.postingendpoint import PostingEndpoint
 from misago.threads.api.postingendpoint.attachments import (
-    AttachmentsMiddleware, validate_attachments_count)
+    AttachmentsMiddleware,
+    validate_attachments_count,
+)
 from misago.threads.models import Attachment, AttachmentType
 from misago.users.testutils import AuthenticatedUserTestCase
 
@@ -27,13 +29,13 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
     def setUp(self):
         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.post = self.thread.first_post
 
         self.post.update_fields = []
 
-        self.filetype = AttachmentType.objects.order_by('id').last()
+        self.filetype = AttachmentType.objects.order_by("id").last()
 
     def mock_attachment(self, user=True, post=None):
         return Attachment.objects.create(
@@ -44,17 +46,17 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             uploader=self.user if user else None,
             uploader_name=self.user.username,
             uploader_slug=self.user.slug,
-            filename='testfile_%s.zip' % (Attachment.objects.count() + 1),
+            filename="testfile_%s.zip" % (Attachment.objects.count() + 1),
         )
 
     def test_use_this_middleware(self):
         """use_this_middleware returns False if we can't upload attachments"""
-        with patch_user_acl({'max_attachment_size': 0}):
+        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())
 
-        with patch_user_acl({'max_attachment_size': 1024}):
+        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())
@@ -62,7 +64,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
     @patch_attachments_acl()
     def test_middleware_is_optional(self):
         """middleware is optional"""
-        INPUTS = [{}, {'attachments': []}]
+        INPUTS = [{}, {"attachments": []}]
 
         user_acl = useracl.get_user_acl(self.user, cache_versions)
 
@@ -81,15 +83,17 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
     @patch_attachments_acl()
     def test_middleware_validates_ids(self):
         """middleware validates attachments ids"""
-        INPUTS = ['none', ['a', 'b', 123], range(settings.MISAGO_POST_ATTACHMENTS_LIMIT + 1)]
+        INPUTS = [
+            "none",
+            ["a", "b", 123],
+            range(settings.MISAGO_POST_ATTACHMENTS_LIMIT + 1),
+        ]
 
         user_acl = useracl.get_user_acl(self.user, cache_versions)
 
         for test_input in INPUTS:
             middleware = AttachmentsMiddleware(
-                request=Mock(data={
-                    'attachments': test_input
-                }),
+                request=Mock(data={"attachments": test_input}),
                 mode=PostingEndpoint.START,
                 user=self.user,
                 user_acl=user_acl,
@@ -97,7 +101,9 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             )
 
             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):
@@ -147,11 +153,12 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
 
         # only own orphaned attachments may be assigned to posts
         other_user_attachment = self.mock_attachment(user=False)
-        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, [])
 
-    
-    @patch_attachments_acl({'can_delete_other_users_attachments': False})
+    @patch_attachments_acl({"can_delete_other_users_attachments": False})
     def test_cant_delete_attachment(self):
         """middleware validates if we have permission to delete other users attachments"""
         attachment = self.mock_attachment(user=False, post=self.post)
@@ -159,9 +166,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
 
         user_acl = useracl.get_user_acl(self.user, cache_versions)
         serializer = AttachmentsMiddleware(
-            request=Mock(data={
-                'attachments': []
-            }),
+            request=Mock(data={"attachments": []}),
             mode=PostingEndpoint.EDIT,
             user=self.user,
             user_acl=user_acl,
@@ -173,16 +178,11 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
     @patch_attachments_acl()
     def test_add_attachments(self):
         """middleware adds attachments to post"""
-        attachments = [
-            self.mock_attachment(),
-            self.mock_attachment(),
-        ]
+        attachments = [self.mock_attachment(), self.mock_attachment()]
 
         user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
-            request=Mock(data={
-                'attachments': [a.pk for a in attachments]
-            }),
+            request=Mock(data={"attachments": [a.pk for a in attachments]}),
             mode=PostingEndpoint.EDIT,
             user=self.user,
             user_acl=user_acl,
@@ -194,12 +194,13 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         middleware.save(serializer)
 
         # attachments were associated with post
-        self.assertEqual(self.post.update_fields, ['attachments_cache'])
+        self.assertEqual(self.post.update_fields, ["attachments_cache"])
         self.assertEqual(self.post.attachment_set.count(), 2)
 
         attachments_filenames = list(reversed([a.filename for a in attachments]))
-        self.assertEqual([a['filename'] for a in self.post.attachments_cache],
-                         attachments_filenames)
+        self.assertEqual(
+            [a["filename"] for a in self.post.attachments_cache], attachments_filenames
+        )
 
     @patch_attachments_acl()
     def test_remove_attachments(self):
@@ -211,9 +212,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
 
         user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
-            request=Mock(data={
-                'attachments': [attachments[0].pk]
-            }),
+            request=Mock(data={"attachments": [attachments[0].pk]}),
             mode=PostingEndpoint.EDIT,
             user=self.user,
             user_acl=user_acl,
@@ -225,30 +224,26 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         middleware.save(serializer)
 
         # attachments were associated with post
-        self.assertEqual(self.post.update_fields, ['attachments_cache'])
+        self.assertEqual(self.post.update_fields, ["attachments_cache"])
         self.assertEqual(self.post.attachment_set.count(), 1)
 
         self.assertEqual(Attachment.objects.count(), 1)
 
         attachments_filenames = [attachments[0].filename]
-        self.assertEqual([a['filename'] for a in self.post.attachments_cache],
-                         attachments_filenames)
+        self.assertEqual(
+            [a["filename"] for a in self.post.attachments_cache], attachments_filenames
+        )
 
     @patch_attachments_acl()
     def test_steal_attachments(self):
         """middleware validates if attachments are already assigned to other posts"""
         other_post = testutils.reply_thread(self.thread)
 
-        attachments = [
-            self.mock_attachment(post=other_post),
-            self.mock_attachment(),
-        ]
+        attachments = [self.mock_attachment(post=other_post), self.mock_attachment()]
 
         user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
-            request=Mock(data={
-                'attachments': [attachments[0].pk, attachments[1].pk]
-            }),
+            request=Mock(data={"attachments": [attachments[0].pk, attachments[1].pk]}),
             mode=PostingEndpoint.EDIT,
             user=self.user,
             user_acl=user_acl,
@@ -260,7 +255,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         middleware.save(serializer)
 
         # only unassociated attachment was associated with post
-        self.assertEqual(self.post.update_fields, ['attachments_cache'])
+        self.assertEqual(self.post.update_fields, ["attachments_cache"])
         self.assertEqual(self.post.attachment_set.count(), 1)
 
         self.assertEqual(Attachment.objects.get(pk=attachments[0].pk).post, other_post)
@@ -277,9 +272,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
 
         user_acl = useracl.get_user_acl(self.user, cache_versions)
         middleware = AttachmentsMiddleware(
-            request=Mock(data={
-                'attachments': [attachments[0].pk, attachments[2].pk]
-            }),
+            request=Mock(data={"attachments": [attachments[0].pk, attachments[2].pk]}),
             mode=PostingEndpoint.EDIT,
             user=self.user,
             user_acl=user_acl,
@@ -291,12 +284,13 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         middleware.save(serializer)
 
         # attachments were associated with post
-        self.assertEqual(self.post.update_fields, ['attachments_cache'])
+        self.assertEqual(self.post.update_fields, ["attachments_cache"])
         self.assertEqual(self.post.attachment_set.count(), 2)
 
         attachments_filenames = [attachments[2].filename, attachments[0].filename]
-        self.assertEqual([a['filename'] for a in self.post.attachments_cache],
-                         attachments_filenames)
+        self.assertEqual(
+            [a["filename"] for a in self.post.attachments_cache], attachments_filenames
+        )
 
 
 class ValidateAttachmentsCountTests(AuthenticatedUserTestCase):
@@ -305,4 +299,6 @@ class ValidateAttachmentsCountTests(AuthenticatedUserTestCase):
         validate_attachments_count(range(settings.MISAGO_POST_ATTACHMENTS_LIMIT))
 
         with self.assertRaises(serializers.ValidationError):
-            validate_attachments_count(range(settings.MISAGO_POST_ATTACHMENTS_LIMIT + 1))
+            validate_attachments_count(
+                range(settings.MISAGO_POST_ATTACHMENTS_LIMIT + 1)
+            )

+ 75 - 81
misago/threads/tests/test_attachmenttypeadmin_views.py

@@ -8,11 +8,11 @@ from misago.threads.models import AttachmentType
 class AttachmentTypeAdminViewsTests(AdminTestCase):
     def setUp(self):
         super().setUp()
-        self.admin_link = reverse('misago:admin:system:attachment-types:index')
+        self.admin_link = reverse("misago:admin:system:attachment-types:index")
 
     def test_link_registered(self):
         """admin nav contains attachment types link"""
-        response = self.client.get(reverse('misago:admin:system:settings:index'))
+        response = self.client.get(reverse("misago:admin:system:settings:index"))
         self.assertContains(response, self.admin_link)
 
     def test_list_view(self):
@@ -29,7 +29,7 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
 
     def test_new_view(self):
         """new attachment type view has no showstoppers"""
-        form_link = reverse('misago:admin:system:attachment-types:new')
+        form_link = reverse("misago:admin:system:attachment-types:new")
 
         response = self.client.get(form_link)
         self.assertEqual(response.status_code, 200)
@@ -40,11 +40,11 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
         response = self.client.post(
             form_link,
             data={
-                'name': 'Test type',
-                'extensions': '.test',
-                'size_limit': 0,
-                'status': AttachmentType.ENABLED,
-            }
+                "name": "Test type",
+                "extensions": ".test",
+                "size_limit": 0,
+                "status": AttachmentType.ENABLED,
+            },
         )
         self.assertEqual(response.status_code, 302)
 
@@ -53,28 +53,26 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
 
         response = self.client.get(self.admin_link)
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'Test type')
-        self.assertContains(response, 'test')
+        self.assertContains(response, "Test type")
+        self.assertContains(response, "test")
 
     def test_edit_view(self):
         """edit attachment type view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:system:attachment-types:new'),
+            reverse("misago:admin:system:attachment-types:new"),
             data={
-                'name': 'Test type',
-                'extensions': '.test',
-                'size_limit': 0,
-                'status': AttachmentType.ENABLED,
-            }
+                "name": "Test type",
+                "extensions": ".test",
+                "size_limit": 0,
+                "status": AttachmentType.ENABLED,
+            },
         )
 
-        test_type = AttachmentType.objects.order_by('id').last()
-        self.assertEqual(test_type.name, 'Test type')
+        test_type = AttachmentType.objects.order_by("id").last()
+        self.assertEqual(test_type.name, "Test type")
 
         form_link = reverse(
-            'misago:admin:system:attachment-types:edit', kwargs={
-                'pk': test_type.pk,
-            }
+            "misago:admin:system:attachment-types:edit", kwargs={"pk": test_type.pk}
         )
 
         response = self.client.get(form_link)
@@ -86,19 +84,19 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
         response = self.client.post(
             form_link,
             data={
-                'name': 'Test type edited',
-                'extensions': '.test.extension',
-                'mimetypes': 'test/edited-mime',
-                'size_limit': 512,
-                'status': AttachmentType.DISABLED,
-                'limit_uploads_to': [r.pk for r in Role.objects.all()],
-                'limit_downloads_to': [r.pk for r in Role.objects.all()],
-            }
+                "name": "Test type edited",
+                "extensions": ".test.extension",
+                "mimetypes": "test/edited-mime",
+                "size_limit": 512,
+                "status": AttachmentType.DISABLED,
+                "limit_uploads_to": [r.pk for r in Role.objects.all()],
+                "limit_downloads_to": [r.pk for r in Role.objects.all()],
+            },
         )
         self.assertEqual(response.status_code, 302)
 
-        test_type = AttachmentType.objects.order_by('id').last()
-        self.assertEqual(test_type.name, 'Test type edited')
+        test_type = AttachmentType.objects.order_by("id").last()
+        self.assertEqual(test_type.name, "Test type edited")
 
         # clean alert about new item created
         self.client.get(self.admin_link)
@@ -116,14 +114,14 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
         response = self.client.post(
             form_link,
             data={
-                'name': 'Test type edited',
-                'extensions': '.test.extension',
-                'mimetypes': 'test/edited-mime',
-                'size_limit': 512,
-                'status': AttachmentType.DISABLED,
-                'limit_uploads_to': [],
-                'limit_downloads_to': [],
-            }
+                "name": "Test type edited",
+                "extensions": ".test.extension",
+                "mimetypes": "test/edited-mime",
+                "size_limit": 512,
+                "status": AttachmentType.DISABLED,
+                "limit_uploads_to": [],
+                "limit_downloads_to": [],
+            },
         )
         self.assertEqual(response.status_code, 302)
 
@@ -133,50 +131,48 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
     def test_clean_params_view(self):
         """admin form nicely cleans lists of extensions/mimetypes"""
         TEST_CASES = [
-            ('test', ['test']),
-            ('.test', ['test']),
-            ('.tar.gz', ['tar.gz']),
-            ('. test', ['test']),
-            ('test, test', ['test']),
-            ('test, tEst', ['test']),
-            ('test, other, tEst', ['test', 'other']),
-            ('test, other, tEst,OTher', ['test', 'other']),
+            ("test", ["test"]),
+            (".test", ["test"]),
+            (".tar.gz", ["tar.gz"]),
+            (". test", ["test"]),
+            ("test, test", ["test"]),
+            ("test, tEst", ["test"]),
+            ("test, other, tEst", ["test", "other"]),
+            ("test, other, tEst,OTher", ["test", "other"]),
         ]
 
         for raw, final in TEST_CASES:
             response = self.client.post(
-                reverse('misago:admin:system:attachment-types:new'),
+                reverse("misago:admin:system:attachment-types:new"),
                 data={
-                    'name': 'Test type',
-                    'extensions': raw,
-                    'size_limit': 0,
-                    'status': AttachmentType.ENABLED,
-                }
+                    "name": "Test type",
+                    "extensions": raw,
+                    "size_limit": 0,
+                    "status": AttachmentType.ENABLED,
+                },
             )
             self.assertEqual(response.status_code, 302)
 
-            test_type = AttachmentType.objects.order_by('id').last()
+            test_type = AttachmentType.objects.order_by("id").last()
             self.assertEqual(set(test_type.extensions_list), set(final))
 
     def test_delete_view(self):
         """delete attachment type view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:system:attachment-types:new'),
+            reverse("misago:admin:system:attachment-types:new"),
             data={
-                'name': 'Test type',
-                'extensions': '.test',
-                'size_limit': 0,
-                'status': AttachmentType.ENABLED,
-            }
+                "name": "Test type",
+                "extensions": ".test",
+                "size_limit": 0,
+                "status": AttachmentType.ENABLED,
+            },
         )
 
-        test_type = AttachmentType.objects.order_by('id').last()
-        self.assertEqual(test_type.name, 'Test type')
+        test_type = AttachmentType.objects.order_by("id").last()
+        self.assertEqual(test_type.name, "Test type")
 
         action_link = reverse(
-            'misago:admin:system:attachment-types:delete', kwargs={
-                'pk': test_type.pk,
-            }
+            "misago:admin:system:attachment-types:delete", kwargs={"pk": test_type.pk}
         )
 
         response = self.client.post(action_link)
@@ -192,31 +188,29 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
     def test_cant_delete_type_with_attachments_view(self):
         """delete attachment type is not allowed if it has attachments associated"""
         self.client.post(
-            reverse('misago:admin:system:attachment-types:new'),
+            reverse("misago:admin:system:attachment-types:new"),
             data={
-                'name': 'Test type',
-                'extensions': '.test',
-                'size_limit': 0,
-                'status': AttachmentType.ENABLED,
-            }
+                "name": "Test type",
+                "extensions": ".test",
+                "size_limit": 0,
+                "status": AttachmentType.ENABLED,
+            },
         )
 
-        test_type = AttachmentType.objects.order_by('id').last()
-        self.assertEqual(test_type.name, 'Test type')
+        test_type = AttachmentType.objects.order_by("id").last()
+        self.assertEqual(test_type.name, "Test type")
 
         test_type.attachment_set.create(
-            secret='loremipsum',
+            secret="loremipsum",
             filetype=test_type,
-            uploader_name='Bob',
-            uploader_slug='bob',
-            filename='test.zip',
-            file='sad76asd678as687sa.zip'
+            uploader_name="Bob",
+            uploader_slug="bob",
+            filename="test.zip",
+            file="sad76asd678as687sa.zip",
         )
 
         action_link = reverse(
-            'misago:admin:system:attachment-types:delete', kwargs={
-                'pk': test_type.pk,
-            }
+            "misago:admin:system:attachment-types:delete", kwargs={"pk": test_type.pk}
         )
 
         response = self.client.post(action_link)

+ 33 - 47
misago/threads/tests/test_attachmentview.py

@@ -10,9 +10,9 @@ from misago.threads import testutils
 from misago.threads.models import Attachment, AttachmentType
 from misago.users.testutils import AuthenticatedUserTestCase
 
-TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
-TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
-TEST_SMALLJPG_PATH = os.path.join(TESTFILES_DIR, 'small.jpg')
+TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testfiles")
+TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, "document.pdf")
+TEST_SMALLJPG_PATH = os.path.join(TESTFILES_DIR, "small.jpg")
 
 
 def patch_attachments_acl(acl_patch=None):
@@ -28,30 +28,24 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
 
         AttachmentType.objects.all().delete()
 
-        self.category = Category.objects.get(slug='first-category')
+        self.category = Category.objects.get(slug="first-category")
         self.post = testutils.post_thread(category=self.category).first_post
 
-        self.api_link = reverse('misago:api:attachment-list')
+        self.api_link = reverse("misago:api:attachment-list")
 
         self.attachment_type_jpg = AttachmentType.objects.create(
-            name="JPG",
-            extensions='jpeg,jpg',
+            name="JPG", extensions="jpeg,jpg"
         )
         self.attachment_type_pdf = AttachmentType.objects.create(
-            name="PDF",
-            extensions='pdf',
+            name="PDF", extensions="pdf"
         )
 
     def upload_document(self, is_orphaned=False, by_other_user=False):
-        with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_DOCUMENT_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
         self.assertEqual(response.status_code, 200)
 
-        attachment = Attachment.objects.order_by('id').last()
+        attachment = Attachment.objects.order_by("id").last()
 
         if not is_orphaned:
             attachment.post = self.post
@@ -63,40 +57,33 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         return attachment
 
     def upload_image(self):
-        with open(TEST_SMALLJPG_PATH, 'rb') as upload:
-            response = self.client.post(
-                self.api_link, data={
-                    'upload': upload,
-                }
-            )
+        with open(TEST_SMALLJPG_PATH, "rb") as upload:
+            response = self.client.post(self.api_link, data={"upload": upload})
         self.assertEqual(response.status_code, 200)
 
-        return Attachment.objects.order_by('id').last()
+        return Attachment.objects.order_by("id").last()
 
     @patch_attachments_acl()
     def assertIs404(self, response):
         self.assertEqual(response.status_code, 302)
-        self.assertTrue(response['location'].endswith(settings.MISAGO_404_IMAGE))
+        self.assertTrue(response["location"].endswith(settings.MISAGO_404_IMAGE))
 
     @patch_attachments_acl()
     def assertIs403(self, response):
         self.assertEqual(response.status_code, 302)
-        self.assertTrue(response['location'].endswith(settings.MISAGO_403_IMAGE))
+        self.assertTrue(response["location"].endswith(settings.MISAGO_403_IMAGE))
 
     @patch_attachments_acl()
     def assertSuccess(self, response):
         self.assertEqual(response.status_code, 302)
-        self.assertFalse(response['location'].endswith(settings.MISAGO_404_IMAGE))
-        self.assertFalse(response['location'].endswith(settings.MISAGO_403_IMAGE))
+        self.assertFalse(response["location"].endswith(settings.MISAGO_404_IMAGE))
+        self.assertFalse(response["location"].endswith(settings.MISAGO_403_IMAGE))
 
     @patch_attachments_acl()
     def test_nonexistant_file(self):
         """user tries to retrieve nonexistant file"""
         response = self.client.get(
-            reverse('misago:attachment', kwargs={
-                'pk': 123,
-                'secret': 'qwertyuiop',
-            })
+            reverse("misago:attachment", kwargs={"pk": 123, "secret": "qwertyuiop"})
         )
 
         self.assertIs404(response)
@@ -107,10 +94,10 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         attachment = self.upload_document()
 
         response = self.client.get(
-            reverse('misago:attachment', kwargs={
-                'pk': attachment.pk,
-                'secret': 'qwertyuiop',
-            })
+            reverse(
+                "misago:attachment",
+                kwargs={"pk": attachment.pk, "secret": "qwertyuiop"},
+            )
         )
 
         self.assertIs404(response)
@@ -131,7 +118,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         self.assertIs404(response)
 
-        response = self.client.get(attachment.get_absolute_url() + '?shva=1')
+        response = self.client.get(attachment.get_absolute_url() + "?shva=1")
         self.assertIs404(response)
 
     @patch_attachments_acl()
@@ -141,11 +128,8 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
 
         response = self.client.get(
             reverse(
-                'misago:attachment-thumbnail',
-                kwargs={
-                    'pk': attachment.pk,
-                    'secret': attachment.secret,
-                }
+                "misago:attachment-thumbnail",
+                kwargs={"pk": attachment.pk, "secret": attachment.secret},
             )
         )
         self.assertIs404(response)
@@ -156,7 +140,9 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         attachment = self.upload_document()
 
         user_roles = (r.pk for r in self.user.get_roles())
-        self.attachment_type_pdf.limit_downloads_to.set(Role.objects.exclude(id__in=user_roles))
+        self.attachment_type_pdf.limit_downloads_to.set(
+            Role.objects.exclude(id__in=user_roles)
+        )
 
         response = self.client.get(attachment.get_absolute_url())
         self.assertIs403(response)
@@ -210,7 +196,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         self.assertIs404(response)
 
-        response = self.client.get(attachment.get_absolute_url() + '?shva=1')
+        response = self.client.get(attachment.get_absolute_url() + "?shva=1")
         self.assertSuccess(response)
 
     @patch_attachments_acl()
@@ -221,7 +207,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         response = self.client.get(attachment.get_absolute_url())
         self.assertIs404(response)
 
-        response = self.client.get(attachment.get_absolute_url() + '?shva=1')
+        response = self.client.get(attachment.get_absolute_url() + "?shva=1")
         self.assertSuccess(response)
 
     @patch_attachments_acl()
@@ -232,7 +218,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         user_roles = self.user.get_roles()
         self.attachment_type_pdf.limit_downloads_to.set(user_roles)
 
-        response = self.client.get(attachment.get_absolute_url() + '?shva=1')
+        response = self.client.get(attachment.get_absolute_url() + "?shva=1")
         self.assertSuccess(response)
 
     @patch_attachments_acl()
@@ -240,7 +226,7 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         """user retrieves """
         attachment = self.upload_image()
 
-        response = self.client.get(attachment.get_absolute_url() + '?shva=1')
+        response = self.client.get(attachment.get_absolute_url() + "?shva=1")
         self.assertSuccess(response)
 
     @patch_attachments_acl()
@@ -248,5 +234,5 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         """user retrieves image's thumbnail"""
         attachment = self.upload_image()
 
-        response = self.client.get(attachment.get_absolute_url() + '?shva=1')
+        response = self.client.get(attachment.get_absolute_url() + "?shva=1")
         self.assertSuccess(response)

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

@@ -25,10 +25,12 @@ class ClearAttachmentsTests(TestCase):
 
     def test_attachments_sync(self):
         """command synchronizes attachments"""
-        filetype = AttachmentType.objects.order_by('id').last()
+        filetype = AttachmentType.objects.order_by("id").last()
 
         # create 5 expired orphaned attachments
-        cutoff = timezone.now() - timedelta(minutes=settings.MISAGO_ATTACHMENT_ORPHANED_EXPIRE)
+        cutoff = timezone.now() - timedelta(
+            minutes=settings.MISAGO_ATTACHMENT_ORPHANED_EXPIRE
+        )
         cutoff -= timedelta(minutes=5)
 
         for _ in range(5):
@@ -37,13 +39,13 @@ class ClearAttachmentsTests(TestCase):
                 filetype=filetype,
                 size=1000,
                 uploaded_on=cutoff,
-                uploader_name='bob',
-                uploader_slug='bob',
-                filename='testfile_%s.zip' % (Attachment.objects.count() + 1),
+                uploader_name="bob",
+                uploader_slug="bob",
+                filename="testfile_%s.zip" % (Attachment.objects.count() + 1),
             )
 
         # create 5 expired non-orphaned attachments
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         post = testutils.post_thread(category).first_post
 
         for _ in range(5):
@@ -53,9 +55,9 @@ class ClearAttachmentsTests(TestCase):
                 size=1000,
                 uploaded_on=cutoff,
                 post=post,
-                uploader_name='bob',
-                uploader_slug='bob',
-                filename='testfile_%s.zip' % (Attachment.objects.count() + 1),
+                uploader_name="bob",
+                uploader_slug="bob",
+                filename="testfile_%s.zip" % (Attachment.objects.count() + 1),
             )
 
         # create 5 fresh orphaned attachments
@@ -64,9 +66,9 @@ class ClearAttachmentsTests(TestCase):
                 secret=Attachment.generate_new_secret(),
                 filetype=filetype,
                 size=1000,
-                uploader_name='bob',
-                uploader_slug='bob',
-                filename='testfile_%s.zip' % (Attachment.objects.count() + 1),
+                uploader_name="bob",
+                uploader_slug="bob",
+                filename="testfile_%s.zip" % (Attachment.objects.count() + 1),
             )
 
         command = clearattachments.Command()

+ 10 - 11
misago/threads/tests/test_delete_user_likes.py

@@ -14,7 +14,9 @@ UserModel = get_user_model()
 
 def get_mock_user():
     seed = UserModel.objects.count() + 1
-    return UserModel.objects.create_user('bob%s' % seed, 'user%s@test.com' % seed, 'Pass.123')
+    return UserModel.objects.create_user(
+        "bob%s" % seed, "user%s@test.com" % seed, "Pass.123"
+    )
 
 
 class DeleteUserLikesTests(AuthenticatedUserTestCase):
@@ -23,18 +25,18 @@ class DeleteUserLikesTests(AuthenticatedUserTestCase):
         self.factory = RequestFactory()
 
     def get_request(self, user=None):
-        request = self.factory.get('/customer/details')
+        request = self.factory.get("/customer/details")
         request.user = user or self.user
-        request.user_ip = '127.0.0.1'
+        request.user_ip = "127.0.0.1"
 
         return request
 
     def test_anonymize_user_likes(self):
         """post's last like is anonymized by user.anonymize_data"""
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         thread = testutils.post_thread(category)
         post = testutils.reply_thread(thread)
-        post.acl = {'can_like': True}
+        post.acl = {"can_like": True}
 
         user = get_mock_user()
 
@@ -44,9 +46,6 @@ class DeleteUserLikesTests(AuthenticatedUserTestCase):
         user.delete_content()
 
         last_likes = Post.objects.get(pk=post.pk).last_likes
-        self.assertEqual(last_likes, [
-            {
-                'id': self.user.id,
-                'username': self.user.username,
-            },
-        ])
+        self.assertEqual(
+            last_likes, [{"id": self.user.id, "username": self.user.username}]
+        )

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

@@ -9,9 +9,7 @@ from django.utils.encoding import smart_str
 
 from misago.categories.models import Category
 from misago.threads import testutils
-from misago.threads.test import (
-    patch_category_acl, patch_other_user_category_acl
-)
+from misago.threads.test import patch_category_acl, patch_other_user_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -22,27 +20,24 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
     def setUp(self):
         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,
-            started_on=timezone.now() - timedelta(seconds=5),
+            category=self.category, started_on=timezone.now() - timedelta(seconds=5)
         )
 
         self.api_link = reverse(
-            'misago:api:thread-post-list', kwargs={
-                'thread_pk': self.thread.pk,
-            }
+            "misago:api:thread-post-list", kwargs={"thread_pk": self.thread.pk}
         )
 
-        self.other_user = UserModel.objects.create_user('BobBobertson', 'bob@boberson.com')
+        self.other_user = UserModel.objects.create_user(
+            "BobBobertson", "bob@boberson.com"
+        )
 
     @patch_category_acl({"can_reply_threads": True})
     def test_no_subscriptions(self):
         """no emails are sent because noone subscibes to thread"""
         response = self.client.post(
-            self.api_link, data={
-                'post': 'This is test response!',
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -59,9 +54,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         )
 
         response = self.client.post(
-            self.api_link, data={
-                'post': 'This is test response!',
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -78,9 +71,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         )
 
         response = self.client.post(
-            self.api_link, data={
-                'post': 'This is test response!',
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -98,9 +89,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         )
 
         response = self.client.post(
-            self.api_link, data={
-                'post': 'This is test response!',
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -120,9 +109,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         )
 
         response = self.client.post(
-            self.api_link, data={
-                'post': 'This is test response!',
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -141,9 +128,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         testutils.reply_thread(self.thread, posted_on=timezone.now())
 
         response = self.client.post(
-            self.api_link, data={
-                'post': 'This is test response!',
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -160,9 +145,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         )
 
         response = self.client.post(
-            self.api_link, data={
-                'post': 'This is test response!',
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -178,7 +161,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         self.assertIn(self.thread.title, message)
         self.assertIn(self.thread.get_absolute_url(), message)
 
-        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)
 
     @patch_category_acl({"can_reply_threads": True})
@@ -192,9 +175,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         )
 
         response = self.client.post(
-            self.api_link, data={
-                'post': 'This is test response!',
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -210,5 +191,5 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         self.assertIn(self.thread.title, message)
         self.assertIn(self.thread.get_absolute_url(), message)
 
-        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)

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

@@ -25,11 +25,11 @@ class EventsApiTests(TestCase):
         self.thread = Thread(
             category=self.category,
             started_on=datetime,
-            starter_name='Tester',
-            starter_slug='tester',
+            starter_name="Tester",
+            starter_slug="tester",
             last_post_on=datetime,
-            last_poster_name='Tester',
-            last_poster_slug='tester',
+            last_poster_name="Tester",
+            last_poster_slug="tester",
         )
 
         self.thread.set_title("Test thread")
@@ -42,27 +42,25 @@ class EventsApiTests(TestCase):
     def test_record_event_with_context(self):
         """record_event registers event with context in thread"""
         request = Mock(user=self.user, user_ip="123.14.15.222")
-        context = {'user': 'Lorem ipsum'}
-        event = record_event(request, self.thread, 'announcement', context)
+        context = {"user": "Lorem ipsum"}
+        event = record_event(request, self.thread, "announcement", context)
 
-        event_post = self.thread.post_set.order_by('-id')[:1][0]
+        event_post = self.thread.post_set.order_by("-id")[:1][0]
         self.assertEqual(self.thread.last_post, event_post)
         self.assertTrue(self.thread.has_events)
         self.assertTrue(self.thread.last_post_is_event)
 
         self.assertEqual(event.pk, event_post.pk)
         self.assertTrue(event_post.is_event)
-        self.assertEqual(event_post.event_type, 'announcement')
+        self.assertEqual(event_post.event_type, "announcement")
         self.assertEqual(event_post.event_context, context)
         self.assertEqual(event_post.poster_id, request.user.pk)
 
     def test_record_event_is_read(self):
         """record_event makes recorded event read to its author"""
         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(
-            category=self.category,
-            thread=self.thread,
-            post=event,
+            category=self.category, thread=self.thread, post=event
         )

+ 10 - 19
misago/threads/tests/test_floodprotection.py

@@ -10,46 +10,37 @@ class FloodProtectionTests(AuthenticatedUserTestCase):
     def setUp(self):
         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.post_link = reverse(
-            'misago:api:thread-post-list', kwargs={
-                'thread_pk': self.thread.pk,
-            }
+            "misago:api:thread-post-list", kwargs={"thread_pk": self.thread.pk}
         )
 
     def test_flood_has_no_showstoppers(self):
         """endpoint handles posting interruption"""
         response = self.client.post(
-            self.post_link, data={
-                'post': "This is test response!",
-            }
+            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.post_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't post message so quickly after previous one."
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't post message so quickly after previous one."},
+        )
 
     @patch_user_acl({"can_omit_flood_protection": True})
     def test_user_with_permission_omits_flood_protection(self):
         response = self.client.post(
-            self.post_link, data={
-                'post': "This is test response!",
-            }
+            self.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.post_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)

+ 2 - 2
misago/threads/tests/test_floodprotection_middleware.py

@@ -6,7 +6,7 @@ from misago.threads.api.postingendpoint import PostingInterrupt
 from misago.threads.api.postingendpoint.floodprotection import FloodProtectionMiddleware
 from misago.users.testutils import AuthenticatedUserTestCase
 
-user_acl = {'can_omit_flood_protection': False}
+user_acl = {"can_omit_flood_protection": False}
 
 
 class FloodProtectionMiddlewareTests(AuthenticatedUserTestCase):
@@ -42,7 +42,7 @@ class FloodProtectionMiddlewareTests(AuthenticatedUserTestCase):
 
     def test_flood_permission(self):
         """middleware is respects permission to flood for team members"""
-        can_omit_flood_protection_user_acl = {'can_omit_flood_protection': True}
+        can_omit_flood_protection_user_acl = {"can_omit_flood_protection": True}
         middleware = FloodProtectionMiddleware(
             user=self.user, user_acl=can_omit_flood_protection_user_acl
         )

+ 56 - 43
misago/threads/tests/test_gotoviews.py

@@ -8,15 +8,15 @@ from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-GOTO_URL = '%s#post-%s'
-GOTO_PAGE_URL = '%s%s/#post-%s'
+GOTO_URL = "%s#post-%s"
+GOTO_PAGE_URL = "%s%s/#post-%s"
 
 
 class GotoViewTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         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)
 
 
@@ -26,11 +26,11 @@ class GotoPostTests(GotoViewTestCase):
         response = self.client.get(self.thread.first_post.get_absolute_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'],
-            GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id)
+            response["location"],
+            GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id),
         )
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, self.thread.first_post.get_absolute_url())
 
     def test_goto_last_post_on_page(self):
@@ -41,10 +41,10 @@ class GotoPostTests(GotoViewTestCase):
         response = self.client.get(post.get_absolute_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'], GOTO_URL % (self.thread.get_absolute_url(), post.pk)
+            response["location"], GOTO_URL % (self.thread.get_absolute_url(), post.pk)
         )
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, post.get_absolute_url())
 
     def test_goto_first_post_on_next_page(self):
@@ -55,10 +55,11 @@ class GotoPostTests(GotoViewTestCase):
         response = self.client.get(post.get_absolute_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk)
+            response["location"],
+            GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk),
         )
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, post.get_absolute_url())
 
     def test_goto_first_post_on_page_three_out_of_five(self):
@@ -73,10 +74,11 @@ class GotoPostTests(GotoViewTestCase):
         response = self.client.get(post.get_absolute_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 3, post.pk)
+            response["location"],
+            GOTO_PAGE_URL % (self.thread.get_absolute_url(), 3, post.pk),
         )
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, post.get_absolute_url())
 
     def test_goto_first_event_on_page_three_out_of_five(self):
@@ -97,10 +99,11 @@ class GotoPostTests(GotoViewTestCase):
         response = self.client.get(post.get_absolute_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 3, post.pk)
+            response["location"],
+            GOTO_PAGE_URL % (self.thread.get_absolute_url(), 3, post.pk),
         )
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, post.get_absolute_url())
 
 
@@ -110,11 +113,11 @@ class GotoLastTests(GotoViewTestCase):
         response = self.client.get(self.thread.get_last_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'],
-            GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id)
+            response["location"],
+            GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id),
         )
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, self.thread.last_post.get_absolute_url())
 
     def test_goto_last_post_on_page(self):
@@ -125,10 +128,10 @@ class GotoLastTests(GotoViewTestCase):
         response = self.client.get(self.thread.get_last_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'], GOTO_URL % (self.thread.get_absolute_url(), post.pk)
+            response["location"], GOTO_URL % (self.thread.get_absolute_url(), post.pk)
         )
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, post.get_absolute_url())
 
 
@@ -138,8 +141,8 @@ class GotoNewTests(GotoViewTestCase):
         response = self.client.get(self.thread.get_new_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'],
-            GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id)
+            response["location"],
+            GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id),
         )
 
     def test_goto_first_new_post(self):
@@ -153,7 +156,7 @@ class GotoNewTests(GotoViewTestCase):
         response = self.client.get(self.thread.get_new_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'], GOTO_URL % (self.thread.get_absolute_url(), post.pk)
+            response["location"], GOTO_URL % (self.thread.get_absolute_url(), post.pk)
         )
 
     def test_goto_first_new_post_on_next_page(self):
@@ -171,7 +174,8 @@ class GotoNewTests(GotoViewTestCase):
         response = self.client.get(self.thread.get_new_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk)
+            response["location"],
+            GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk),
         )
 
     def test_goto_first_new_post_in_read_thread(self):
@@ -185,7 +189,8 @@ class GotoNewTests(GotoViewTestCase):
         response = self.client.get(self.thread.get_new_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk)
+            response["location"],
+            GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk),
         )
 
     def test_guest_goto_first_new_post_in_thread(self):
@@ -198,7 +203,8 @@ class GotoNewTests(GotoViewTestCase):
         response = self.client.get(self.thread.get_new_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk)
+            response["location"],
+            GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk),
         )
 
 
@@ -208,7 +214,7 @@ class GotoBestAnswerTests(GotoViewTestCase):
         response = self.client.get(self.thread.get_best_answer_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'],
+            response["location"],
             GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id),
         )
 
@@ -227,7 +233,7 @@ class GotoBestAnswerTests(GotoViewTestCase):
         response = self.client.get(self.thread.get_best_answer_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'],
+            response["location"],
             GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, best_answer.pk),
         )
 
@@ -236,7 +242,9 @@ class GotoUnapprovedTests(GotoViewTestCase):
     def test_view_validates_permission(self):
         """view validates permission to see unapproved posts"""
         response = self.client.get(self.thread.get_unapproved_post_url())
-        self.assertContains(response, "You need permission to approve content", status_code=403)
+        self.assertContains(
+            response, "You need permission to approve content", status_code=403
+        )
 
         with patch_category_acl({"can_approve_content": True}):
             response = self.client.get(self.thread.get_unapproved_post_url())
@@ -248,8 +256,8 @@ class GotoUnapprovedTests(GotoViewTestCase):
         response = self.client.get(self.thread.get_unapproved_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'],
-            GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id)
+            response["location"],
+            GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id),
         )
 
     @patch_category_acl({"can_approve_content": True})
@@ -258,32 +266,36 @@ class GotoUnapprovedTests(GotoViewTestCase):
         for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
             testutils.reply_thread(self.thread, posted_on=timezone.now())
 
-        post = testutils.reply_thread(self.thread, is_unapproved=True, posted_on=timezone.now())
+        post = testutils.reply_thread(
+            self.thread, is_unapproved=True, posted_on=timezone.now()
+        )
         for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
             testutils.reply_thread(self.thread, posted_on=timezone.now())
 
         response = self.client.get(self.thread.get_unapproved_post_url())
         self.assertEqual(response.status_code, 302)
         self.assertEqual(
-            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk)
+            response["location"],
+            GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk),
         )
 
 
 class ThreadGotoPostTests(GotoViewTestCase):
     """brureforcing regression tests for regression test for #869"""
+
     def test_thread_growing_post_goto(self):
         """growing thread goto views don't fail"""
         for _ in range(60):
             post = testutils.reply_thread(self.thread, posted_on=timezone.now())
 
             # go to post link is valid
-            post_url = self.client.get(post.get_absolute_url())['location']
+            post_url = self.client.get(post.get_absolute_url())["location"]
 
             response = self.client.get(post_url)
             self.assertContains(response, post.get_absolute_url())
 
             # go to last post link is valid
-            last_url = self.client.get(self.thread.get_last_post_url())['location']
+            last_url = self.client.get(self.thread.get_last_post_url())["location"]
             self.assertEqual(post_url, last_url)
 
     def test_thread_growing_event_goto(self):
@@ -296,7 +308,7 @@ class ThreadGotoPostTests(GotoViewTestCase):
             post.save()
 
             # go to post link is valid
-            post_url = self.client.get(post.get_absolute_url())['location']
+            post_url = self.client.get(post.get_absolute_url())["location"]
 
             if i == 0:
                 # manually set events flag after first event was created
@@ -307,7 +319,7 @@ class ThreadGotoPostTests(GotoViewTestCase):
             self.assertContains(response, post.get_absolute_url())
 
             # go to last post link is valid
-            last_url = self.client.get(self.thread.get_last_post_url())['location']
+            last_url = self.client.get(self.thread.get_last_post_url())["location"]
             self.assertEqual(post_url, last_url)
 
     def test_thread_post_goto(self):
@@ -315,15 +327,15 @@ class ThreadGotoPostTests(GotoViewTestCase):
         for _ in range(60):
             testutils.reply_thread(self.thread, posted_on=timezone.now())
 
-        for post in self.thread.post_set.order_by('id').iterator():
+        for post in self.thread.post_set.order_by("id").iterator():
             # go to post link is valid
-            post_url = self.client.get(post.get_absolute_url())['location']
+            post_url = self.client.get(post.get_absolute_url())["location"]
 
             response = self.client.get(post_url)
             self.assertContains(response, post.get_absolute_url())
 
         # go to last post link is valid
-        last_url = self.client.get(self.thread.get_last_post_url())['location']
+        last_url = self.client.get(self.thread.get_last_post_url())["location"]
         self.assertEqual(post_url, last_url)
 
     def test_thread_event_goto(self):
@@ -335,14 +347,15 @@ class ThreadGotoPostTests(GotoViewTestCase):
             post.is_event = True
             post.save()
 
-        for post in self.thread.post_set.filter(is_event=True).order_by('id').iterator():
+        for post in (
+            self.thread.post_set.filter(is_event=True).order_by("id").iterator()
+        ):
             # go to post link is valid
-            post_url = self.client.get(post.get_absolute_url())['location']
+            post_url = self.client.get(post.get_absolute_url())["location"]
 
             response = self.client.get(post_url)
             self.assertContains(response, post.get_absolute_url())
 
         # go to last post link is valid
-        last_url = self.client.get(self.thread.get_last_post_url())['location']
+        last_url = self.client.get(self.thread.get_last_post_url())["location"]
         self.assertEqual(post_url, last_url)
-

+ 148 - 139
misago/threads/tests/test_mergeconflict.py

@@ -14,8 +14,8 @@ UserModel = get_user_model()
 
 class MergeConflictTests(TestCase):
     def setUp(self):
-        self.category = Category.objects.get(slug='first-category')
-        self.user = UserModel.objects.create_user('bob', 'bob@test.com', 'Pass.123')
+        self.category = Category.objects.get(slug="first-category")
+        self.user = UserModel.objects.create_user("bob", "bob@test.com", "Pass.123")
 
     def create_plain_thread(self):
         return testutils.post_thread(self.category)
@@ -45,40 +45,27 @@ class MergeConflictTests(TestCase):
 
     def test_one_best_answer_one_plain(self):
         """thread with best answer and plain thread don't conflict"""
-        threads = [
-            self.create_best_answer_thread(),
-            self.create_plain_thread(),
-        ]
+        threads = [self.create_best_answer_thread(), self.create_plain_thread()]
         merge_conflict = MergeConflict(threads=threads)
         self.assertFalse(merge_conflict.is_merge_conflict())
         self.assertEqual(merge_conflict.get_conflicting_fields(), [])
 
         merge_conflict.is_valid(raise_exception=True)
-        self.assertEqual(merge_conflict.get_resolution(), {
-            'best_answer': threads[0],
-        })
+        self.assertEqual(merge_conflict.get_resolution(), {"best_answer": threads[0]})
 
     def test_one_poll_one_plain(self):
         """thread with poll and plain thread don't conflict"""
-        threads = [
-            self.create_poll_thread(),
-            self.create_plain_thread(),
-        ]
+        threads = [self.create_poll_thread(), self.create_plain_thread()]
         merge_conflict = MergeConflict(threads=threads)
         self.assertFalse(merge_conflict.is_merge_conflict())
         self.assertEqual(merge_conflict.get_conflicting_fields(), [])
 
         merge_conflict.is_valid(raise_exception=True)
-        self.assertEqual(merge_conflict.get_resolution(), {
-            'poll': threads[0].poll,
-        })
+        self.assertEqual(merge_conflict.get_resolution(), {"poll": threads[0].poll})
 
     def test_one_best_answer_one_poll(self):
         """thread with best answer and thread with poll don't conflict"""
-        threads = [
-            self.create_poll_thread(),
-            self.create_best_answer_thread(),
-        ]
+        threads = [self.create_poll_thread(), self.create_best_answer_thread()]
         merge_conflict = MergeConflict(threads=threads)
         self.assertFalse(merge_conflict.is_merge_conflict())
 
@@ -96,14 +83,15 @@ class MergeConflictTests(TestCase):
         """three threads with best answer, thread with poll and two plain threads conflict"""
         best_answers = [self.create_best_answer_thread() for i in range(3)]
         polls = [self.create_poll_thread()]
-        threads = [
-            self.create_plain_thread(),
-            self.create_plain_thread(),
-        ] + best_answers + polls
+        threads = (
+            [self.create_plain_thread(), self.create_plain_thread()]
+            + best_answers
+            + polls
+        )
 
         merge_conflict = MergeConflict(threads=threads)
         self.assertTrue(merge_conflict.is_merge_conflict())
-        self.assertEqual(merge_conflict.get_conflicting_fields(), ['best_answer'])
+        self.assertEqual(merge_conflict.get_conflicting_fields(), ["best_answer"])
 
         # without choice, conflict lists resolutions
         try:
@@ -111,57 +99,57 @@ class MergeConflictTests(TestCase):
             self.fail("merge_conflict.is_valid() should raise ValidationError")
         except ValidationError as e:
             self.assertTrue(merge_conflict.is_merge_conflict())
-            self.assertEqual(merge_conflict.get_conflicting_fields(), ['best_answer'])
-            self.assertEqual(e.detail, {
-                'best_answers': [['0', 'Unmark all best answers']] + [
-                    [
-                        str(thread.id),
-                        thread.title,
-                    ] for thread in best_answers
-                ]
-            })
+            self.assertEqual(merge_conflict.get_conflicting_fields(), ["best_answer"])
+            self.assertEqual(
+                e.detail,
+                {
+                    "best_answers": [["0", "Unmark all best answers"]]
+                    + [[str(thread.id), thread.title] for thread in best_answers]
+                },
+            )
 
         # conflict validates choice
         try:
-            merge_conflict = MergeConflict({'best_answer': threads[0].id}, threads)
+            merge_conflict = MergeConflict({"best_answer": threads[0].id}, threads)
             merge_conflict.is_valid(raise_exception=True)
             self.fail("merge_conflict.is_valid() should raise ValidationError")
         except ValidationError as e:
             self.assertTrue(merge_conflict.is_merge_conflict())
-            self.assertEqual(e.detail, {'best_answer': ['Invalid choice.']})
+            self.assertEqual(e.detail, {"best_answer": ["Invalid choice."]})
 
         # conflict returns selected resolution
-        merge_conflict = MergeConflict({'best_answer': best_answers[0].id}, threads)
+        merge_conflict = MergeConflict({"best_answer": best_answers[0].id}, threads)
         self.assertTrue(merge_conflict.is_merge_conflict())
-        self.assertEqual(merge_conflict.get_conflicting_fields(), ['best_answer'])
+        self.assertEqual(merge_conflict.get_conflicting_fields(), ["best_answer"])
         merge_conflict.is_valid(raise_exception=True)
-        self.assertEqual(merge_conflict.get_resolution(), {
-            'best_answer': best_answers[0],
-            'poll': polls[0].poll,
-        })
+        self.assertEqual(
+            merge_conflict.get_resolution(),
+            {"best_answer": best_answers[0], "poll": polls[0].poll},
+        )
 
         # conflict returns no-choice resolution
-        merge_conflict = MergeConflict({'best_answer': 0}, threads)
+        merge_conflict = MergeConflict({"best_answer": 0}, threads)
         self.assertTrue(merge_conflict.is_merge_conflict())
-        self.assertEqual(merge_conflict.get_conflicting_fields(), ['best_answer'])
+        self.assertEqual(merge_conflict.get_conflicting_fields(), ["best_answer"])
         merge_conflict.is_valid(raise_exception=True)
-        self.assertEqual(merge_conflict.get_resolution(), {
-            'best_answer': None,
-            'poll': polls[0].poll,
-        })
+        self.assertEqual(
+            merge_conflict.get_resolution(),
+            {"best_answer": None, "poll": polls[0].poll},
+        )
 
     def test_one_best_answer_three_polls_two_plain_conflict(self):
         """one thread with best answer, three threads with poll and two plain threads conflict"""
         best_answers = [self.create_best_answer_thread()]
         polls = [self.create_poll_thread() for i in range(3)]
-        threads = [
-            self.create_plain_thread(),
-            self.create_plain_thread(),
-        ] + best_answers + polls
+        threads = (
+            [self.create_plain_thread(), self.create_plain_thread()]
+            + best_answers
+            + polls
+        )
 
         merge_conflict = MergeConflict(threads=threads)
         self.assertTrue(merge_conflict.is_merge_conflict())
-        self.assertEqual(merge_conflict.get_conflicting_fields(), ['poll'])
+        self.assertEqual(merge_conflict.get_conflicting_fields(), ["poll"])
 
         # without choice, conflict lists resolutions
         try:
@@ -169,57 +157,65 @@ class MergeConflictTests(TestCase):
             self.fail("merge_conflict.is_valid() should raise ValidationError")
         except ValidationError as e:
             self.assertTrue(merge_conflict.is_merge_conflict())
-            self.assertEqual(merge_conflict.get_conflicting_fields(), ['poll'])
-            self.assertEqual(e.detail, {
-                'polls': [['0', 'Delete all polls']] + [
-                    [
-                        str(thread.poll.id),
-                        '%s (%s)' % (thread.poll.question, thread.title),
-                    ] for thread in polls
-                ]
-            })
+            self.assertEqual(merge_conflict.get_conflicting_fields(), ["poll"])
+            self.assertEqual(
+                e.detail,
+                {
+                    "polls": [["0", "Delete all polls"]]
+                    + [
+                        [
+                            str(thread.poll.id),
+                            "%s (%s)" % (thread.poll.question, thread.title),
+                        ]
+                        for thread in polls
+                    ]
+                },
+            )
 
         # conflict validates choice
         try:
-            merge_conflict = MergeConflict({'poll': threads[0].id}, threads)
+            merge_conflict = MergeConflict({"poll": threads[0].id}, threads)
             merge_conflict.is_valid(raise_exception=True)
             self.fail("merge_conflict.is_valid() should raise ValidationError")
         except ValidationError as e:
             self.assertTrue(merge_conflict.is_merge_conflict())
-            self.assertEqual(e.detail, {'poll': ['Invalid choice.']})
+            self.assertEqual(e.detail, {"poll": ["Invalid choice."]})
 
         # conflict returns selected resolution
-        merge_conflict = MergeConflict({'poll': polls[0].poll.id}, threads)
+        merge_conflict = MergeConflict({"poll": polls[0].poll.id}, threads)
         self.assertTrue(merge_conflict.is_merge_conflict())
-        self.assertEqual(merge_conflict.get_conflicting_fields(), ['poll'])
+        self.assertEqual(merge_conflict.get_conflicting_fields(), ["poll"])
         merge_conflict.is_valid(raise_exception=True)
-        self.assertEqual(merge_conflict.get_resolution(), {
-            'best_answer': best_answers[0],
-            'poll': polls[0].poll,
-        })
+        self.assertEqual(
+            merge_conflict.get_resolution(),
+            {"best_answer": best_answers[0], "poll": polls[0].poll},
+        )
 
         # conflict returns no-choice resolution
-        merge_conflict = MergeConflict({'poll': 0}, threads)
+        merge_conflict = MergeConflict({"poll": 0}, threads)
         self.assertTrue(merge_conflict.is_merge_conflict())
-        self.assertEqual(merge_conflict.get_conflicting_fields(), ['poll'])
+        self.assertEqual(merge_conflict.get_conflicting_fields(), ["poll"])
         merge_conflict.is_valid(raise_exception=True)
-        self.assertEqual(merge_conflict.get_resolution(), {
-            'best_answer': best_answers[0],
-            'poll': None,
-        })
+        self.assertEqual(
+            merge_conflict.get_resolution(),
+            {"best_answer": best_answers[0], "poll": None},
+        )
 
     def test_three_best_answers_three_polls_two_plain_conflict(self):
         """multiple conflict is handled"""
         best_answers = [self.create_best_answer_thread() for i in range(3)]
         polls = [self.create_poll_thread() for i in range(3)]
-        threads = [
-            self.create_plain_thread(),
-            self.create_plain_thread(),
-        ] + best_answers + polls
+        threads = (
+            [self.create_plain_thread(), self.create_plain_thread()]
+            + best_answers
+            + polls
+        )
 
         merge_conflict = MergeConflict(threads=threads)
         self.assertTrue(merge_conflict.is_merge_conflict())
-        self.assertEqual(merge_conflict.get_conflicting_fields(), ['best_answer', 'poll'])
+        self.assertEqual(
+            merge_conflict.get_conflicting_fields(), ["best_answer", "poll"]
+        )
 
         # without choice, conflict lists all resolutions
         try:
@@ -227,96 +223,109 @@ class MergeConflictTests(TestCase):
             self.fail("merge_conflict.is_valid() should raise ValidationError")
         except ValidationError as e:
             self.assertTrue(merge_conflict.is_merge_conflict())
-            self.assertEqual(merge_conflict.get_conflicting_fields(), ['best_answer', 'poll'])
-            self.assertEqual(e.detail, {
-                'best_answers': [['0', 'Unmark all best answers']] + [
-                    [
-                        str(thread.id),
-                        thread.title,
-                    ] for thread in best_answers
-                ],
-                'polls': [['0', 'Delete all polls']] + [
-                    [
-                        str(thread.poll.id),
-                        '%s (%s)' % (thread.poll.question, thread.title),
-                    ] for thread in polls
-                ]
-            })
+            self.assertEqual(
+                merge_conflict.get_conflicting_fields(), ["best_answer", "poll"]
+            )
+            self.assertEqual(
+                e.detail,
+                {
+                    "best_answers": [["0", "Unmark all best answers"]]
+                    + [[str(thread.id), thread.title] for thread in best_answers],
+                    "polls": [["0", "Delete all polls"]]
+                    + [
+                        [
+                            str(thread.poll.id),
+                            "%s (%s)" % (thread.poll.question, thread.title),
+                        ]
+                        for thread in polls
+                    ],
+                },
+            )
 
         # conflict validates all choices if single choice was given
         try:
-            merge_conflict = MergeConflict({'best_answer': threads[0].id}, threads)
+            merge_conflict = MergeConflict({"best_answer": threads[0].id}, threads)
             merge_conflict.is_valid(raise_exception=True)
             self.fail("merge_conflict.is_valid() should raise ValidationError")
         except ValidationError as e:
             self.assertTrue(merge_conflict.is_merge_conflict())
-            self.assertEqual(e.detail, {
-                'best_answer': ['Invalid choice.'],
-                'poll': ['Invalid choice.'],
-            })
+            self.assertEqual(
+                e.detail,
+                {"best_answer": ["Invalid choice."], "poll": ["Invalid choice."]},
+            )
 
         try:
-            merge_conflict = MergeConflict({'poll': threads[0].id}, threads)
+            merge_conflict = MergeConflict({"poll": threads[0].id}, threads)
             merge_conflict.is_valid(raise_exception=True)
             self.fail("merge_conflict.is_valid() should raise ValidationError")
         except ValidationError as e:
             self.assertTrue(merge_conflict.is_merge_conflict())
-            self.assertEqual(e.detail, {
-                'best_answer': ['Invalid choice.'],
-                'poll': ['Invalid choice.'],
-            })
+            self.assertEqual(
+                e.detail,
+                {"best_answer": ["Invalid choice."], "poll": ["Invalid choice."]},
+            )
 
         # conflict validates all choices if all choices were given
         try:
-            merge_conflict = MergeConflict({
-                'best_answer': threads[0].id,
-                'poll': threads[0].id,
-            }, threads)
+            merge_conflict = MergeConflict(
+                {"best_answer": threads[0].id, "poll": threads[0].id}, threads
+            )
             merge_conflict.is_valid(raise_exception=True)
             self.fail("merge_conflict.is_valid() should raise ValidationError")
         except ValidationError as e:
             self.assertTrue(merge_conflict.is_merge_conflict())
-            self.assertEqual(e.detail, {
-                'best_answer': ['Invalid choice.'],
-                'poll': ['Invalid choice.'],
-            })
+            self.assertEqual(
+                e.detail,
+                {"best_answer": ["Invalid choice."], "poll": ["Invalid choice."]},
+            )
 
         # conflict returns selected resolutions
-        valid_choices = {'best_answer': best_answers[0].id, 'poll': polls[0].poll.id}
+        valid_choices = {"best_answer": best_answers[0].id, "poll": polls[0].poll.id}
         merge_conflict = MergeConflict(valid_choices, threads)
         self.assertTrue(merge_conflict.is_merge_conflict())
-        self.assertEqual(merge_conflict.get_conflicting_fields(), ['best_answer', 'poll'])
+        self.assertEqual(
+            merge_conflict.get_conflicting_fields(), ["best_answer", "poll"]
+        )
         merge_conflict.is_valid(raise_exception=True)
-        self.assertEqual(merge_conflict.get_resolution(), {
-            'best_answer': best_answers[0],
-            'poll': polls[0].poll,
-        })
+        self.assertEqual(
+            merge_conflict.get_resolution(),
+            {"best_answer": best_answers[0], "poll": polls[0].poll},
+        )
 
         # conflict returns no-choice resolution
-        merge_conflict = MergeConflict({'best_answer': 0, 'poll': 0}, threads)
+        merge_conflict = MergeConflict({"best_answer": 0, "poll": 0}, threads)
         self.assertTrue(merge_conflict.is_merge_conflict())
-        self.assertEqual(merge_conflict.get_conflicting_fields(), ['best_answer', 'poll'])
+        self.assertEqual(
+            merge_conflict.get_conflicting_fields(), ["best_answer", "poll"]
+        )
         merge_conflict.is_valid(raise_exception=True)
-        self.assertEqual(merge_conflict.get_resolution(), {
-            'best_answer': None,
-            'poll': None,
-        })
+        self.assertEqual(
+            merge_conflict.get_resolution(), {"best_answer": None, "poll": None}
+        )
 
         # conflict allows mixing no-choice with choice
-        merge_conflict = MergeConflict({'best_answer': best_answers[0].id, 'poll': 0}, threads)
+        merge_conflict = MergeConflict(
+            {"best_answer": best_answers[0].id, "poll": 0}, threads
+        )
         self.assertTrue(merge_conflict.is_merge_conflict())
-        self.assertEqual(merge_conflict.get_conflicting_fields(), ['best_answer', 'poll'])
+        self.assertEqual(
+            merge_conflict.get_conflicting_fields(), ["best_answer", "poll"]
+        )
         merge_conflict.is_valid(raise_exception=True)
-        self.assertEqual(merge_conflict.get_resolution(), {
-            'best_answer': best_answers[0],
-            'poll': None,
-        })
-
-        merge_conflict = MergeConflict({'best_answer': 0, 'poll': polls[0].poll.id}, threads)
+        self.assertEqual(
+            merge_conflict.get_resolution(),
+            {"best_answer": best_answers[0], "poll": None},
+        )
+
+        merge_conflict = MergeConflict(
+            {"best_answer": 0, "poll": polls[0].poll.id}, threads
+        )
         self.assertTrue(merge_conflict.is_merge_conflict())
-        self.assertEqual(merge_conflict.get_conflicting_fields(), ['best_answer', 'poll'])
+        self.assertEqual(
+            merge_conflict.get_conflicting_fields(), ["best_answer", "poll"]
+        )
         merge_conflict.is_valid(raise_exception=True)
-        self.assertEqual(merge_conflict.get_resolution(), {
-            'best_answer': None,
-            'poll': polls[0].poll,
-        })
+        self.assertEqual(
+            merge_conflict.get_resolution(),
+            {"best_answer": None, "poll": polls[0].poll},
+        )

+ 17 - 32
misago/threads/tests/test_paginator.py

@@ -12,7 +12,8 @@ class PostsPaginatorTests(TestCase):
 
         paginator = PostsPaginator(items, 5)
         self.assertEqual(
-            self.get_paginator_items_list(paginator), [
+            self.get_paginator_items_list(paginator),
+            [
                 [1, 2, 3, 4, 5],
                 [5, 6, 7, 8, 9],
                 [9, 10, 11, 12, 13],
@@ -21,7 +22,7 @@ class PostsPaginatorTests(TestCase):
                 [21, 22, 23, 24, 25],
                 [25, 26, 27, 28, 29],
                 [29, 30],
-            ]
+            ],
         )
 
     def test_paginator_orphans(self):
@@ -30,55 +31,38 @@ class PostsPaginatorTests(TestCase):
 
         paginator = PostsPaginator(items, 8, 6)
         self.assertEqual(
-            self.get_paginator_items_list(paginator), [
-                [1, 2, 3, 4, 5, 6, 7, 8],
-                [8, 9, 10, 11, 12, 13, 14, 15, 16],
-            ]
+            self.get_paginator_items_list(paginator),
+            [[1, 2, 3, 4, 5, 6, 7, 8], [8, 9, 10, 11, 12, 13, 14, 15, 16]],
         )
 
         paginator = PostsPaginator(items, 4, 4)
         self.assertEqual(
-            self.get_paginator_items_list(paginator), [
-                [1, 2, 3, 4],
-                [4, 5, 6, 7],
-                [7, 8, 9, 10],
-                [10, 11, 12, 13, 14, 15, 16],
-            ]
+            self.get_paginator_items_list(paginator),
+            [[1, 2, 3, 4], [4, 5, 6, 7], [7, 8, 9, 10], [10, 11, 12, 13, 14, 15, 16]],
         )
 
         paginator = PostsPaginator(items, 5, 3)
         self.assertEqual(
-            self.get_paginator_items_list(paginator), [
-                [1, 2, 3, 4, 5],
-                [5, 6, 7, 8, 9],
-                [9, 10, 11, 12, 13, 14, 15, 16],
-            ]
+            self.get_paginator_items_list(paginator),
+            [[1, 2, 3, 4, 5], [5, 6, 7, 8, 9], [9, 10, 11, 12, 13, 14, 15, 16]],
         )
 
         paginator = PostsPaginator(items, 6, 2)
         self.assertEqual(
-            self.get_paginator_items_list(paginator), [
-                [1, 2, 3, 4, 5, 6],
-                [6, 7, 8, 9, 10, 11],
-                [11, 12, 13, 14, 15, 16],
-            ]
+            self.get_paginator_items_list(paginator),
+            [[1, 2, 3, 4, 5, 6], [6, 7, 8, 9, 10, 11], [11, 12, 13, 14, 15, 16]],
         )
 
         paginator = PostsPaginator(items, 7, 1)
         self.assertEqual(
-            self.get_paginator_items_list(paginator), [
-                [1, 2, 3, 4, 5, 6, 7],
-                [7, 8, 9, 10, 11, 12, 13],
-                [13, 14, 15, 16],
-            ]
+            self.get_paginator_items_list(paginator),
+            [[1, 2, 3, 4, 5, 6, 7], [7, 8, 9, 10, 11, 12, 13], [13, 14, 15, 16]],
         )
 
         paginator = PostsPaginator(items, 7, 3)
         self.assertEqual(
-            self.get_paginator_items_list(paginator), [
-                [1, 2, 3, 4, 5, 6, 7],
-                [7, 8, 9, 10, 11, 12, 13, 14, 15, 16],
-            ]
+            self.get_paginator_items_list(paginator),
+            [[1, 2, 3, 4, 5, 6, 7], [7, 8, 9, 10, 11, 12, 13, 14, 15, 16]],
         )
 
         paginator = PostsPaginator(items, 10, 6)
@@ -100,7 +84,8 @@ class PostsPaginatorTests(TestCase):
                     common_part = set(page) & set(compared)
                     self.assertTrue(
                         len(common_part) < 2,
-                        "invalid page %s: %s" % (max(p, c) + 1, sorted(list(common_part)))
+                        "invalid page %s: %s"
+                        % (max(p, c) + 1, sorted(list(common_part))),
                     )
 
     def get_paginator_items_list(self, paginator):

+ 42 - 48
misago/threads/tests/test_participants.py

@@ -5,7 +5,11 @@ from django.utils import timezone
 from misago.categories.models import Category
 from misago.threads.models import Post, Thread, ThreadParticipant
 from misago.threads.participants import (
-    has_participants, make_participants_aware, set_owner, set_users_unread_private_threads_sync)
+    has_participants,
+    make_participants_aware,
+    set_owner,
+    set_users_unread_private_threads_sync,
+)
 
 
 UserModel = get_user_model()
@@ -19,11 +23,11 @@ class ParticipantsTests(TestCase):
         self.thread = Thread(
             category=self.category,
             started_on=datetime,
-            starter_name='Tester',
-            starter_slug='tester',
+            starter_name="Tester",
+            starter_slug="tester",
             last_post_on=datetime,
-            last_poster_name='Tester',
-            last_poster_slug='tester',
+            last_poster_name="Tester",
+            last_poster_slug="tester",
         )
 
         self.thread.set_title("Test thread")
@@ -32,7 +36,7 @@ class ParticipantsTests(TestCase):
         post = Post.objects.create(
             category=self.category,
             thread=self.thread,
-            poster_name='Tester',
+            poster_name="Tester",
             original="Hello! I am test message!",
             parsed="<p>Hello! I am test message!</p>",
             checksum="nope",
@@ -65,15 +69,17 @@ class ParticipantsTests(TestCase):
         annotations on list of threads
         """
         user = UserModel.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
-        other_user = UserModel.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123")
+        other_user = UserModel.objects.create_user(
+            "Bob2", "bob2@boberson.com", "Pass.123"
+        )
 
-        self.assertFalse(hasattr(self.thread, 'participants_list'))
-        self.assertFalse(hasattr(self.thread, 'participant'))
+        self.assertFalse(hasattr(self.thread, "participants_list"))
+        self.assertFalse(hasattr(self.thread, "participant"))
 
         make_participants_aware(user, [self.thread])
 
-        self.assertFalse(hasattr(self.thread, 'participants_list'))
-        self.assertTrue(hasattr(self.thread, 'participant'))
+        self.assertFalse(hasattr(self.thread, "participants_list"))
+        self.assertTrue(hasattr(self.thread, "participant"))
         self.assertIsNone(self.thread.participant)
 
         ThreadParticipant.objects.set_owner(self.thread, user)
@@ -81,7 +87,7 @@ class ParticipantsTests(TestCase):
 
         make_participants_aware(user, [self.thread])
 
-        self.assertFalse(hasattr(self.thread, 'participants_list'))
+        self.assertFalse(hasattr(self.thread, "participants_list"))
         self.assertEqual(self.thread.participant.user, user)
 
     def test_make_thread_participants_aware(self):
@@ -90,15 +96,17 @@ class ParticipantsTests(TestCase):
         annotations on thread model
         """
         user = UserModel.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
-        other_user = UserModel.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123")
+        other_user = UserModel.objects.create_user(
+            "Bob2", "bob2@boberson.com", "Pass.123"
+        )
 
-        self.assertFalse(hasattr(self.thread, 'participants_list'))
-        self.assertFalse(hasattr(self.thread, 'participant'))
+        self.assertFalse(hasattr(self.thread, "participants_list"))
+        self.assertFalse(hasattr(self.thread, "participant"))
 
         make_participants_aware(user, self.thread)
 
-        self.assertTrue(hasattr(self.thread, 'participants_list'))
-        self.assertTrue(hasattr(self.thread, 'participant'))
+        self.assertTrue(hasattr(self.thread, "participants_list"))
+        self.assertTrue(hasattr(self.thread, "participant"))
 
         self.assertEqual(self.thread.participants_list, [])
         self.assertIsNone(self.thread.participant)
@@ -136,10 +144,7 @@ class ParticipantsTests(TestCase):
 
         set_users_unread_private_threads_sync(users=users)
         for user in users:
-            UserModel.objects.get(
-                pk=user.pk,
-                sync_unread_private_threads=True,
-            )
+            UserModel.objects.get(pk=user.pk, sync_unread_private_threads=True)
 
     def test_set_participants_unread_private_threads_sync(self):
         """
@@ -155,57 +160,46 @@ class ParticipantsTests(TestCase):
 
         set_users_unread_private_threads_sync(participants=participants)
         for user in users:
-            UserModel.objects.get(
-                pk=user.pk,
-                sync_unread_private_threads=True,
-            )
+            UserModel.objects.get(pk=user.pk, sync_unread_private_threads=True)
 
     def test_set_participants_users_unread_private_threads_sync(self):
         """
         set_users_unread_private_threads_sync sets sync_unread_private_threads
         flag on users and participants provided to true
         """
-        users = [
-            UserModel.objects.create_user("Bob1", "bob1@boberson.com", "Pass.123"),
-        ]
+        users = [UserModel.objects.create_user("Bob1", "bob1@boberson.com", "Pass.123")]
 
         participants = [ThreadParticipant(user=u) for u in users]
 
-        users.append(UserModel.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123"))
-
-        set_users_unread_private_threads_sync(
-            users=users,
-            participants=participants,
+        users.append(
+            UserModel.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123")
         )
+
+        set_users_unread_private_threads_sync(users=users, participants=participants)
         for user in users:
-            UserModel.objects.get(
-                pk=user.pk,
-                sync_unread_private_threads=True,
-            )
+            UserModel.objects.get(pk=user.pk, sync_unread_private_threads=True)
 
     def test_set_users_unread_private_threads_sync_exclude_user(self):
         """exclude_user kwarg works"""
         users = [
             UserModel.objects.create_user("Bob1", "bob1@boberson.com", "Pass.123"),
-            UserModel.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123")
+            UserModel.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123"),
         ]
 
-        set_users_unread_private_threads_sync(
-            users=users,
-            exclude_user=users[0],
-        )
+        set_users_unread_private_threads_sync(users=users, exclude_user=users[0])
 
-        self.assertFalse(UserModel.objects.get(pk=users[0].pk).sync_unread_private_threads)
-        self.assertTrue(UserModel.objects.get(pk=users[1].pk).sync_unread_private_threads)
+        self.assertFalse(
+            UserModel.objects.get(pk=users[0].pk).sync_unread_private_threads
+        )
+        self.assertTrue(
+            UserModel.objects.get(pk=users[1].pk).sync_unread_private_threads
+        )
 
     def test_set_users_unread_private_threads_sync_noop(self):
         """excluding only user is noop"""
         user = UserModel.objects.create_user("Bob1", "bob1@boberson.com", "Pass.123")
 
         with self.assertNumQueries(0):
-            set_users_unread_private_threads_sync(
-                users=[user],
-                exclude_user=user,
-            )
+            set_users_unread_private_threads_sync(users=[user], exclude_user=user)
 
         self.assertFalse(UserModel.objects.get(pk=user.pk).sync_unread_private_threads)

+ 39 - 60
misago/threads/tests/test_post_mentions.py

@@ -14,13 +14,11 @@ class PostMentionsTests(AuthenticatedUserTestCase):
     def setUp(self):
         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.post_link = reverse(
-            'misago:api:thread-post-list', kwargs={
-                'thread_pk': self.thread.pk,
-            }
+            "misago:api:thread-post-list", kwargs={"thread_pk": self.thread.pk}
         )
 
     def put(self, url, data=None):
@@ -30,37 +28,31 @@ class PostMentionsTests(AuthenticatedUserTestCase):
     def test_mention_noone(self):
         """endpoint handles no mentions in post"""
         response = self.client.post(
-            self.post_link, data={
-                'post': "This is test response!",
-            }
+            self.post_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
-        post = self.user.post_set.order_by('id').last()
+        post = self.user.post_set.order_by("id").last()
         self.assertEqual(post.mentions.count(), 0)
 
     def test_mention_nonexistant(self):
         """endpoint handles nonexistant mention"""
         response = self.client.post(
-            self.post_link, data={
-                'post': "This is test response, @InvalidUser!",
-            }
+            self.post_link, data={"post": "This is test response, @InvalidUser!"}
         )
         self.assertEqual(response.status_code, 200)
 
-        post = self.user.post_set.order_by('id').last()
+        post = self.user.post_set.order_by("id").last()
         self.assertEqual(post.mentions.count(), 0)
 
     def test_mention_self(self):
         """endpoint mentions author"""
         response = self.client.post(
-            self.post_link, data={
-                'post': "This is test response, @%s!" % self.user,
-            }
+            self.post_link, data={"post": "This is test response, @%s!" % self.user}
         )
         self.assertEqual(response.status_code, 200)
 
-        post = self.user.post_set.order_by('id').last()
+        post = self.user.post_set.order_by("id").last()
 
         self.assertEqual(post.mentions.count(), 1)
         self.assertEqual(post.mentions.all()[0], self.user)
@@ -71,95 +63,84 @@ class PostMentionsTests(AuthenticatedUserTestCase):
 
         for i in range(MENTIONS_LIMIT + 5):
             users.append(
-                UserModel.objects.
-                create_user('Mention%s' % i, 'mention%s@bob.com' % i, 'pass123')
+                UserModel.objects.create_user(
+                    "Mention%s" % i, "mention%s@bob.com" % i, "pass123"
+                )
             )
 
-        mentions = ['@%s' % u for u in users]
+        mentions = ["@%s" % u for u in users]
         response = self.client.post(
             self.post_link,
-            data={
-                'post': "This is test response, %s!" % (', '.join(mentions)),
-            }
+            data={"post": "This is test response, %s!" % (", ".join(mentions))},
         )
         self.assertEqual(response.status_code, 200)
 
-        post = self.user.post_set.order_by('id').last()
+        post = self.user.post_set.order_by("id").last()
 
         self.assertEqual(post.mentions.count(), 24)
-        self.assertEqual(list(post.mentions.order_by('id')), users[:24])
+        self.assertEqual(list(post.mentions.order_by("id")), users[:24])
 
     def test_mention_update(self):
         """edit post endpoint updates mentions"""
-        user_a = UserModel.objects.create_user('Mention', 'mention@test.com', 'pass123')
-        user_b = UserModel.objects.create_user('MentionB', 'mentionb@test.com', 'pass123')
+        user_a = UserModel.objects.create_user("Mention", "mention@test.com", "pass123")
+        user_b = UserModel.objects.create_user(
+            "MentionB", "mentionb@test.com", "pass123"
+        )
 
         response = self.client.post(
-            self.post_link, data={
-                'post': "This is test response, @%s!" % user_a,
-            }
+            self.post_link, data={"post": "This is test response, @%s!" % user_a}
         )
         self.assertEqual(response.status_code, 200)
 
-        post = self.user.post_set.order_by('id').last()
+        post = self.user.post_set.order_by("id").last()
 
         self.assertEqual(post.mentions.count(), 1)
-        self.assertEqual(post.mentions.order_by('id')[0], user_a)
+        self.assertEqual(post.mentions.order_by("id")[0], user_a)
 
         # add mention to post
         edit_link = reverse(
-            'misago:api:thread-post-detail', kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': post.pk,
-            }
+            "misago:api:thread-post-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": post.pk},
         )
 
         response = self.put(
             edit_link,
-            data={
-                'post': "This is test response, @%s and @%s!" % (user_a, user_b),
-            }
+            data={"post": "This is test response, @%s and @%s!" % (user_a, user_b)},
         )
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(post.mentions.count(), 2)
-        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
         response = self.put(
-            edit_link, data={
-                'post': "This is test response, @%s!" % user_b,
-            }
+            edit_link, data={"post": "This is test response, @%s!" % user_b}
         )
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(post.mentions.count(), 2)
-        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
-        response = self.put(
-            edit_link, data={
-                'post': "This is test response!",
-            }
-        )
+        response = self.put(edit_link, data={"post": "This is test response!"})
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(post.mentions.count(), 2)
-        self.assertEqual(list(post.mentions.order_by('id')), [user_a, user_b])
+        self.assertEqual(list(post.mentions.order_by("id")), [user_a, user_b])
 
     def test_mentions_merge(self):
         """posts merge sums mentions"""
-        user_a = UserModel.objects.create_user('Mention', 'mention@test.com', 'pass123')
-        user_b = UserModel.objects.create_user('MentionB', 'mentionb@test.com', 'pass123')
+        user_a = UserModel.objects.create_user("Mention", "mention@test.com", "pass123")
+        user_b = UserModel.objects.create_user(
+            "MentionB", "mentionb@test.com", "pass123"
+        )
 
         response = self.client.post(
-            self.post_link, data={
-                'post': "This is test response, @%s!" % user_a,
-            }
+            self.post_link, data={"post": "This is test response, @%s!" % user_a}
         )
         self.assertEqual(response.status_code, 200)
 
-        post_a = self.user.post_set.order_by('id').last()
+        post_a = self.user.post_set.order_by("id").last()
 
         self.assertEqual(post_a.mentions.count(), 1)
         self.assertEqual(list(post_a.mentions.all()), [user_a])
@@ -170,16 +151,14 @@ class PostMentionsTests(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.post_link,
-            data={
-                'post': "This is test response, @%s and @%s!" % (user_a, user_b),
-            }
+            data={"post": "This is test response, @%s and @%s!" % (user_a, user_b)},
         )
         self.assertEqual(response.status_code, 200)
 
-        post_b = self.user.post_set.order_by('id').last()
+        post_b = self.user.post_set.order_by("id").last()
 
         # merge posts and validate that post A has all mentions
         post_b.merge(post_a)
 
         self.assertEqual(post_a.mentions.count(), 2)
-        self.assertEqual(list(post_a.mentions.order_by('id')), [user_a, user_b])
+        self.assertEqual(list(post_a.mentions.order_by("id")), [user_a, user_b])

+ 13 - 13
misago/threads/tests/test_post_model.py

@@ -22,11 +22,11 @@ class PostModelTests(TestCase):
         self.thread = Thread(
             category=self.category,
             started_on=datetime,
-            starter_name='Tester',
-            starter_slug='tester',
+            starter_name="Tester",
+            starter_slug="tester",
             last_post_on=datetime,
-            last_poster_name='Tester',
-            last_poster_slug='tester',
+            last_poster_name="Tester",
+            last_poster_slug="tester",
         )
 
         self.thread.set_title("Test thread")
@@ -45,7 +45,7 @@ class PostModelTests(TestCase):
         )
 
         update_post_checksum(self.post)
-        self.post.save(update_fields=['checksum'])
+        self.post.save(update_fields=["checksum"])
 
         self.thread.first_post = self.post
         self.thread.last_post = self.post
@@ -62,11 +62,11 @@ class PostModelTests(TestCase):
         other_thread = Thread.objects.create(
             category=self.category,
             started_on=timezone.now(),
-            starter_name='Tester',
-            starter_slug='tester',
+            starter_name="Tester",
+            starter_slug="tester",
             last_post_on=timezone.now(),
-            last_poster_name='Tester',
-            last_poster_slug='tester',
+            last_poster_name="Tester",
+            last_poster_slug="tester",
         )
 
         # can't merge with other users posts
@@ -195,11 +195,11 @@ class PostModelTests(TestCase):
         new_thread = Thread.objects.create(
             category=self.category,
             started_on=timezone.now(),
-            starter_name='Tester',
-            starter_slug='tester',
+            starter_name="Tester",
+            starter_slug="tester",
             last_post_on=timezone.now(),
-            last_poster_name='Tester',
-            last_poster_slug='tester',
+            last_poster_name="Tester",
+            last_poster_slug="tester",
         )
 
         self.post.move(new_thread)

+ 285 - 368
misago/threads/tests/test_privatethread_patch_api.py

@@ -21,11 +21,13 @@ class PrivateThreadPatchApiTestCase(PrivateThreadsTestCase):
         self.api_link = self.thread.get_api_url()
 
         self.other_user = UserModel.objects.create_user(
-            'BobBoberson', 'bob@boberson.com', 'pass123'
+            "BobBoberson", "bob@boberson.com", "pass123"
         )
 
     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"
+        )
 
 
 class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
@@ -34,76 +36,66 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'add',
-                    'path': 'participants',
-                    'value': self.user.username,
-                },
-            ]
+            self.api_link,
+            [{"op": "add", "path": "participants", "value": self.user.username}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["You have to be thread owner to add new participants to it."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.pk,
+                "detail": [
+                    "You have to be thread owner to add new participants to it."
+                ],
+            },
+        )
 
     def test_add_empty_username(self):
         """path validates username"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'add',
-                    'path': 'participants',
-                    'value': '',
-                },
-            ]
+            self.api_link, [{"op": "add", "path": "participants", "value": ""}]
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["You have to enter new participant's username."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.pk,
+                "detail": ["You have to enter new participant's username."],
+            },
+        )
 
     def test_add_nonexistant_user(self):
         """can't user two times"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'add',
-                    'path': 'participants',
-                    'value': 'InvalidUser',
-                },
-            ]
+            self.api_link,
+            [{"op": "add", "path": "participants", "value": "InvalidUser"}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["No user with such name exists."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.thread.pk, "detail": ["No user with such name exists."]},
+        )
 
     def test_add_already_participant(self):
         """can't add user that is already participant"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'add',
-                    'path': 'participants',
-                    'value': self.user.username,
-                },
-            ]
+            self.api_link,
+            [{"op": "add", "path": "participants", "value": self.user.username}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["This user is already thread participant."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.pk,
+                "detail": ["This user is already thread participant."],
+            },
+        )
 
     def test_add_blocking_user(self):
         """can't add user that is already participant"""
@@ -111,19 +103,14 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.other_user.blocks.add(self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'add',
-                    'path': 'participants',
-                    'value': self.other_user.username,
-                },
-            ]
+            self.api_link,
+            [{"op": "add", "path": "participants", "value": self.other_user.username}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["BobBoberson is blocking you."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.thread.pk, "detail": ["BobBoberson is blocking you."]},
+        )
 
     @patch_user_acl(other_user_cant_use_private_threads)
     def test_add_no_perm_user(self):
@@ -131,19 +118,17 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'add',
-                    'path': 'participants',
-                    'value': self.other_user.username,
-                },
-            ]
+            self.api_link,
+            [{"op": "add", "path": "participants", "value": self.other_user.username}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["BobBoberson can't participate in private threads."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.pk,
+                "detail": ["BobBoberson can't participate in private threads."],
+            },
+        )
 
     @patch_user_acl({"max_private_thread_participants": 3})
     def test_add_too_many_users(self):
@@ -152,24 +137,22 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
 
         for i in range(3):
             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"
             )
             ThreadParticipant.objects.add_participants(self.thread, [user])
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'add',
-                    'path': 'participants',
-                    'value': self.other_user.username,
-                },
-            ]
+            self.api_link,
+            [{"op": "add", "path": "participants", "value": self.other_user.username}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["You can't add any more new users to this thread."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.pk,
+                "detail": ["You can't add any more new users to this thread."],
+            },
+        )
 
     def test_add_user_closed_thread(self):
         """adding user to closed thread fails for non-moderator"""
@@ -179,38 +162,31 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'add',
-                    'path': 'participants',
-                    'value': self.other_user.username,
-                },
-            ]
+            self.api_link,
+            [{"op": "add", "path": "participants", "value": self.other_user.username}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["Only moderators can add participants to closed threads."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.pk,
+                "detail": ["Only moderators can add participants to closed threads."],
+            },
+        )
 
     def test_add_user(self):
         """adding user to thread add user to thread as participant, sets event and emails him"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
         self.patch(
-            self.api_link, [
-                {
-                    'op': 'add',
-                    'path': 'participants',
-                    'value': self.other_user.username,
-                },
-            ]
+            self.api_link,
+            [{"op": "add", "path": "participants", "value": self.other_user.username}],
         )
 
         # event was set on thread
-        event = self.thread.post_set.order_by('id').last()
+        event = self.thread.post_set.order_by("id").last()
         self.assertTrue(event.is_event)
-        self.assertTrue(event.event_type, 'added_participant')
+        self.assertTrue(event.event_type, "added_participant")
 
         # notification about new private thread was sent to other user
         self.assertEqual(len(mail.outbox), 1)
@@ -219,7 +195,7 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.assertIn(self.user.username, email.subject)
         self.assertIn(self.thread.title, email.subject)
 
-    @patch_user_acl({'can_moderate_private_threads': True})
+    @patch_user_acl({"can_moderate_private_threads": True})
     def test_add_user_to_other_user_thread_moderator(self):
         """moderators can add users to other users threads"""
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
@@ -228,24 +204,19 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.save()
 
         self.patch(
-            self.api_link, [
-                {
-                    'op': 'add',
-                    'path': 'participants',
-                    'value': self.user.username,
-                },
-            ]
+            self.api_link,
+            [{"op": "add", "path": "participants", "value": self.user.username}],
         )
 
         # event was set on thread
-        event = self.thread.post_set.order_by('id').last()
+        event = self.thread.post_set.order_by("id").last()
         self.assertTrue(event.is_event)
-        self.assertTrue(event.event_type, 'entered_thread')
+        self.assertTrue(event.event_type, "entered_thread")
 
         # notification about new private thread wasn't send because we invited ourselves
         self.assertEqual(len(mail.outbox), 0)
 
-    @patch_user_acl({'can_moderate_private_threads': True})
+    @patch_user_acl({"can_moderate_private_threads": True})
     def test_add_user_to_closed_moderator(self):
         """moderators can add users to closed threads"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
@@ -254,19 +225,14 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.save()
 
         self.patch(
-            self.api_link, [
-                {
-                    'op': 'add',
-                    'path': 'participants',
-                    'value': self.other_user.username,
-                },
-            ]
+            self.api_link,
+            [{"op": "add", "path": "participants", "value": self.other_user.username}],
         )
 
         # event was set on thread
-        event = self.thread.post_set.order_by('id').last()
+        event = self.thread.post_set.order_by("id").last()
         self.assertTrue(event.is_event)
-        self.assertTrue(event.event_type, 'added_participant')
+        self.assertTrue(event.event_type, "added_participant")
 
         # notification about new private thread was sent to other user
         self.assertEqual(len(mail.outbox), 1)
@@ -282,57 +248,40 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'participants',
-                    'value': '',
-                },
-            ]
+            self.api_link, [{"op": "remove", "path": "participants", "value": ""}]
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["A valid integer is required."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.thread.pk, "detail": ["A valid integer is required."]},
+        )
 
     def test_remove_invalid(self):
         """api validates user id type"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'participants',
-                    'value': 'string',
-                },
-            ]
+            self.api_link, [{"op": "remove", "path": "participants", "value": "string"}]
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["A valid integer is required."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.thread.pk, "detail": ["A valid integer is required."]},
+        )
 
     def test_remove_nonexistant(self):
         """removed user has to be participant"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'participants',
-                    'value': self.other_user.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "participants", "value": self.other_user.pk}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["Participant doesn't exist."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.thread.pk, "detail": ["Participant doesn't exist."]},
+        )
 
     def test_remove_not_owner(self):
         """api validates if user trying to remove other user is an owner"""
@@ -340,19 +289,19 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'participants',
-                    'value': self.other_user.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "participants", "value": self.other_user.pk}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["You have to be thread owner to remove participants from it."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.pk,
+                "detail": [
+                    "You have to be thread owner to remove participants from it."
+                ],
+            },
+        )
 
     def test_owner_remove_user_closed_thread(self):
         """api disallows owner to remove other user from closed thread"""
@@ -363,54 +312,50 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'participants',
-                    'value': self.other_user.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "participants", "value": self.other_user.pk}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["Only moderators can remove participants from closed threads."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.pk,
+                "detail": [
+                    "Only moderators can remove participants from closed threads."
+                ],
+            },
+        )
 
     def test_user_leave_thread(self):
         """api allows user to remove himself from thread"""
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
 
-        self.user.subscription_set.create(
-            category=self.category,
-            thread=self.thread,
-        )
+        self.user.subscription_set.create(category=self.category, thread=self.thread)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'participants',
-                    'value': self.user.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "participants", "value": self.user.pk}],
         )
 
         self.assertEqual(response.status_code, 200)
-        self.assertFalse(response.json()['deleted'])
+        self.assertFalse(response.json()["deleted"])
 
         # thread still exists
         self.assertTrue(Thread.objects.get(pk=self.thread.pk))
 
         # leave event has valid type
-        event = self.thread.post_set.order_by('id').last()
+        event = self.thread.post_set.order_by("id").last()
         self.assertTrue(event.is_event)
-        self.assertTrue(event.event_type, 'participant_left')
+        self.assertTrue(event.event_type, "participant_left")
 
         # valid users were flagged for sync
-        self.assertTrue(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.user.pk).sync_unread_private_threads
+        )
+        self.assertTrue(
+            UserModel.objects.get(pk=self.other_user.pk).sync_unread_private_threads
+        )
 
         # user was removed from participation
         self.assertEqual(self.thread.participants.count(), 1)
@@ -428,67 +373,71 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'participants',
-                    'value': self.user.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "participants", "value": self.user.pk}],
         )
 
         self.assertEqual(response.status_code, 200)
-        self.assertFalse(response.json()['deleted'])
+        self.assertFalse(response.json()["deleted"])
 
         # thread still exists
         self.assertTrue(Thread.objects.get(pk=self.thread.pk))
 
         # leave event has valid type
-        event = self.thread.post_set.order_by('id').last()
+        event = self.thread.post_set.order_by("id").last()
         self.assertTrue(event.is_event)
-        self.assertTrue(event.event_type, 'participant_left')
+        self.assertTrue(event.event_type, "participant_left")
 
         # valid users were flagged for sync
-        self.assertTrue(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.user.pk).sync_unread_private_threads
+        )
+        self.assertTrue(
+            UserModel.objects.get(pk=self.other_user.pk).sync_unread_private_threads
+        )
 
         # user was removed from participation
         self.assertEqual(self.thread.participants.count(), 1)
         self.assertEqual(self.thread.participants.filter(pk=self.user.pk).count(), 0)
 
-    @patch_user_acl({'can_moderate_private_threads': True})
+    @patch_user_acl({"can_moderate_private_threads": True})
     def test_moderator_remove_user(self):
         """api allows moderator to remove other user"""
-        removed_user = UserModel.objects.create_user('Vigilante', 'test@test.com', 'pass123')
+        removed_user = UserModel.objects.create_user(
+            "Vigilante", "test@test.com", "pass123"
+        )
 
         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]
+        )
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'participants',
-                    'value': removed_user.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "participants", "value": removed_user.pk}],
         )
 
         self.assertEqual(response.status_code, 200)
-        self.assertFalse(response.json()['deleted'])
+        self.assertFalse(response.json()["deleted"])
 
         # thread still exists
         self.assertTrue(Thread.objects.get(pk=self.thread.pk))
 
         # leave event has valid type
-        event = self.thread.post_set.order_by('id').last()
+        event = self.thread.post_set.order_by("id").last()
         self.assertTrue(event.is_event)
-        self.assertTrue(event.event_type, 'participant_removed')
+        self.assertTrue(event.event_type, "participant_removed")
 
         # valid users were flagged for sync
-        self.assertTrue(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=removed_user.pk).sync_unread_private_threads)
+        self.assertTrue(
+            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=removed_user.pk).sync_unread_private_threads
+        )
 
         # user was removed from participation
         self.assertEqual(self.thread.participants.count(), 2)
@@ -500,33 +449,34 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.add_participants(self.thread, [self.other_user])
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'participants',
-                    'value': self.other_user.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "participants", "value": self.other_user.pk}],
         )
 
         self.assertEqual(response.status_code, 200)
-        self.assertFalse(response.json()['deleted'])
+        self.assertFalse(response.json()["deleted"])
 
         # thread still exists
         self.assertTrue(Thread.objects.get(pk=self.thread.pk))
 
         # leave event has valid type
-        event = self.thread.post_set.order_by('id').last()
+        event = self.thread.post_set.order_by("id").last()
         self.assertTrue(event.is_event)
-        self.assertTrue(event.event_type, 'participant_removed')
+        self.assertTrue(event.event_type, "participant_removed")
 
         # valid users were flagged for sync
-        self.assertTrue(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.user.pk).sync_unread_private_threads
+        )
+        self.assertTrue(
+            UserModel.objects.get(pk=self.other_user.pk).sync_unread_private_threads
+        )
 
         # user was removed from participation
         self.assertEqual(self.thread.participants.count(), 1)
-        self.assertEqual(self.thread.participants.filter(pk=self.other_user.pk).count(), 0)
+        self.assertEqual(
+            self.thread.participants.filter(pk=self.other_user.pk).count(), 0
+        )
 
     def test_owner_leave_thread(self):
         """api allows owner to remove hisemf from thread, causing thread to close"""
@@ -534,29 +484,28 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.add_participants(self.thread, [self.other_user])
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'participants',
-                    'value': self.user.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "participants", "value": self.user.pk}],
         )
 
         self.assertEqual(response.status_code, 200)
-        self.assertFalse(response.json()['deleted'])
+        self.assertFalse(response.json()["deleted"])
 
         # thread still exists and is closed
         self.assertTrue(Thread.objects.get(pk=self.thread.pk).is_closed)
 
         # leave event has valid type
-        event = self.thread.post_set.order_by('id').last()
+        event = self.thread.post_set.order_by("id").last()
         self.assertTrue(event.is_event)
-        self.assertTrue(event.event_type, 'owner_left')
+        self.assertTrue(event.event_type, "owner_left")
 
         # valid users were flagged for sync
-        self.assertTrue(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.user.pk).sync_unread_private_threads
+        )
+        self.assertTrue(
+            UserModel.objects.get(pk=self.other_user.pk).sync_unread_private_threads
+        )
 
         # user was removed from participation
         self.assertEqual(self.thread.participants.count(), 1)
@@ -567,24 +516,21 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'participants',
-                    'value': self.user.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "participants", "value": self.user.pk}],
         )
 
         self.assertEqual(response.status_code, 200)
-        self.assertTrue(response.json()['deleted'])
+        self.assertTrue(response.json()["deleted"])
 
         # thread is gone
         with self.assertRaises(Thread.DoesNotExist):
             Thread.objects.get(pk=self.thread.pk)
 
         # valid users were flagged for sync
-        self.assertTrue(UserModel.objects.get(pk=self.user.pk).sync_unread_private_threads)
+        self.assertTrue(
+            UserModel.objects.get(pk=self.user.pk).sync_unread_private_threads
+        )
 
 
 class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
@@ -593,57 +539,40 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'owner',
-                    'value': '',
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "owner", "value": ""}]
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["A valid integer is required."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.thread.pk, "detail": ["A valid integer is required."]},
+        )
 
     def test_invalid_user_id(self):
         """api handles invalid user id"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'owner',
-                    'value': 'dsadsa',
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "owner", "value": "dsadsa"}]
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["A valid integer is required."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.thread.pk, "detail": ["A valid integer is required."]},
+        )
 
     def test_nonexistant_user_id(self):
         """api handles nonexistant user id"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'owner',
-                    'value': self.other_user.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "owner", "value": self.other_user.pk}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["Participant doesn't exist."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.thread.pk, "detail": ["Participant doesn't exist."]},
+        )
 
     def test_no_permission(self):
         """non-moderator/owner can't change owner"""
@@ -651,19 +580,18 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'owner',
-                    'value': self.user.pk,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "owner", "value": self.user.pk}]
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["Only thread owner and moderators can change threads owners."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.pk,
+                "detail": [
+                    "Only thread owner and moderators can change threads owners."
+                ],
+            },
+        )
 
     def test_no_change(self):
         """api validates that new owner id is same as current owner"""
@@ -671,19 +599,13 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.add_participants(self.thread, [self.other_user])
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'owner',
-                    'value': self.user.pk,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "owner", "value": self.user.pk}]
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["This user already is thread owner."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.thread.pk, "detail": ["This user already is thread owner."]},
+        )
 
     def test_change_closed_thread_owner(self):
         """non-moderator can't change owner in closed thread"""
@@ -694,19 +616,17 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'owner',
-                    'value': self.other_user.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "owner", "value": self.other_user.pk}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.pk,
-            'detail': ["Only moderators can change closed threads owners."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.pk,
+                "detail": ["Only moderators can change closed threads owners."],
+            },
+        )
 
     def test_owner_change_thread_owner(self):
         """owner can pass thread ownership to other participant"""
@@ -714,20 +634,19 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.add_participants(self.thread, [self.other_user])
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'owner',
-                    'value': self.other_user.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "owner", "value": self.other_user.pk}],
         )
 
         self.assertEqual(response.status_code, 200)
 
         # valid users were flagged for sync
-        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.assertFalse(
+            UserModel.objects.get(pk=self.user.pk).sync_unread_private_threads
+        )
+        self.assertTrue(
+            UserModel.objects.get(pk=self.other_user.pk).sync_unread_private_threads
+        )
 
         # ownership was transfered
         self.assertEqual(self.thread.participants.count(), 2)
@@ -735,34 +654,36 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertFalse(ThreadParticipant.objects.get(user=self.user).is_owner)
 
         # change was recorded in event
-        event = self.thread.post_set.order_by('id').last()
+        event = self.thread.post_set.order_by("id").last()
         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})
+    @patch_user_acl({"can_moderate_private_threads": True})
     def test_moderator_change_owner(self):
         """moderator can change thread owner to other user"""
-        new_owner = UserModel.objects.create_user('NewOwner', 'new@owner.com', 'pass123')
+        new_owner = UserModel.objects.create_user(
+            "NewOwner", "new@owner.com", "pass123"
+        )
 
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.add_participants(self.thread, [self.user, new_owner])
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'owner',
-                    'value': new_owner.pk,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "owner", "value": new_owner.pk}]
         )
 
         self.assertEqual(response.status_code, 200)
 
         # valid users were flagged for sync
-        self.assertTrue(UserModel.objects.get(pk=new_owner.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=new_owner.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
+        )
 
         # ownership was transferred
         self.assertEqual(self.thread.participants.count(), 3)
@@ -771,31 +692,29 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertFalse(ThreadParticipant.objects.get(user=self.other_user).is_owner)
 
         # change was recorded in event
-        event = self.thread.post_set.order_by('id').last()
+        event = self.thread.post_set.order_by("id").last()
         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})
+    @patch_user_acl({"can_moderate_private_threads": True})
     def test_moderator_takeover(self):
         """moderator can takeover the thread"""
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'owner',
-                    'value': self.user.pk,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "owner", "value": self.user.pk}]
         )
 
         self.assertEqual(response.status_code, 200)
 
         # valid users were flagged for sync
-        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.assertFalse(
+            UserModel.objects.get(pk=self.user.pk).sync_unread_private_threads
+        )
+        self.assertTrue(
+            UserModel.objects.get(pk=self.other_user.pk).sync_unread_private_threads
+        )
 
         # ownership was transfered
         self.assertEqual(self.thread.participants.count(), 2)
@@ -803,11 +722,11 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertFalse(ThreadParticipant.objects.get(user=self.other_user).is_owner)
 
         # change was recorded in event
-        event = self.thread.post_set.order_by('id').last()
+        event = self.thread.post_set.order_by("id").last()
         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})
+    @patch_user_acl({"can_moderate_private_threads": True})
     def test_moderator_closed_thread_takeover(self):
         """moderator can takeover closed thread thread"""
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
@@ -817,20 +736,18 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'owner',
-                    'value': self.user.pk,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "owner", "value": self.user.pk}]
         )
 
         self.assertEqual(response.status_code, 200)
 
         # valid users were flagged for sync
-        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.assertFalse(
+            UserModel.objects.get(pk=self.user.pk).sync_unread_private_threads
+        )
+        self.assertTrue(
+            UserModel.objects.get(pk=self.other_user.pk).sync_unread_private_threads
+        )
 
         # ownership was transferred
         self.assertEqual(self.thread.participants.count(), 2)
@@ -838,6 +755,6 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.assertFalse(ThreadParticipant.objects.get(user=self.other_user).is_owner)
 
         # change was recorded in event
-        event = self.thread.post_set.order_by('id').last()
+        event = self.thread.post_set.order_by("id").last()
         self.assertTrue(event.is_event)
-        self.assertTrue(event.event_type, 'tookover')
+        self.assertTrue(event.event_type, "tookover")

+ 8 - 6
misago/threads/tests/test_privatethread_reply_api.py

@@ -17,7 +17,7 @@ class PrivateThreadReplyApiTestCase(PrivateThreadsTestCase):
         self.api_link = self.thread.get_posts_api_url()
 
         self.other_user = UserModel.objects.create_user(
-            'BobBoberson', 'bob@boberson.com', 'pass123'
+            "BobBoberson", "bob@boberson.com", "pass123"
         )
 
     def test_reply_private_thread(self):
@@ -26,9 +26,7 @@ class PrivateThreadReplyApiTestCase(PrivateThreadsTestCase):
         ThreadParticipant.objects.add_participants(self.thread, [self.other_user])
 
         response = self.client.post(
-            self.api_link, data={
-                'post': "This is test response!",
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -40,5 +38,9 @@ class PrivateThreadReplyApiTestCase(PrivateThreadsTestCase):
         self.assertEqual(self.user.audittrail_set.count(), 1)
 
         # valid user was flagged to sync
-        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.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
+        )

+ 122 - 120
misago/threads/tests/test_privatethread_start_api.py

@@ -17,10 +17,10 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         super().setUp()
 
         self.category = Category.objects.private_threads()
-        self.api_link = reverse('misago:api:private-thread-list')
+        self.api_link = reverse("misago:api:private-thread-list")
 
         self.other_user = UserModel.objects.create_user(
-            'BobBoberson', 'bob@boberson.com', 'pass123'
+            "BobBoberson", "bob@boberson.com", "pass123"
         )
 
     def test_cant_start_thread_as_guest(self):
@@ -30,23 +30,21 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
 
-    @patch_user_acl({'can_use_private_threads': False})
+    @patch_user_acl({"can_use_private_threads": False})
     def test_cant_use_private_threads(self):
         """has no permission to use private threads"""
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't use private threads.",
-        })
+        self.assertEqual(response.json(), {"detail": "You can't use private threads."})
 
-    @patch_user_acl({'can_start_private_threads': False})
+    @patch_user_acl({"can_start_private_threads": False})
     def test_cant_start_private_thread(self):
         """permission to start private thread is validated"""
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't start private threads.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't start private threads."}
+        )
 
     def test_empty_data(self):
         """no data sent handling has no showstoppers"""
@@ -54,11 +52,12 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'to': ["You have to enter user names."],
-                'title': ["You have to enter thread title."],
-                'post': ["You have to enter a message."],
-            }
+            response.json(),
+            {
+                "to": ["You have to enter user names."],
+                "title": ["You have to enter thread title."],
+                "post": ["You have to enter a message."],
+            },
         )
 
     def test_title_is_validated(self):
@@ -66,17 +65,16 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': [self.other_user.username],
-                'title': "------",
-                'post': "Lorem ipsum dolor met, sit amet elit!",
-            }
+                "to": [self.other_user.username],
+                "title": "------",
+                "post": "Lorem ipsum dolor met, sit amet elit!",
+            },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'title': ["Thread title should contain alpha-numeric characters."],
-            }
+            response.json(),
+            {"title": ["Thread title should contain alpha-numeric characters."]},
         )
 
     def test_post_is_validated(self):
@@ -84,17 +82,20 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': [self.other_user.username],
-                'title': "Lorem ipsum dolor met",
-                'post': "a",
-            }
+                "to": [self.other_user.username],
+                "title": "Lorem ipsum dolor met",
+                "post": "a",
+            },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'post': ["Posted message should be at least 5 characters long (it has 1)."],
-            }
+            response.json(),
+            {
+                "post": [
+                    "Posted message should be at least 5 characters long (it has 1)."
+                ]
+            },
         )
 
     def test_cant_invite_self(self):
@@ -102,17 +103,20 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': [self.user.username],
-                'title': "Lorem ipsum dolor met",
-                'post': "Lorem ipsum dolor.",
-            }
+                "to": [self.user.username],
+                "title": "Lorem ipsum dolor met",
+                "post": "Lorem ipsum dolor.",
+            },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'to': ["You can't include yourself on the list of users to invite to new thread."],
-            }
+            response.json(),
+            {
+                "to": [
+                    "You can't include yourself on the list of users to invite to new thread."
+                ]
+            },
         )
 
     def test_cant_invite_nonexisting(self):
@@ -120,17 +124,15 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': ['Ab', 'Cd'],
-                'title': "Lorem ipsum dolor met",
-                'post': "Lorem ipsum dolor.",
-            }
+                "to": ["Ab", "Cd"],
+                "title": "Lorem ipsum dolor met",
+                "post": "Lorem ipsum dolor.",
+            },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'to': ["One or more users could not be found: ab, cd"],
-            }
+            response.json(), {"to": ["One or more users could not be found: ab, cd"]}
         )
 
     def test_cant_invite_too_many(self):
@@ -138,17 +140,20 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': ['Username%s' % i for i in range(50)],
-                'title': "Lorem ipsum dolor met",
-                'post': "Lorem ipsum dolor.",
-            }
+                "to": ["Username%s" % i for i in range(50)],
+                "title": "Lorem ipsum dolor met",
+                "post": "Lorem ipsum dolor.",
+            },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'to': ["You can't add more than 3 users to private thread (you've added 50)."],
-            }
+            response.json(),
+            {
+                "to": [
+                    "You can't add more than 3 users to private thread (you've added 50)."
+                ]
+            },
         )
 
     @patch_user_acl(other_user_cant_use_private_threads)
@@ -157,17 +162,16 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': [self.other_user.username],
-                'title': "Lorem ipsum dolor met",
-                'post': "Lorem ipsum dolor.",
-            }
+                "to": [self.other_user.username],
+                "title": "Lorem ipsum dolor met",
+                "post": "Lorem ipsum dolor.",
+            },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'to': ["BobBoberson can't participate in private threads."],
-            }
+            response.json(),
+            {"to": ["BobBoberson can't participate in private threads."]},
         )
 
     def test_cant_invite_blocking(self):
@@ -177,18 +181,16 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': [self.other_user.username],
-                'title': "Lorem ipsum dolor met",
-                'post': "Lorem ipsum dolor.",
-            }
+                "to": [self.other_user.username],
+                "title": "Lorem ipsum dolor met",
+                "post": "Lorem ipsum dolor.",
+            },
         )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'to': ["BobBoberson is blocking you."],
-        })
+        self.assertEqual(response.json(), {"to": ["BobBoberson is blocking you."]})
 
-    @patch_user_acl({'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)
@@ -196,17 +198,16 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': [self.other_user.username],
-                'title': "-----",
-                'post': "Lorem ipsum dolor.",
-            }
+                "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."],
-            }
+            response.json(),
+            {"title": ["Thread title should contain alpha-numeric characters."]},
         )
 
     def test_cant_invite_followers_only(self):
@@ -218,35 +219,37 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': [self.other_user.username],
-                'title': "Lorem ipsum dolor met",
-                'post': "Lorem ipsum dolor.",
-            }
+                "to": [self.other_user.username],
+                "title": "Lorem ipsum dolor met",
+                "post": "Lorem ipsum dolor.",
+            },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'to': ["BobBoberson limits invitations to private threads to followed users."],
-            }
+            response.json(),
+            {
+                "to": [
+                    "BobBoberson limits invitations to private threads to followed users."
+                ]
+            },
         )
 
         # allow us to bypass following check
-        with patch_user_acl({'can_add_everyone_to_private_threads': 1}):
+        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.",
-                }
+                    "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."],
-                }
+                response.json(),
+                {"title": ["Thread title should contain alpha-numeric characters."]},
             )
 
         # make user follow us
@@ -255,17 +258,16 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': [self.other_user.username],
-                'title': "-----",
-                'post': "Lorem ipsum dolor.",
-            }
+                "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."],
-            }
+            response.json(),
+            {"title": ["Thread title should contain alpha-numeric characters."]},
         )
 
     def test_cant_invite_anyone(self):
@@ -277,35 +279,33 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': [self.other_user.username],
-                'title': "Lorem ipsum dolor met",
-                'post': "Lorem ipsum dolor.",
-            }
+                "to": [self.other_user.username],
+                "title": "Lorem ipsum dolor met",
+                "post": "Lorem ipsum dolor.",
+            },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'to': ["BobBoberson is not allowing invitations to private threads."],
-            }
+            response.json(),
+            {"to": ["BobBoberson is not allowing invitations to private threads."]},
         )
 
         # allow us to bypass user preference check
-        with patch_user_acl({'can_add_everyone_to_private_threads': 1}):
+        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.",
-                }
+                    "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."],
-                }
+                response.json(),
+                {"title": ["Thread title should contain alpha-numeric characters."]},
             )
 
     def test_can_start_thread(self):
@@ -313,17 +313,17 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': [self.other_user.username],
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-            }
+                "to": [self.other_user.username],
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         thread = self.user.thread_set.all()[:1][0]
 
         response_json = response.json()
-        self.assertEqual(response_json['url'], thread.get_absolute_url())
+        self.assertEqual(response_json["url"], thread.get_absolute_url())
 
         response = self.client.get(thread.get_absolute_url())
         self.assertContains(response, self.category.name)
@@ -346,7 +346,7 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
 
         post = self.user.post_set.all()[:1][0]
         self.assertEqual(post.category_id, self.category.pk)
-        self.assertEqual(post.original, 'Lorem ipsum dolor met!')
+        self.assertEqual(post.original, "Lorem ipsum dolor met!")
         self.assertEqual(post.poster_id, self.user.id)
         self.assertEqual(post.poster_name, self.user.username)
 
@@ -359,7 +359,9 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         ThreadParticipant.objects.get(thread=thread, user=self.user, is_owner=True)
 
         # other user was added to thread
-        ThreadParticipant.objects.get(thread=thread, user=self.other_user, is_owner=False)
+        ThreadParticipant.objects.get(
+            thread=thread, user=self.other_user, is_owner=False
+        )
 
         # other user has sync_unread_private_threads flag
         user_to_sync = UserModel.objects.get(sync_unread_private_threads=True)
@@ -383,9 +385,9 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'to': [self.other_user.username],
-                'title': "Brzęczyżczykiewicz",
-                'post': "Chrzążczyżewoszyce, powiat Łękółody.",
-            }
+                "to": [self.other_user.username],
+                "title": "Brzęczyżczykiewicz",
+                "post": "Chrzążczyżewoszyce, powiat Łękółody.",
+            },
         )
         self.assertEqual(response.status_code, 200)

+ 49 - 55
misago/threads/tests/test_privatethreads_api.py

@@ -12,7 +12,7 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
     def setUp(self):
         super().setUp()
 
-        self.api_link = reverse('misago:api:private-thread-list')
+        self.api_link = reverse("misago:api:private-thread-list")
 
     def test_unauthenticated(self):
         """api requires user to sign in and be able to access it"""
@@ -20,18 +20,16 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You have to sign in to use private threads."
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You have to sign in to use private threads."}
+        )
 
     @patch_user_acl({"can_use_private_threads": False})
     def test_no_permission(self):
         """api requires user to have permission to be able to access it"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't use private threads."
-        })
+        self.assertEqual(response.json(), {"detail": "You can't use private threads."})
 
     @patch_user_acl({"can_use_private_threads": True})
     def test_empty_list(self):
@@ -40,7 +38,7 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
         self.assertEqual(response.status_code, 200)
 
         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):
@@ -60,8 +58,8 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['count'], 1)
-        self.assertEqual(response_json['results'][0]['id'], visible.id)
+        self.assertEqual(response_json["count"], 1)
+        self.assertEqual(response_json["results"][0]["id"], visible.id)
 
         # threads with reported posts will also show to moderators
         with patch_user_acl({"can_moderate_private_threads": True}):
@@ -69,9 +67,9 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
             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)
+            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):
@@ -87,18 +85,16 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You have to sign in to use private threads."
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You have to sign in to use private threads."}
+        )
 
     @patch_user_acl({"can_use_private_threads": False})
     def test_no_permission(self):
         """user needs to have permission to see private thread"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't use private threads."
-        })
+        self.assertEqual(response.json(), {"detail": "You can't use private threads."})
 
     @patch_user_acl({"can_use_private_threads": True})
     def test_no_participant(self):
@@ -106,19 +102,17 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
 
-    @patch_user_acl({
-        "can_use_private_threads": True,
-        "can_moderate_private_threads": True,
-    })
+    @patch_user_acl(
+        {"can_use_private_threads": True, "can_moderate_private_threads": True}
+    )
     def test_mod_not_reported(self):
         """moderator can't see private thread that has no reports"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
 
-    @patch_user_acl({
-        "can_use_private_threads": True,
-        "can_moderate_private_threads": False,
-    })
+    @patch_user_acl(
+        {"can_use_private_threads": True, "can_moderate_private_threads": False}
+    )
     def test_reported_not_mod(self):
         """non-mod can't see private thread that has reported posts"""
         self.thread.has_reported_posts = True
@@ -136,17 +130,18 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['title'], self.thread.title)
+        self.assertEqual(response_json["title"], self.thread.title)
         self.assertEqual(
-            response_json['participants'], [
+            response_json["participants"],
+            [
                 {
-                    'id': self.user.id,
-                    'username': self.user.username,
-                    'avatars': self.user.avatars,
-                    'url': self.user.get_absolute_url(),
-                    'is_owner': True,
-                },
-            ]
+                    "id": self.user.id,
+                    "username": self.user.username,
+                    "avatars": self.user.avatars,
+                    "url": self.user.get_absolute_url(),
+                    "is_owner": True,
+                }
+            ],
         )
 
     @patch_user_acl({"can_use_private_threads": True})
@@ -158,23 +153,23 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['title'], self.thread.title)
+        self.assertEqual(response_json["title"], self.thread.title)
         self.assertEqual(
-            response_json['participants'], [
+            response_json["participants"],
+            [
                 {
-                    'id': self.user.id,
-                    'username': self.user.username,
-                    'avatars': self.user.avatars,
-                    'url': self.user.get_absolute_url(),
-                    'is_owner': False,
-                },
-            ]
+                    "id": self.user.id,
+                    "username": self.user.username,
+                    "avatars": self.user.avatars,
+                    "url": self.user.get_absolute_url(),
+                    "is_owner": False,
+                }
+            ],
         )
 
-    @patch_user_acl({
-        "can_use_private_threads": True,
-        "can_moderate_private_threads": True,
-    })
+    @patch_user_acl(
+        {"can_use_private_threads": True, "can_moderate_private_threads": True}
+    )
     def test_mod_can_see_reported(self):
         """moderator can see private thread that has reports"""
         self.thread.has_reported_posts = True
@@ -184,8 +179,8 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['title'], self.thread.title)
-        self.assertEqual(response_json['participants'], [])
+        self.assertEqual(response_json["title"], self.thread.title)
+        self.assertEqual(response_json["participants"], [])
 
 
 class PrivateThreadDeleteApiTests(PrivateThreadsTestCase):
@@ -200,21 +195,20 @@ class PrivateThreadDeleteApiTests(PrivateThreadsTestCase):
     @patch_private_threads_acl({"can_hide_threads": 0})
     def test_hide_thread_no_permission(self):
         """api tests permission to delete threads"""
-        
+
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json()['detail'], "You can't delete threads in this category."
+            response.json()["detail"], "You can't delete threads in this category."
         )
 
-
     @patch_private_threads_acl({"can_hide_threads": 1})
     def test_delete_thread_no_permission(self):
         """api tests permission to delete threads"""
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json()['detail'], "You can't delete threads in this category."
+            response.json()["detail"], "You can't delete threads in this category."
         )
 
     @patch_private_threads_acl({"can_hide_threads": 2})

+ 1 - 1
misago/threads/tests/test_privatethreads_lists.py

@@ -11,7 +11,7 @@ class PrivateThreadsListTests(PrivateThreadsTestCase):
     def setUp(self):
         super().setUp()
 
-        self.test_link = reverse('misago:private-threads')
+        self.test_link = reverse("misago:private-threads")
 
     def test_unauthenticated(self):
         """view requires user to sign in and be able to access it"""

+ 55 - 62
misago/threads/tests/test_search.py

@@ -9,19 +9,19 @@ class SearchApiTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.category = Category.objects.get(slug='first-category')
+        self.category = Category.objects.get(slug="first-category")
 
-        self.api_link = reverse('misago:api:search')
+        self.api_link = reverse("misago:api:search")
 
     def index_post(self, post):
         if post.id == post.thread.first_post_id:
             post.set_search_document(post.thread.title)
         else:
             post.set_search_document()
-        post.save(update_fields=['search_document'])
+        post.save(update_fields=["search_document"])
 
         post.update_search_vector()
-        post.save(update_fields=['search_vector'])
+        post.save(update_fields=["search_vector"])
 
     def test_no_query(self):
         """api handles no search query"""
@@ -29,23 +29,23 @@ class SearchApiTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertIn('threads', [p['id'] for p in reponse_json])
+        self.assertIn("threads", [p["id"] for p in reponse_json])
 
         for provider in reponse_json:
-            if provider['id'] == 'threads':
-                self.assertEqual(provider['results']['results'], [])
+            if provider["id"] == "threads":
+                self.assertEqual(provider["results"]["results"], [])
 
     def test_empty_query(self):
         """api handles empty search query"""
-        response = self.client.get('%s?q=' % self.api_link)
+        response = self.client.get("%s?q=" % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertIn('threads', [p['id'] for p in reponse_json])
+        self.assertIn("threads", [p["id"] for p in reponse_json])
 
         for provider in reponse_json:
-            if provider['id'] == 'threads':
-                self.assertEqual(provider['results']['results'], [])
+            if provider["id"] == "threads":
+                self.assertEqual(provider["results"]["results"], [])
 
     def test_short_query(self):
         """api handles short search query"""
@@ -53,15 +53,15 @@ class SearchApiTests(AuthenticatedUserTestCase):
         post = testutils.reply_thread(thread, message="Lorem ipsum dolor.")
         self.index_post(post)
 
-        response = self.client.get('%s?q=ip' % self.api_link)
+        response = self.client.get("%s?q=ip" % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertIn('threads', [p['id'] for p in reponse_json])
+        self.assertIn("threads", [p["id"] for p in reponse_json])
 
         for provider in reponse_json:
-            if provider['id'] == 'threads':
-                self.assertEqual(provider['results']['results'], [])
+            if provider["id"] == "threads":
+                self.assertEqual(provider["results"]["results"], [])
 
     def test_wrong_query(self):
         """api handles query miss"""
@@ -69,55 +69,51 @@ class SearchApiTests(AuthenticatedUserTestCase):
         post = testutils.reply_thread(thread, message="Lorem ipsum dolor.")
         self.index_post(post)
 
-        response = self.client.get('%s?q=elit' % self.api_link)
+        response = self.client.get("%s?q=elit" % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertIn('threads', [p['id'] for p in reponse_json])
+        self.assertIn("threads", [p["id"] for p in reponse_json])
 
         for provider in reponse_json:
-            if provider['id'] == 'threads':
-                self.assertEqual(provider['results']['results'], [])
+            if provider["id"] == "threads":
+                self.assertEqual(provider["results"]["results"], [])
 
     def test_hidden_post(self):
         """hidden posts are extempt from search"""
         thread = testutils.post_thread(self.category)
         post = testutils.reply_thread(
-            thread,
-            message="Lorem ipsum dolor.",
-            is_hidden=True,
+            thread, message="Lorem ipsum dolor.", is_hidden=True
         )
         self.index_post(post)
 
-        response = self.client.get('%s?q=ipsum' % self.api_link)
+        response = self.client.get("%s?q=ipsum" % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertIn('threads', [p['id'] for p in reponse_json])
+        self.assertIn("threads", [p["id"] for p in reponse_json])
 
         for provider in reponse_json:
-            if provider['id'] == 'threads':
-                self.assertEqual(provider['results']['results'], [])
+            if provider["id"] == "threads":
+                self.assertEqual(provider["results"]["results"], [])
 
     def test_unapproved_post(self):
         """unapproves posts are extempt from search"""
         thread = testutils.post_thread(self.category)
         post = testutils.reply_thread(
-            thread,
-            message="Lorem ipsum dolor.",
-            is_unapproved=True,
+            thread, message="Lorem ipsum dolor.", is_unapproved=True
         )
         self.index_post(post)
 
-        response = self.client.get('%s?q=ipsum' % self.api_link)
+        response = self.client.get("%s?q=ipsum" % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertIn('threads', [p['id'] for p in reponse_json])
+        self.assertIn("threads", [p["id"] for p in reponse_json])
 
         for provider in reponse_json:
-            if provider['id'] == 'threads':
-                self.assertEqual(provider['results']['results'], [])
+            if provider["id"] == "threads":
+                self.assertEqual(provider["results"]["results"], [])
 
     def test_query(self):
         """api handles search query"""
@@ -125,17 +121,17 @@ class SearchApiTests(AuthenticatedUserTestCase):
         post = testutils.reply_thread(thread, message="Lorem ipsum dolor.")
         self.index_post(post)
 
-        response = self.client.get('%s?q=ipsum' % self.api_link)
+        response = self.client.get("%s?q=ipsum" % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertIn('threads', [p['id'] for p in reponse_json])
+        self.assertIn("threads", [p["id"] for p in reponse_json])
 
         for provider in reponse_json:
-            if provider['id'] == 'threads':
-                results = provider['results']['results']
+            if provider["id"] == "threads":
+                results = provider["results"]["results"]
                 self.assertEqual(len(results), 1)
-                self.assertEqual(results[0]['id'], post.id)
+                self.assertEqual(results[0]["id"], post.id)
 
     def test_thread_title_search(self):
         """api searches threads by title"""
@@ -145,17 +141,17 @@ class SearchApiTests(AuthenticatedUserTestCase):
         post = testutils.reply_thread(thread, message="Lorem ipsum dolor.")
         self.index_post(post)
 
-        response = self.client.get('%s?q=mars atmosphere' % self.api_link)
+        response = self.client.get("%s?q=mars atmosphere" % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertIn('threads', [p['id'] for p in reponse_json])
+        self.assertIn("threads", [p["id"] for p in reponse_json])
 
         for provider in reponse_json:
-            if provider['id'] == 'threads':
-                results = provider['results']['results']
+            if provider["id"] == "threads":
+                results = provider["results"]["results"]
                 self.assertEqual(len(results), 1)
-                self.assertEqual(results[0]['id'], thread.first_post_id)
+                self.assertEqual(results[0]["id"], thread.first_post_id)
 
     def test_complex_query(self):
         """api handles complex query that uses fulltext search facilities"""
@@ -163,48 +159,47 @@ class SearchApiTests(AuthenticatedUserTestCase):
         post = testutils.reply_thread(thread, message="Atmosphere of Mars")
         self.index_post(post)
 
-        response = self.client.get('%s?q=Mars atmosphere' % self.api_link)
+        response = self.client.get("%s?q=Mars atmosphere" % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertIn('threads', [p['id'] for p in reponse_json])
+        self.assertIn("threads", [p["id"] for p in reponse_json])
 
         for provider in reponse_json:
-            if provider['id'] == 'threads':
-                results = provider['results']['results']
+            if provider["id"] == "threads":
+                results = provider["results"]["results"]
                 self.assertEqual(len(results), 1)
-                self.assertEqual(results[0]['id'], post.id)
+                self.assertEqual(results[0]["id"], post.id)
 
     def test_filtered_query(self):
         """search filters are used by search system"""
         thread = testutils.post_thread(self.category)
         post = testutils.reply_thread(
-            thread,
-            message="You just do MMM in 4th minute and its pwnt",
+            thread, message="You just do MMM in 4th minute and its pwnt"
         )
 
         self.index_post(post)
 
-        response = self.client.get('%s?q=MMM' % self.api_link)
+        response = self.client.get("%s?q=MMM" % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertIn('threads', [p['id'] for p in reponse_json])
+        self.assertIn("threads", [p["id"] for p in reponse_json])
 
         for provider in reponse_json:
-            if provider['id'] == 'threads':
-                results = provider['results']['results']
+            if provider["id"] == "threads":
+                results = provider["results"]["results"]
                 self.assertEqual(len(results), 1)
-                self.assertEqual(results[0]['id'], post.id)
+                self.assertEqual(results[0]["id"], post.id)
 
-        response = self.client.get('%s?q=Marines Medics' % self.api_link)
+        response = self.client.get("%s?q=Marines Medics" % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         for provider in reponse_json:
-            if provider['id'] == 'threads':
-                results = provider['results']['results']
+            if provider["id"] == "threads":
+                results = provider["results"]["results"]
                 self.assertEqual(len(results), 1)
-                self.assertEqual(results[0]['id'], post.id)
+                self.assertEqual(results[0]["id"], post.id)
 
 
 class SearchProviderApiTests(SearchApiTests):
@@ -212,7 +207,5 @@ class SearchProviderApiTests(SearchApiTests):
         super().setUp()
 
         self.api_link = reverse(
-            'misago:api:search', kwargs={
-                'search_provider': 'threads',
-            }
+            "misago:api:search", kwargs={"search_provider": "threads"}
         )

+ 23 - 37
misago/threads/tests/test_subscription_middleware.py

@@ -14,13 +14,13 @@ UserModel = get_user_model()
 class SubscriptionMiddlewareTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
-        self.category = Category.objects.get(slug='first-category')
+        self.category = Category.objects.get(slug="first-category")
 
 
 class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
     def setUp(self):
         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):
@@ -32,10 +32,10 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.id,
-                'title': "This is an test thread!",
-                'post': "This is test response!",
-            }
+                "category": self.category.id,
+                "title": "This is an test thread!",
+                "post": "This is test response!",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -51,15 +51,15 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.id,
-                'title': "This is an test thread!",
-                'post': "This is test response!",
-            }
+                "category": self.category.id,
+                "title": "This is an test thread!",
+                "post": "This is test response!",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         # user has subscribed to thread
-        thread = self.user.thread_set.order_by('id').last()
+        thread = self.user.thread_set.order_by("id").last()
         subscription = self.user.subscription_set.get(thread=thread)
 
         self.assertEqual(subscription.category_id, self.category.id)
@@ -74,15 +74,15 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.id,
-                'title': "This is an test thread!",
-                'post': "This is test response!",
-            }
+                "category": self.category.id,
+                "title": "This is an test thread!",
+                "post": "This is test response!",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         # user has subscribed to thread
-        thread = self.user.thread_set.order_by('id').last()
+        thread = self.user.thread_set.order_by("id").last()
         subscription = self.user.subscription_set.get(thread=thread)
 
         self.assertEqual(subscription.category_id, self.category.id)
@@ -94,9 +94,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         super().setUp()
         self.thread = testutils.post_thread(self.category)
         self.api_link = reverse(
-            'misago:api:thread-post-list', kwargs={
-                'thread_pk': self.thread.pk,
-            }
+            "misago:api:thread-post-list", kwargs={"thread_pk": self.thread.pk}
         )
 
     @patch_category_acl({"can_reply_threads": True})
@@ -107,9 +105,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.user.save()
 
         response = self.client.post(
-            self.api_link, data={
-                'post': "This is test response!",
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -123,9 +119,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.user.save()
 
         response = self.client.post(
-            self.api_link, data={
-                'post': "This is test response!",
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -142,9 +136,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.user.save()
 
         response = self.client.post(
-            self.api_link, data={
-                'post': "This is test response!",
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -165,9 +157,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
 
         # reply thread
         response = self.client.post(
-            self.api_link, data={
-                'post': "This is test response!",
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -185,9 +175,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.user.save()
 
         response = self.client.post(
-            self.api_link, data={
-                'post': "This is test response!",
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -196,9 +184,7 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
 
         # reply again
         response = self.client.post(
-            self.api_link, data={
-                'post': "This is test response!",
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 

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

@@ -22,10 +22,7 @@ class SubscriptionsTests(TestCase):
         self.anon = AnonymousUser()
 
     def post_thread(self, datetime):
-        return testutils.post_thread(
-            category=self.category,
-            started_on=datetime,
-        )
+        return testutils.post_thread(category=self.category, started_on=datetime)
 
     def test_anon_subscription(self):
         """make single thread sub aware for anon"""

+ 3 - 3
misago/threads/tests/test_sync_unread_private_threads.py

@@ -14,7 +14,7 @@ class SyncUnreadPrivateThreadsTestCase(PrivateThreadsTestCase):
         super().setUp()
 
         self.other_user = UserModel.objects.create_user(
-            'BobBoberson', 'bob@boberson.com', 'pass123'
+            "BobBoberson", "bob@boberson.com", "pass123"
         )
 
         self.thread = testutils.post_thread(self.category, poster=self.user)
@@ -27,7 +27,7 @@ class SyncUnreadPrivateThreadsTestCase(PrivateThreadsTestCase):
         self.user.sync_unread_private_threads = True
         self.user.save()
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
 
         # user was resynced
@@ -55,7 +55,7 @@ class SyncUnreadPrivateThreadsTestCase(PrivateThreadsTestCase):
         self.user.save()
 
         # middleware did recount and accounted for new unread post
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
 
         self.reload_user()

+ 115 - 167
misago/threads/tests/test_thread_bulkpatch_api.py

@@ -14,19 +14,24 @@ class ThreadsBulkPatchApiTestCase(ThreadsApiTestCase):
     def setUp(self):
         super().setUp()
 
-        self.threads = list(reversed([
-            testutils.post_thread(category=self.category),
-            testutils.post_thread(category=self.category),
-            testutils.post_thread(category=self.category),
-        ]))
+        self.threads = list(
+            reversed(
+                [
+                    testutils.post_thread(category=self.category),
+                    testutils.post_thread(category=self.category),
+                    testutils.post_thread(category=self.category),
+                ]
+            )
+        )
 
         self.ids = list(reversed([t.id for t in self.threads]))
 
-        self.api_link = reverse('misago:api:thread-list')
+        self.api_link = reverse("misago:api:thread-list")
 
     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"
+        )
 
 
 class BulkPatchSerializerTests(ThreadsBulkPatchApiTestCase):
@@ -35,90 +40,76 @@ class BulkPatchSerializerTests(ThreadsBulkPatchApiTestCase):
         response = self.patch(self.api_link, [1, 2, 3])
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'non_field_errors': [
-                "Invalid data. Expected a dictionary, but got list.",
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "non_field_errors": [
+                    "Invalid data. Expected a dictionary, but got list."
+                ]
+            },
+        )
 
     def test_missing_input_keys(self):
         """api rejects input with missing keys"""
         response = self.patch(self.api_link, {})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'ids': [
-                "This field is required.",
-            ],
-            'ops': [
-                "This field is required.",
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {"ids": ["This field is required."], "ops": ["This field is required."]},
+        )
 
     def test_empty_input_keys(self):
         """api rejects input with empty keys"""
-        response = self.patch(self.api_link, {
-            'ids': [],
-            'ops': [],
-        })
+        response = self.patch(self.api_link, {"ids": [], "ops": []})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'ids': [
-                "Ensure this field has at least 1 elements.",
-            ],
-            'ops': [
-                "Ensure this field has at least 1 elements.",
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "ids": ["Ensure this field has at least 1 elements."],
+                "ops": ["Ensure this field has at least 1 elements."],
+            },
+        )
 
     def test_invalid_input_keys(self):
         """api rejects input with invalid keys"""
-        response = self.patch(self.api_link, {
-            'ids': ['a'],
-            'ops': [1],
-        })
+        response = self.patch(self.api_link, {"ids": ["a"], "ops": [1]})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'ids': [
-                "A valid integer is required.",
-            ],
-            'ops': [
-                'Expected a dictionary of items but got type "int".',
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "ids": ["A valid integer is required."],
+                "ops": ['Expected a dictionary of items but got type "int".'],
+            },
+        )
 
     def test_too_small_id(self):
         """api rejects input with implausiple id"""
-        response = self.patch(self.api_link, {
-            'ids': [0],
-            'ops': [{}],
-        })
+        response = self.patch(self.api_link, {"ids": [0], "ops": [{}]})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'ids': [
-                "Ensure this value is greater than or equal to 1.",
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {"ids": ["Ensure this value is greater than or equal to 1."]},
+        )
 
     def test_too_large_input(self):
         """api rejects too large input"""
-        response = self.patch(self.api_link, {
-            'ids': [i + 1 for i in range(200)],
-            'ops': [{} for i in range(200)],
-        })
+        response = self.patch(
+            self.api_link,
+            {"ids": [i + 1 for i in range(200)], "ops": [{} for i in range(200)]},
+        )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'ids': [
-                "Ensure this field has no more than 40 elements.",
-            ],
-            'ops': [
-                "Ensure this field has no more than 10 elements.",
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "ids": ["Ensure this field has no more than 40 elements."],
+                "ops": ["Ensure this field has no more than 10 elements."],
+            },
+        )
 
     def test_threads_not_found(self):
         """api fails to find threads"""
@@ -127,59 +118,46 @@ class BulkPatchSerializerTests(ThreadsBulkPatchApiTestCase):
             testutils.post_thread(category=self.category, is_unapproved=True),
         ]
 
-        response = self.patch(self.api_link, {
-            'ids': [t.id for t in threads],
-            'ops': [{}],
-        })
+        response = self.patch(
+            self.api_link, {"ids": [t.id for t in threads], "ops": [{}]}
+        )
 
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "One or more threads to update could not be found.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "One or more threads to update could not be found."},
+        )
 
     def test_ops_invalid(self):
         """api validates descriptions"""
-        response = self.patch(self.api_link, {
-            'ids': self.ids[:1],
-            'ops': [{}],
-        })
+        response = self.patch(self.api_link, {"ids": self.ids[:1], "ops": [{}]})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), [
-            {'id': self.ids[0], 'detail': ['undefined op']},
-        ])
+        self.assertEqual(
+            response.json(), [{"id": self.ids[0], "detail": ["undefined op"]}]
+        )
 
     def test_anonymous_user(self):
         """anonymous users can't use bulk actions"""
         self.logout_user()
 
-        response = self.patch(self.api_link, {
-            'ids': self.ids[:1],
-            'ops': [{}],
-        })
+        response = self.patch(self.api_link, {"ids": self.ids[:1], "ops": [{}]})
         self.assertEqual(response.status_code, 403)
 
 
 class ThreadAddAclApiTests(ThreadsBulkPatchApiTestCase):
     def test_add_acl_true(self):
         """api adds current threads acl to response"""
-        response = self.patch(self.api_link,
-            {
-            'ids': self.ids,
-            'ops': [
-                {
-                    'op': 'add',
-                    'path': 'acl',
-                    'value': True,
-                },
-            ]
-        })
+        response = self.patch(
+            self.api_link,
+            {"ids": self.ids, "ops": [{"op": "add", "path": "acl", "value": True}]},
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         for i, thread in enumerate(self.threads):
-            self.assertEqual(response_json[i]['id'], thread.id)
-            self.assertTrue(response_json[i]['acl'])
+            self.assertEqual(response_json[i]["id"], thread.id)
+            self.assertTrue(response_json[i]["acl"])
 
 
 class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
@@ -189,28 +167,24 @@ class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
         response = self.patch(
             self.api_link,
             {
-                'ids': self.ids,
-                'ops': [
-                    {
-                        'op': 'replace',
-                        'path': 'title',
-                        'value': 'Changed the title!',
-                    },
-                ]
-            }
+                "ids": self.ids,
+                "ops": [
+                    {"op": "replace", "path": "title", "value": "Changed the title!"}
+                ],
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         for i, thread in enumerate(self.threads):
-            self.assertEqual(response_json[i]['id'], thread.id)
-            self.assertEqual(response_json[i]['title'], 'Changed the title!')
+            self.assertEqual(response_json[i]["id"], thread.id)
+            self.assertEqual(response_json[i]["title"], "Changed the title!")
 
         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.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):
@@ -218,24 +192,19 @@ class BulkThreadChangeTitleApiTests(ThreadsBulkPatchApiTestCase):
         response = self.patch(
             self.api_link,
             {
-                'ids': self.ids,
-                'ops': [
-                    {
-                        'op': 'replace',
-                        'path': 'title',
-                        'value': 'Changed the title!',
-                    },
-                ]
-            }
+                "ids": self.ids,
+                "ops": [
+                    {"op": "replace", "path": "title", "value": "Changed the title!"}
+                ],
+            },
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         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]['detail'],
-                ["You can't edit threads in this category."],
+                response_json[i]["detail"], ["You can't edit threads in this category."]
             )
 
 
@@ -243,15 +212,10 @@ class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
     def setUp(self):
         super().setUp()
 
-        Category(
-            name='Other Category',
-            slug='other-category',
-        ).insert_at(
-            self.category,
-            position='last-child',
-            save=True,
+        Category(name="Other Category", slug="other-category").insert_at(
+            self.category, position="last-child", save=True
         )
-        self.other_category = Category.objects.get(slug='other-category')
+        self.other_category = Category.objects.get(slug="other-category")
 
     @patch_category_acl({"can_move_threads": True})
     @patch_other_category_acl({"can_start_threads": 2})
@@ -260,27 +224,23 @@ class BulkThreadMoveApiTests(ThreadsBulkPatchApiTestCase):
         response = self.patch(
             self.api_link,
             {
-                'ids': self.ids,
-                'ops': [
-                    {
-                        'op': 'replace',
-                        'path': 'category',
-                        'value': self.other_category.id,
-                    },
+                "ids": self.ids,
+                "ops": [
                     {
-                        'op': 'replace',
-                        'path': 'flatten-categories',
-                        'value': None,
+                        "op": "replace",
+                        "path": "category",
+                        "value": self.other_category.id,
                     },
-                ]
-            }
+                    {"op": "replace", "path": "flatten-categories", "value": None},
+                ],
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         for i, thread in enumerate(self.threads):
-            self.assertEqual(response_json[i]['id'], thread.id)
-            self.assertEqual(response_json[i]['category'], self.other_category.id)
+            self.assertEqual(response_json[i]["id"], thread.id)
+            self.assertEqual(response_json[i]["category"], self.other_category.id)
 
         for thread in Thread.objects.filter(id__in=self.ids):
             self.assertEqual(thread.category_id, self.other_category.id)
@@ -299,22 +259,16 @@ class BulkThreadsHideApiTests(ThreadsBulkPatchApiTestCase):
         response = self.patch(
             self.api_link,
             {
-                'ids': self.ids,
-                'ops': [
-                    {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                    },
-                ]
-            }
+                "ids": self.ids,
+                "ops": [{"op": "replace", "path": "is-hidden", "value": True}],
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         for i, thread in enumerate(self.threads):
-            self.assertEqual(response_json[i]['id'], thread.id)
-            self.assertTrue(response_json[i]['is_hidden'])
+            self.assertEqual(response_json[i]["id"], thread.id)
+            self.assertTrue(response_json[i]["is_hidden"])
 
         for thread in Thread.objects.filter(id__in=self.ids):
             self.assertTrue(thread.is_hidden)
@@ -343,23 +297,17 @@ class BulkThreadsApproveApiTests(ThreadsBulkPatchApiTestCase):
         response = self.patch(
             self.api_link,
             {
-                'ids': self.ids,
-                'ops': [
-                    {
-                    'op': 'replace',
-                    'path': 'is-unapproved',
-                    'value': False,
-                    },
-                ]
-            }
+                "ids": self.ids,
+                "ops": [{"op": "replace", "path": "is-unapproved", "value": False}],
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         for i, thread in enumerate(self.threads):
-            self.assertEqual(response_json[i]['id'], thread.id)
-            self.assertFalse(response_json[i]['is_unapproved'])
-            self.assertFalse(response_json[i]['has_unapproved_posts'])
+            self.assertEqual(response_json[i]["id"], thread.id)
+            self.assertFalse(response_json[i]["is_unapproved"])
+            self.assertFalse(response_json[i]["has_unapproved_posts"])
 
         for thread in Thread.objects.filter(id__in=self.ids):
             self.assertFalse(thread.is_unapproved)

+ 87 - 141
misago/threads/tests/test_thread_editreply_api.py

@@ -16,16 +16,13 @@ class EditReplyTests(AuthenticatedUserTestCase):
     def setUp(self):
         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.post = testutils.reply_thread(self.thread, poster=self.user)
 
         self.api_link = reverse(
-            'misago:api:thread-post-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.post.pk,
-            }
+            "misago:api:thread-post-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.post.pk},
         )
 
     def put(self, url, data=None):
@@ -41,15 +38,15 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
     def test_thread_visibility(self):
         """thread's visibility is validated"""
-        with patch_category_acl({'can_see': False}):
+        with patch_category_acl({"can_see": False}):
             response = self.put(self.api_link)
             self.assertEqual(response.status_code, 404)
 
-        with patch_category_acl({'can_browse': False}):
+        with patch_category_acl({"can_browse": False}):
             response = self.put(self.api_link)
             self.assertEqual(response.status_code, 404)
 
-        with patch_category_acl({'can_see_all_threads': False}):
+        with patch_category_acl({"can_see_all_threads": False}):
             response = self.put(self.api_link)
             self.assertEqual(response.status_code, 404)
 
@@ -58,9 +55,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
         """permission to edit reply is validated"""
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't edit posts in this category.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't edit posts in this category."}
+        )
 
     @patch_category_acl({"can_edit_posts": 1})
     def test_cant_edit_other_user_reply(self):
@@ -70,9 +67,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't edit other users posts in this category.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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):
@@ -82,9 +80,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't edit posts that are older than 1 minute.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't edit posts that are older than 1 minute."},
+        )
 
     @patch_category_acl({"can_edit_posts": 1, "can_close_threads": False})
     def test_closed_category_no_permission(self):
@@ -94,9 +93,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
         response = self.put(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.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This category is closed. You can't edit posts in it."},
+        )
 
     @patch_category_acl({"can_edit_posts": 1, "can_close_threads": True})
     def test_closed_category(self):
@@ -115,9 +115,10 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
         response = self.put(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.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This thread is closed. You can't edit posts in it."},
+        )
 
     @patch_category_acl({"can_edit_posts": 1, "can_close_threads": True})
     def test_closed_thread(self):
@@ -136,9 +137,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This post is protected. You can't edit it.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This post is protected. You can't edit it."}
+        )
 
     @patch_category_acl({"can_edit_posts": 1, "can_protect_posts": True})
     def test_protected_post_no(self):
@@ -154,22 +155,23 @@ class EditReplyTests(AuthenticatedUserTestCase):
         """no data sent handling has no showstoppers"""
         response = self.put(self.api_link, data={})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "post": ["You have to enter a message."],
-        })
+        self.assertEqual(response.json(), {"post": ["You have to enter a message."]})
 
     @patch_category_acl({"can_edit_posts": 1})
     def test_invalid_data(self):
         """api errors for invalid request data"""
         response = self.client.put(
-            self.api_link,
-            'false',
-            content_type="application/json",
+            self.api_link, "false", content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "non_field_errors": ["Invalid data. Expected a dictionary, but got bool."]
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "non_field_errors": [
+                    "Invalid data. Expected a dictionary, but got bool."
+                ]
+            },
+        )
 
     @patch_category_acl({"can_edit_posts": 1})
     def test_edit_event(self):
@@ -179,23 +181,20 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
         response = self.put(self.api_link, data={})
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "Events can't be edited.",
-        })
+        self.assertEqual(response.json(), {"detail": "Events can't be edited."})
 
     @patch_category_acl({"can_edit_posts": 1})
     def test_post_is_validated(self):
         """post is validated"""
-        response = self.put(
-            self.api_link, data={
-                'post': "a",
-            }
-        )
+        response = self.put(self.api_link, data={"post": "a"})
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'post': ["Posted message should be at least 5 characters long (it has 1)."],
-            }
+            response.json(),
+            {
+                "post": [
+                    "Posted message should be at least 5 characters long (it has 1)."
+                ]
+            },
         )
 
     @patch_category_acl({"can_edit_posts": 1})
@@ -203,13 +202,13 @@ class EditReplyTests(AuthenticatedUserTestCase):
         """endpoint isn't bumping edits count if no change was made to post's body"""
         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)
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, self.post.parsed)
 
-        post = self.thread.post_set.order_by('id').last()
+        post = self.thread.post_set.order_by("id").last()
         self.assertEqual(post.edits, 0)
         self.assertEqual(post.original, self.post.original)
         self.assertIsNone(post.last_editor_id, self.user.id)
@@ -223,7 +222,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
         """endpoint updates reply"""
         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)
 
         response = self.client.get(self.thread.get_absolute_url())
@@ -231,7 +230,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
         self.assertEqual(self.user.audittrail_set.count(), 1)
 
-        post = self.thread.post_set.order_by('id').last()
+        post = self.thread.post_set.order_by("id").last()
         self.assertEqual(post.edits, 1)
         self.assertEqual(post.original, "This is test edit!")
         self.assertEqual(post.last_editor_id, self.user.id)
@@ -257,51 +256,40 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.thread.first_post.save()
 
         api_link = reverse(
-            'misago:api:thread-post-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.thread.first_post.pk,
-            }
+            "misago:api:thread-post-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.thread.first_post.pk},
         )
 
-        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)
 
     @patch_category_acl({"can_edit_posts": 1, "can_protect_posts": True})
     def test_protect_post(self):
         """can protect post"""
         response = self.put(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-                'protect': 1,
-            }
+            self.api_link, data={"post": "Lorem ipsum dolor met!", "protect": 1}
         )
         self.assertEqual(response.status_code, 200)
 
-        post = self.user.post_set.order_by('id').last()
+        post = self.user.post_set.order_by("id").last()
         self.assertTrue(post.is_protected)
 
     @patch_category_acl({"can_edit_posts": 1, "can_protect_posts": False})
     def test_protect_post_no_permission(self):
         """cant protect post without permission"""
         response = self.put(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-                'protect': 1,
-            }
+            self.api_link, data={"post": "Lorem ipsum dolor met!", "protect": 1}
         )
         self.assertEqual(response.status_code, 200)
 
-        post = self.user.post_set.order_by('id').last()
+        post = self.user.post_set.order_by("id").last()
         self.assertFalse(post.is_protected)
 
     @patch_category_acl({"can_edit_posts": 1})
     def test_post_unicode(self):
         """unicode characters can be posted"""
         response = self.put(
-            self.api_link, data={
-                'post': "Chrzążczyżewoszyce, powiat Łękółody.",
-            }
+            self.api_link, data={"post": "Chrzążczyżewoszyce, powiat Łękółody."}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -311,11 +299,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.category.require_edits_approval = True
         self.category.save()
 
-        response = self.put(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
-        )
+        response = self.put(self.api_link, data={"post": "Lorem ipsum dolor met!"})
         self.assertEqual(response.status_code, 200)
 
         post = self.user.post_set.all()[:1][0]
@@ -328,11 +312,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.category.require_edits_approval = True
         self.category.save()
 
-        response = self.put(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
-        )
+        response = self.put(self.api_link, data={"post": "Lorem ipsum dolor met!"})
         self.assertEqual(response.status_code, 200)
 
         post = self.user.post_set.all()[:1][0]
@@ -341,49 +321,36 @@ class EditReplyTests(AuthenticatedUserTestCase):
     @patch_category_acl({"can_edit_posts": 1, "require_edits_approval": True})
     def test_reply_user_moderation_queue(self):
         """edit sends reply to queue due to user acl"""
-        response = self.put(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
-        )
+        response = self.put(self.api_link, data={"post": "Lorem ipsum dolor met!"})
         self.assertEqual(response.status_code, 200)
 
         post = self.user.post_set.all()[:1][0]
         self.assertTrue(post.is_unapproved)
 
-    @patch_category_acl({
-        "can_edit_posts": 1,
-        "require_edits_approval": True,
-    })
+    @patch_category_acl({"can_edit_posts": 1, "require_edits_approval": True})
     @patch_user_acl({"can_approve_content": True})
     def test_reply_user_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
-        response = self.put(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
-        )
+        response = self.put(self.api_link, data={"post": "Lorem ipsum dolor met!"})
         self.assertEqual(response.status_code, 200)
 
         post = self.user.post_set.all()[:1][0]
         self.assertFalse(post.is_unapproved)
 
-    @patch_category_acl({
-        "can_edit_posts": 1,
-        "require_threads_approval": True,
-        "require_replies_approval": True,
-    })
+    @patch_category_acl(
+        {
+            "can_edit_posts": 1,
+            "require_threads_approval": True,
+            "require_replies_approval": True,
+        }
+    )
     def test_reply_omit_other_moderation_queues(self):
         """other queues are omitted"""
         self.category.require_threads_approval = True
         self.category.require_replies_approval = True
         self.category.save()
 
-        response = self.put(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
-        )
+        response = self.put(self.api_link, data={"post": "Lorem ipsum dolor met!"})
         self.assertEqual(response.status_code, 200)
 
         post = self.user.post_set.all()[:1][0]
@@ -396,11 +363,8 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.post.save()
 
         self.api_link = reverse(
-            'misago:api:thread-post-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.post.pk,
-            }
+            "misago:api:thread-post-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.post.pk},
         )
 
     @patch_category_acl({"can_edit_posts": 1})
@@ -411,11 +375,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.category.require_edits_approval = True
         self.category.save()
 
-        response = self.put(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
-        )
+        response = self.put(self.api_link, data={"post": "Lorem ipsum dolor met!"})
         self.assertEqual(response.status_code, 200)
 
         thread = Thread.objects.get(pk=self.thread.pk)
@@ -426,7 +386,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.assertTrue(post.is_unapproved)
 
     @patch_category_acl({"can_edit_posts": 1})
-    @patch_user_acl({'can_approve_content': True})
+    @patch_user_acl({"can_approve_content": True})
     def test_first_reply_category_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         self.setUpFirstReplyTest()
@@ -434,11 +394,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.category.require_edits_approval = True
         self.category.save()
 
-        response = self.put(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
-        )
+        response = self.put(self.api_link, data={"post": "Lorem ipsum dolor met!"})
         self.assertEqual(response.status_code, 200)
 
         thread = Thread.objects.get(pk=self.thread.pk)
@@ -453,11 +409,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
         """edit sends thread to queue due to user acl"""
         self.setUpFirstReplyTest()
 
-        response = self.put(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
-        )
+        response = self.put(self.api_link, data={"post": "Lorem ipsum dolor met!"})
         self.assertEqual(response.status_code, 200)
 
         thread = Thread.objects.get(pk=self.thread.pk)
@@ -468,16 +420,12 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.assertTrue(post.is_unapproved)
 
     @patch_category_acl({"can_edit_posts": 1, "require_edits_approval": True})
-    @patch_user_acl({'can_approve_content': True})
+    @patch_user_acl({"can_approve_content": True})
     def test_first_reply_user_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         self.setUpFirstReplyTest()
 
-        response = self.put(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
-        )
+        response = self.put(self.api_link, data={"post": "Lorem ipsum dolor met!"})
         self.assertEqual(response.status_code, 200)
 
         thread = Thread.objects.get(pk=self.thread.pk)
@@ -487,11 +435,13 @@ class EditReplyTests(AuthenticatedUserTestCase):
         post = Post.objects.get(pk=self.post.pk)
         self.assertFalse(post.is_unapproved)
 
-    @patch_category_acl({
-        "can_edit_posts": 1,
-        "require_threads_approval": True,
-        "require_replies_approval": True,
-    })
+    @patch_category_acl(
+        {
+            "can_edit_posts": 1,
+            "require_threads_approval": True,
+            "require_replies_approval": True,
+        }
+    )
     def test_first_reply_omit_other_moderation_queues(self):
         """other queues are omitted"""
         self.setUpFirstReplyTest()
@@ -500,11 +450,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.category.require_replies_approval = True
         self.category.save()
 
-        response = self.put(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
-        )
+        response = self.put(self.api_link, data={"post": "Lorem ipsum dolor met!"})
         self.assertEqual(response.status_code, 200)
 
         thread = Thread.objects.get(pk=self.thread.pk)

+ 280 - 260
misago/threads/tests/test_thread_merge_api.py

@@ -12,21 +12,14 @@ from .test_threads_api import ThreadsApiTestCase
 class ThreadMergeApiTests(ThreadsApiTestCase):
     def setUp(self):
         super().setUp()
-        
-        Category(
-            name='Other Category',
-            slug='other-category',
-        ).insert_at(
-            self.category,
-            position='last-child',
-            save=True,
+
+        Category(name="Other Category", slug="other-category").insert_at(
+            self.category, position="last-child", save=True
         )
-        self.other_category = Category.objects.get(slug='other-category')
+        self.other_category = Category.objects.get(slug="other-category")
 
         self.api_link = reverse(
-            'misago:api:thread-merge', kwargs={
-                'pk': self.thread.pk,
-            }
+            "misago:api:thread-merge", kwargs={"pk": self.thread.pk}
         )
 
     @patch_category_acl({"can_merge_threads": False})
@@ -34,42 +27,38 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         """api validates if thread can be merged with other one"""
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't merge threads in this category."
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't merge threads in this category."}
+        )
 
     @patch_category_acl({"can_merge_threads": True})
     def test_merge_no_url(self):
         """api validates if thread url was given"""
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Enter link to new thread."
-        })
+        self.assertEqual(response.json(), {"detail": "Enter link to new thread."})
 
     @patch_category_acl({"can_merge_threads": True})
     def test_invalid_url(self):
         """api validates thread url"""
-        response = self.client.post(self.api_link, {
-            'other_thread': self.user.get_absolute_url(),
-        })
+        response = self.client.post(
+            self.api_link, {"other_thread": self.user.get_absolute_url()}
+        )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "This is not a valid thread link."
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This is not a valid thread link."}
+        )
 
     @patch_category_acl({"can_merge_threads": True})
     def test_current_other_thread(self):
         """api validates if thread url given is to current thread"""
         response = self.client.post(
-            self.api_link, {
-                'other_thread': self.thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": self.thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You can't merge thread with itself."
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't merge thread with itself."}
+        )
 
     @patch_other_category_acl()
     @patch_category_acl({"can_merge_threads": True})
@@ -79,16 +68,17 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_other_thread = other_thread.get_absolute_url()
         other_thread.delete()
 
-        response = self.client.post(self.api_link, {
-            'other_thread': other_other_thread,
-        })
+        response = self.client.post(self.api_link, {"other_thread": other_other_thread})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": (
-                "The thread you have entered link to doesn't exist "
-                "or you don't have permission to see it."
-            )
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": (
+                    "The thread you have entered link to doesn't exist "
+                    "or you don't have permission to see it."
+                )
+            },
+        )
 
     @patch_other_category_acl({"can_see": False})
     @patch_category_acl({"can_merge_threads": True})
@@ -97,17 +87,18 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": (
-                "The thread you have entered link to doesn't exist "
-                "or you don't have permission to see it."
-            )
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": (
+                    "The thread you have entered link to doesn't exist "
+                    "or you don't have permission to see it."
+                )
+            },
+        )
 
     @patch_other_category_acl({"can_merge_threads": False})
     @patch_category_acl({"can_merge_threads": True})
@@ -116,14 +107,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Other thread can't be merged with."
-        })
+        self.assertEqual(
+            response.json(), {"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})
@@ -135,14 +124,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.category.save()
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't merge it's threads."
-        })
+        self.assertEqual(
+            response.json(),
+            {"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})
@@ -154,14 +142,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.thread.save()
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This thread is closed. You can't merge it with other threads."
-        })
+        self.assertEqual(
+            response.json(),
+            {"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})
@@ -173,14 +160,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.other_category.save()
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Other thread's category is closed. You can't merge with it."
-        })
+        self.assertEqual(
+            response.json(),
+            {"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})
@@ -192,14 +178,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_thread.save()
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Other thread is closed and can't be merged with."
-        })
+        self.assertEqual(
+            response.json(),
+            {"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})
@@ -208,14 +193,13 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You can't merge this thread into thread you can't reply."
-        })
+        self.assertEqual(
+            response.json(),
+            {"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})
@@ -224,16 +208,17 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # other thread has two posts and an event now
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -252,23 +237,24 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         poststracker.save_read(self.user, other_thread.first_post)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # posts reads are kept
-        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(
-            list(postreads.values_list('post_id', flat=True)),
-            [self.thread.first_post_id, other_thread.first_post_id]
+            list(postreads.values_list("post_id", flat=True)),
+            [self.thread.first_post_id, other_thread.first_post_id],
         )
         self.assertEqual(postreads.filter(thread=other_thread).count(), 2)
         self.assertEqual(postreads.filter(category=self.other_category).count(), 2)
@@ -291,16 +277,17 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.user.subscription_set.get(category=self.category)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # subscriptions are kept
         self.assertEqual(self.user.subscription_set.count(), 1)
@@ -325,16 +312,17 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.user.subscription_set.get(category=self.other_category)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # subscriptions are kept
         self.assertEqual(self.user.subscription_set.count(), 1)
@@ -368,16 +356,17 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.user.subscription_set.get(category=self.other_category)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # subscriptions are kept
         self.assertEqual(self.user.subscription_set.count(), 1)
@@ -394,16 +383,17 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_thread.save()
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # other thread has three posts and an event now
         self.assertEqual(other_thread.post_set.count(), 4)
@@ -427,16 +417,17 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         self.thread.save()
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # other thread has three posts and an event now
         self.assertEqual(other_thread.post_set.count(), 4)
@@ -456,32 +447,36 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-        
+
         other_thread = testutils.post_thread(self.other_category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'best_answers': [
-                ['0', "Unmark all best answers"],
-                [str(self.thread.id), self.thread.title],
-                [str(other_thread.id), other_thread.title],
-            ]
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "best_answers": [
+                    ["0", "Unmark all best answers"],
+                    [str(self.thread.id), self.thread.title],
+                    [str(other_thread.id), other_thread.title],
+                ]
+            },
+        )
 
         # best answers were untouched
         self.assertEqual(self.thread.post_set.count(), 2)
         self.assertEqual(other_thread.post_set.count(), 2)
-        self.assertEqual(Thread.objects.get(pk=self.thread.pk).best_answer_id, best_answer.id)
         self.assertEqual(
-            Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
+            Thread.objects.get(pk=self.thread.pk).best_answer_id, best_answer.id
+        )
+        self.assertEqual(
+            Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id
+        )
 
     @patch_other_category_acl({"can_merge_threads": True})
     @patch_category_acl({"can_merge_threads": True})
@@ -490,27 +485,31 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-        
+
         other_thread = testutils.post_thread(self.other_category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-                'best_answer': other_thread.id + 10,
-            }
+            self.api_link,
+            {
+                "other_thread": other_thread.get_absolute_url(),
+                "best_answer": other_thread.id + 10,
+            },
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {'detail': "Invalid choice."})
+        self.assertEqual(response.json(), {"detail": "Invalid choice."})
 
         # best answers were untouched
         self.assertEqual(self.thread.post_set.count(), 2)
         self.assertEqual(other_thread.post_set.count(), 2)
-        self.assertEqual(Thread.objects.get(pk=self.thread.pk).best_answer_id, best_answer.id)
         self.assertEqual(
-            Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
+            Thread.objects.get(pk=self.thread.pk).best_answer_id, best_answer.id
+        )
+        self.assertEqual(
+            Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id
+        )
 
     @patch_other_category_acl({"can_merge_threads": True})
     @patch_category_acl({"can_merge_threads": True})
@@ -519,24 +518,25 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-        
+
         other_thread = testutils.post_thread(self.other_category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-                'best_answer': 0,
-            }
+            self.api_link,
+            {"other_thread": other_thread.get_absolute_url(), "best_answer": 0},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # other thread has four posts and an event now
         self.assertEqual(other_thread.post_set.count(), 5)
@@ -555,24 +555,28 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-        
+
         other_thread = testutils.post_thread(self.other_category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-                'best_answer': self.thread.pk,
-            }
+            self.api_link,
+            {
+                "other_thread": other_thread.get_absolute_url(),
+                "best_answer": self.thread.pk,
+            },
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # other thread has four posts and an event now
         self.assertEqual(other_thread.post_set.count(), 5)
@@ -582,7 +586,9 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             Thread.objects.get(pk=self.thread.pk)
 
         # 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})
@@ -591,24 +597,28 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-        
+
         other_thread = testutils.post_thread(self.other_category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-                'best_answer': other_thread.pk,
-            }
+            self.api_link,
+            {
+                "other_thread": other_thread.get_absolute_url(),
+                "best_answer": other_thread.pk,
+            },
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # other thread has four posts and an event now
         self.assertEqual(other_thread.post_set.count(), 5)
@@ -619,7 +629,8 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
 
         # other thread's best answer was changed to merged in thread's answer
         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})
@@ -629,16 +640,17 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         poll = testutils.post_poll(other_thread, self.user)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # other thread has two posts and an event now
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -648,8 +660,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             Thread.objects.get(pk=self.thread.pk)
 
         # poll and its votes were kept
-        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(
+            Poll.objects.filter(pk=poll.pk, thread=other_thread).count(), 1
+        )
+        self.assertEqual(
+            PollVote.objects.filter(poll=poll, thread=other_thread).count(), 4
+        )
 
     @patch_other_category_acl({"can_merge_threads": True})
     @patch_category_acl({"can_merge_threads": True})
@@ -659,16 +675,17 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         poll = testutils.post_poll(self.thread, self.user)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # other thread has two posts and an event now
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -678,8 +695,12 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             Thread.objects.get(pk=self.thread.pk)
 
         # poll and its votes were moved
-        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(
+            Poll.objects.filter(pk=poll.pk, thread=other_thread).count(), 1
+        )
+        self.assertEqual(
+            PollVote.objects.filter(poll=poll, thread=other_thread).count(), 4
+        )
 
     @patch_other_category_acl({"can_merge_threads": True})
     @patch_category_acl({"can_merge_threads": True})
@@ -690,25 +711,21 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_poll = testutils.post_poll(other_thread, self.user)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'polls': [
-                    ['0', "Delete all polls"],
-                    [
-                        str(poll.pk),
-                        '%s (%s)' % (poll.question, poll.thread.title),
-                    ],
+            response.json(),
+            {
+                "polls": [
+                    ["0", "Delete all polls"],
+                    [str(poll.pk), "%s (%s)" % (poll.question, poll.thread.title)],
                     [
                         str(other_poll.pk),
-                        '%s (%s)' % (other_poll.question, other_poll.thread.title),
+                        "%s (%s)" % (other_poll.question, other_poll.thread.title),
                     ],
                 ]
-            }
+            },
         )
 
         # polls and votes were untouched
@@ -725,13 +742,14 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         testutils.post_poll(other_thread, self.user)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-                'poll': Poll.objects.all()[0].pk + 10,
-            }
+            self.api_link,
+            {
+                "other_thread": other_thread.get_absolute_url(),
+                "poll": Poll.objects.all()[0].pk + 10,
+            },
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {'detail': "Invalid choice."})
+        self.assertEqual(response.json(), {"detail": "Invalid choice."})
 
         # polls and votes were untouched
         self.assertEqual(Poll.objects.count(), 2)
@@ -746,17 +764,17 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         testutils.post_poll(other_thread, self.user)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-                'poll': 0,
-            }
+            self.api_link, {"other_thread": other_thread.get_absolute_url(), "poll": 0}
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # other thread has two posts and an event now
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -778,17 +796,18 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_poll = testutils.post_poll(other_thread, self.user)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-                'poll': poll.pk,
-            }
+            self.api_link,
+            {"other_thread": other_thread.get_absolute_url(), "poll": poll.pk},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # other thread has two posts and an event now
         self.assertEqual(other_thread.post_set.count(), 3)
@@ -817,17 +836,18 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_poll = testutils.post_poll(other_thread, self.user)
 
         response = self.client.post(
-            self.api_link, {
-                'other_thread': other_thread.get_absolute_url(),
-                'poll': other_poll.pk,
-            }
+            self.api_link,
+            {"other_thread": other_thread.get_absolute_url(), "poll": other_poll.pk},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': other_thread.id,
-            'title': other_thread.title,
-            'url': other_thread.get_absolute_url(),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": other_thread.id,
+                "title": other_thread.title,
+                "url": other_thread.get_absolute_url(),
+            },
+        )
 
         # other thread has two posts and an event now
         self.assertEqual(other_thread.post_set.count(), 3)

+ 18 - 21
misago/threads/tests/test_thread_model.py

@@ -20,11 +20,11 @@ class ThreadModelTests(TestCase):
         self.thread = Thread(
             category=self.category,
             started_on=datetime,
-            starter_name='Tester',
-            starter_slug='tester',
+            starter_name="Tester",
+            starter_slug="tester",
             last_post_on=datetime,
-            last_poster_name='Tester',
-            last_poster_slug='tester',
+            last_poster_name="Tester",
+            last_poster_slug="tester",
         )
 
         self.thread.set_title("Test thread")
@@ -33,7 +33,7 @@ class ThreadModelTests(TestCase):
         Post.objects.create(
             category=self.category,
             thread=self.thread,
-            poster_name='Tester',
+            poster_name="Tester",
             original="Hello! I am test message!",
             parsed="<p>Hello! I am test message!</p>",
             checksum="nope",
@@ -216,8 +216,8 @@ class ThreadModelTests(TestCase):
         Poll.objects.create(
             thread=self.thread,
             category=self.category,
-            poster_name='test',
-            poster_slug='test',
+            poster_name="test",
+            poster_slug="test",
             choices=[],
         )
 
@@ -338,15 +338,10 @@ class ThreadModelTests(TestCase):
     def test_move(self):
         """move(new_category) moves thread to other category"""
         root_category = Category.objects.root_category()
-        Category(
-            name='New Category',
-            slug='new-category',
-        ).insert_at(
-            root_category,
-            position='last-child',
-            save=True,
+        Category(name="New Category", slug="new-category").insert_at(
+            root_category, position="last-child", save=True
         )
-        new_category = Category.objects.get(slug='new-category')
+        new_category = Category.objects.get(slug="new-category")
 
         self.thread.move(new_category)
         self.assertEqual(self.thread.category, new_category)
@@ -364,11 +359,11 @@ class ThreadModelTests(TestCase):
         other_thread = Thread(
             category=self.category,
             started_on=datetime,
-            starter_name='Tester',
-            starter_slug='tester',
+            starter_name="Tester",
+            starter_slug="tester",
             last_post_on=datetime,
-            last_poster_name='Tester',
-            last_poster_slug='tester',
+            last_poster_name="Tester",
+            last_poster_slug="tester",
         )
 
         other_thread.set_title("Other thread")
@@ -377,7 +372,7 @@ class ThreadModelTests(TestCase):
         post = Post.objects.create(
             category=self.category,
             thread=other_thread,
-            poster_name='Admin',
+            poster_name="Admin",
             original="Hello! I am other message!",
             parsed="<p>Hello! I am other message!</p>",
             checksum="nope",
@@ -404,7 +399,9 @@ class ThreadModelTests(TestCase):
         when there are no participants left in it
         """
         user_a = UserModel.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
-        user_b = UserModel.objects.create_user("Weebl", "weebl@weeblson.com", "Pass.123")
+        user_b = UserModel.objects.create_user(
+            "Weebl", "weebl@weeblson.com", "Pass.123"
+        )
 
         ThreadParticipant.objects.add_participants(self.thread, [user_a, user_b])
         self.assertEqual(self.thread.participants.count(), 2)

+ 806 - 1213
misago/threads/tests/test_thread_patch_api.py

@@ -14,122 +14,99 @@ from .test_threads_api import ThreadsApiTestCase
 
 class ThreadPatchApiTestCase(ThreadsApiTestCase):
     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"
+        )
 
 
 class ThreadAddAclApiTests(ThreadPatchApiTestCase):
     def test_add_acl_true(self):
         """api adds current thread's acl to response"""
-        response = self.patch(self.api_link, [
-            {
-                'op': 'add',
-                'path': 'acl',
-                'value': True,
-            },
-        ])
+        response = self.patch(
+            self.api_link, [{"op": "add", "path": "acl", "value": True}]
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertTrue(response_json['acl'])
+        self.assertTrue(response_json["acl"])
 
     def test_add_acl_false(self):
         """if value is false, api won't add acl to the response, but will set empty key"""
-        response = self.patch(self.api_link, [
-            {
-                'op': 'add',
-                'path': 'acl',
-                'value': False,
-            },
-        ])
+        response = self.patch(
+            self.api_link, [{"op": "add", "path": "acl", "value": False}]
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertIsNone(response_json['acl'])
+        self.assertIsNone(response_json["acl"])
 
 
 class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
-    @patch_category_acl({'can_edit_threads': 2})
+    @patch_category_acl({"can_edit_threads": 2})
     def test_change_thread_title(self):
         """api makes it possible to change thread title"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'title',
-                    'value': "Lorem ipsum change!",
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "title", "value": "Lorem ipsum change!"}],
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['title'], "Lorem ipsum change!")
+        self.assertEqual(response_json["title"], "Lorem ipsum change!")
 
         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})
+    @patch_category_acl({"can_edit_threads": 0})
     def test_change_thread_title_no_permission(self):
         """api validates permission to change title"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'title',
-                    'value': "Lorem ipsum change!",
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "title", "value": "Lorem ipsum change!"}],
         )
         self.assertEqual(response.status_code, 400)
 
         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})
+    @patch_category_acl({"can_edit_threads": 2, "can_close_threads": 0})
     def test_change_thread_title_closed_category_no_permission(self):
         """api test permission to edit thread title in closed category"""
         self.category.is_closed = True
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'title',
-                    'value': "Lorem ipsum change!",
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "title", "value": "Lorem ipsum change!"}],
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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})
+    @patch_category_acl({"can_edit_threads": 2, "can_close_threads": 0})
     def test_change_thread_title_closed_thread_no_permission(self):
         """api test permission to edit closed thread title"""
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'title',
-                    'value': "Lorem ipsum change!",
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "title", "value": "Lorem ipsum change!"}],
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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})
+    @patch_category_acl({"can_edit_threads": 1, "thread_edit_time": 1})
     def test_change_thread_title_after_edit_time(self):
         """api cleans, validates and rejects too short title"""
         self.thread.started_on = timezone.now() - timedelta(minutes=10)
@@ -137,360 +114,273 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'title',
-                    'value': "Lorem ipsum change!",
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "title", "value": "Lorem ipsum change!"}],
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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})
+    @patch_category_acl({"can_edit_threads": 2})
     def test_change_thread_title_invalid(self):
         """api cleans, validates and rejects too short title"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'title',
-                    'value': 12,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "title", "value": 12}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0],
-            "Thread title should be at least 5 characters long (it has 2)."
+            response_json["detail"][0],
+            "Thread title should be at least 5 characters long (it has 2).",
         )
 
 
 class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
-    @patch_category_acl({'can_pin_threads': 2})
+    @patch_category_acl({"can_pin_threads": 2})
     def test_pin_thread(self):
         """api makes it possible to pin globally thread"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'weight',
-                    'value': 2,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "weight", "value": 2}]
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['weight'], 2)
+        self.assertEqual(response_json["weight"], 2)
 
         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})
+    @patch_category_acl({"can_pin_threads": 2, "can_close_threads": 0})
     def test_pin_thread_closed_category_no_permission(self):
         """api checks if category is closed"""
         self.category.is_closed = True
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'weight',
-                    'value': 2,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "weight", "value": 2}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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})
+    @patch_category_acl({"can_pin_threads": 2, "can_close_threads": 0})
     def test_pin_thread_closed_no_permission(self):
         """api checks if thread is closed"""
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'weight',
-                    'value': 2,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "weight", "value": 2}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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})
+    @patch_category_acl({"can_pin_threads": 2})
     def test_unpin_thread(self):
         """api makes it possible to unpin thread"""
         self.thread.weight = 2
         self.thread.save()
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['weight'], 2)
+        self.assertEqual(thread_json["weight"], 2)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'weight',
-                    'value': 0,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "weight", "value": 0}]
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['weight'], 0)
+        self.assertEqual(response_json["weight"], 0)
 
         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})
+    @patch_category_acl({"can_pin_threads": 1})
     def test_pin_thread_no_permission(self):
         """api pin thread globally with no permission fails"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'weight',
-                    'value': 2,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "weight", "value": 2}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0], "You can't pin threads globally in this category."
+            response_json["detail"][0],
+            "You can't pin threads globally in this category.",
         )
 
         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})
+    @patch_category_acl({"can_pin_threads": 1})
     def test_unpin_thread_no_permission(self):
         """api unpin thread with no permission fails"""
         self.thread.weight = 2
         self.thread.save()
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['weight'], 2)
+        self.assertEqual(thread_json["weight"], 2)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'weight',
-                    'value': 1,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "weight", "value": 1}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0], "You can't change globally pinned threads weights in this category."
+            response_json["detail"][0],
+            "You can't change globally pinned threads weights in this category.",
         )
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['weight'], 2)
+        self.assertEqual(thread_json["weight"], 2)
 
 
 class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
-    @patch_category_acl({'can_pin_threads': 1})
+    @patch_category_acl({"can_pin_threads": 1})
     def test_pin_thread(self):
         """api makes it possible to pin locally thread"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'weight',
-                    'value': 1,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "weight", "value": 1}]
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['weight'], 1)
+        self.assertEqual(response_json["weight"], 1)
 
         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})
+    @patch_category_acl({"can_pin_threads": 1})
     def test_unpin_thread(self):
         """api makes it possible to unpin thread"""
         self.thread.weight = 1
         self.thread.save()
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['weight'], 1)
+        self.assertEqual(thread_json["weight"], 1)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'weight',
-                    'value': 0,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "weight", "value": 0}]
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['weight'], 0)
+        self.assertEqual(response_json["weight"], 0)
 
         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})
+    @patch_category_acl({"can_pin_threads": 0})
     def test_pin_thread_no_permission(self):
         """api pin thread locally with no permission fails"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'weight',
-                    'value': 1,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "weight", "value": 1}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0], "You can't change threads weights in this category."
+            response_json["detail"][0],
+            "You can't change threads weights in this category.",
         )
 
         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})
+    @patch_category_acl({"can_pin_threads": 0})
     def test_unpin_thread_no_permission(self):
         """api unpin thread with no permission fails"""
         self.thread.weight = 1
         self.thread.save()
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['weight'], 1)
+        self.assertEqual(thread_json["weight"], 1)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'weight',
-                    'value': 0,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "weight", "value": 0}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0], "You can't change threads weights in this category."
+            response_json["detail"][0],
+            "You can't change threads weights in this category.",
         )
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['weight'], 1)
+        self.assertEqual(thread_json["weight"], 1)
 
 
 class ThreadMoveApiTests(ThreadPatchApiTestCase):
     def setUp(self):
         super().setUp()
 
-        Category(
-            name='Other category',
-            slug='other-category',
-        ).insert_at(
-            self.category,
-            position='last-child',
-            save=True,
+        Category(name="Other category", slug="other-category").insert_at(
+            self.category, position="last-child", save=True
         )
-        self.dst_category = Category.objects.get(slug='other-category')
+        self.dst_category = Category.objects.get(slug="other-category")
 
-    @patch_other_category_acl({'can_start_threads': 2})
-    @patch_category_acl({'can_move_threads': True})
+    @patch_other_category_acl({"can_start_threads": 2})
+    @patch_category_acl({"can_move_threads": True})
     def test_move_thread_no_top(self):
         """api moves thread to other category, sets no top category"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'category',
-                    'value': self.dst_category.pk,
-                },
-                {
-                    'op': 'add',
-                    'path': 'top-category',
-                    'value': self.dst_category.pk,
-                },
-                {
-                    'op': 'replace',
-                    'path': 'flatten-categories',
-                    'value': None,
-                },
-            ]
+            self.api_link,
+            [
+                {"op": "replace", "path": "category", "value": self.dst_category.pk},
+                {"op": "add", "path": "top-category", "value": self.dst_category.pk},
+                {"op": "replace", "path": "flatten-categories", "value": None},
+            ],
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertEqual(reponse_json['category'], self.dst_category.pk)
+        self.assertEqual(reponse_json["category"], self.dst_category.pk)
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['category']['id'], self.dst_category.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})
+    @patch_other_category_acl({"can_start_threads": 2})
+    @patch_category_acl({"can_move_threads": True})
     def test_move_thread_with_top(self):
         """api moves thread to other category, sets top"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'category',
-                    'value': self.dst_category.pk,
-                },
-                {
-                    'op': 'add',
-                    'path': 'top-category',
-                    'value': Category.objects.root_category().pk,
-                },
+            self.api_link,
+            [
+                {"op": "replace", "path": "category", "value": self.dst_category.pk},
                 {
-                    'op': 'replace',
-                    'path': 'flatten-categories',
-                    'value': None,
+                    "op": "add",
+                    "path": "top-category",
+                    "value": Category.objects.root_category().pk,
                 },
-            ]
+                {"op": "replace", "path": "flatten-categories", "value": None},
+            ],
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertEqual(reponse_json['category'], self.dst_category.pk)
+        self.assertEqual(reponse_json["category"], self.dst_category.pk)
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['category']['id'], self.dst_category.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})
+    @patch_other_category_acl({"can_start_threads": 2})
+    @patch_category_acl({"can_move_threads": True})
     def test_move_thread_reads(self):
         """api moves thread reads together with thread"""
         poststracker.save_read(self.user, self.thread.first_post)
@@ -499,34 +389,23 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         self.user.postread_set.get(category=self.category)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'category',
-                    'value': self.dst_category.pk,
-                },
-                {
-                    'op': 'add',
-                    'path': 'top-category',
-                    'value': self.dst_category.pk,
-                },
-                {
-                    'op': 'replace',
-                    'path': 'flatten-categories',
-                    'value': None,
-                },
-            ]
+            self.api_link,
+            [
+                {"op": "replace", "path": "category", "value": self.dst_category.pk},
+                {"op": "add", "path": "top-category", "value": self.dst_category.pk},
+                {"op": "replace", "path": "flatten-categories", "value": None},
+            ],
         )
         self.assertEqual(response.status_code, 200)
 
         # thread read was moved to new category
-        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)
         postreads.get(category=self.dst_category)
 
-    @patch_other_category_acl({'can_start_threads': 2})
-    @patch_category_acl({'can_move_threads': True})
+    @patch_other_category_acl({"can_start_threads": 2})
+    @patch_category_acl({"can_move_threads": True})
     def test_move_thread_subscriptions(self):
         """api moves thread subscriptions together with thread"""
         self.user.subscription_set.create(
@@ -540,23 +419,12 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         self.user.subscription_set.get(category=self.category)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'category',
-                    'value': self.dst_category.pk,
-                },
-                {
-                    'op': 'add',
-                    'path': 'top-category',
-                    'value': self.dst_category.pk,
-                },
-                {
-                    'op': 'replace',
-                    'path': 'flatten-categories',
-                    'value': None,
-                },
-            ]
+            self.api_link,
+            [
+                {"op": "replace", "path": "category", "value": self.dst_category.pk},
+                {"op": "add", "path": "top-category", "value": self.dst_category.pk},
+                {"op": "replace", "path": "flatten-categories", "value": None},
+            ],
         )
         self.assertEqual(response.status_code, 200)
 
@@ -564,283 +432,222 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         self.assertEqual(self.user.subscription_set.count(), 1)
         self.user.subscription_set.get(category=self.dst_category)
 
-    @patch_category_acl({'can_move_threads': False})
+    @patch_category_acl({"can_move_threads": False})
     def test_move_thread_no_permission(self):
         """api move thread to other category with no permission fails"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'category',
-                    'value': self.dst_category.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "category", "value": self.dst_category.pk}],
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0], "You can't move threads in this category."
+            response_json["detail"][0], "You can't move threads in this category."
         )
 
         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})
+    @patch_other_category_acl({"can_close_threads": False})
+    @patch_category_acl({"can_move_threads": True})
     def test_move_thread_closed_category_no_permission(self):
         """api move thread from closed category with no permission fails"""
         self.category.is_closed = True
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'category',
-                    'value': self.dst_category.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "category", "value": self.dst_category.pk}],
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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})
+    @patch_other_category_acl({"can_close_threads": False})
+    @patch_category_acl({"can_move_threads": True})
     def test_move_closed_thread_no_permission(self):
         """api move closed thread with no permission fails"""
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'category',
-                    'value': self.dst_category.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "category", "value": self.dst_category.pk}],
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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})
+    @patch_other_category_acl({"can_see": False})
+    @patch_category_acl({"can_move_threads": True})
     def test_move_thread_no_category_access(self):
         """api move thread to category with no access fails"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'category',
-                    'value': self.dst_category.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "category", "value": self.dst_category.pk}],
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
-        self.assertEqual(response_json['detail'][0], 'NOT FOUND')
+        self.assertEqual(response_json["detail"][0], "NOT FOUND")
 
         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})
+    @patch_other_category_acl({"can_browse": False})
+    @patch_category_acl({"can_move_threads": True})
     def test_move_thread_no_category_browse(self):
         """api move thread to category with no browsing access fails"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'category',
-                    'value': self.dst_category.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "category", "value": self.dst_category.pk}],
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0],
-            'You don\'t have permission to browse "Other category" contents.'
+            response_json["detail"][0],
+            'You don\'t have permission to browse "Other category" contents.',
         )
 
         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})
+    @patch_other_category_acl({"can_start_threads": False})
+    @patch_category_acl({"can_move_threads": True})
     def test_move_thread_no_category_start_threads(self):
         """api move thread to category with no posting access fails"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'category',
-                    'value': self.dst_category.pk,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "category", "value": self.dst_category.pk}],
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0],
-            "You don't have permission to start new threads in this category."
+            response_json["detail"][0],
+            "You don't have permission to start new threads in this category.",
         )
 
         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})
+    @patch_other_category_acl({"can_start_threads": 2})
+    @patch_category_acl({"can_move_threads": True})
     def test_move_thread_same_category(self):
         """api move thread to category it's already in fails"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'category',
-                    'value': self.thread.category_id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "category", "value": self.thread.category_id}],
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.",
         )
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['category']['id'], self.category.pk)
+        self.assertEqual(thread_json["category"]["id"], self.category.pk)
 
     def test_thread_flatten_categories(self):
         """api flatten thread categories"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'flatten-categories',
-                    'value': None,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "flatten-categories", "value": None}],
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['category'], self.category.pk)
+        self.assertEqual(response_json["category"], self.category.pk)
 
 
 class ThreadCloseApiTests(ThreadPatchApiTestCase):
-    @patch_category_acl({'can_close_threads': True})
+    @patch_category_acl({"can_close_threads": True})
     def test_close_thread(self):
         """api makes it possible to close thread"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-closed',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-closed", "value": True}]
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertTrue(response_json['is_closed'])
+        self.assertTrue(response_json["is_closed"])
 
         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})
+    @patch_category_acl({"can_close_threads": True})
     def test_open_thread(self):
         """api makes it possible to open thread"""
         self.thread.is_closed = True
         self.thread.save()
 
         thread_json = self.get_thread_json()
-        self.assertTrue(thread_json['is_closed'])
+        self.assertTrue(thread_json["is_closed"])
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-closed',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-closed", "value": False}]
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertFalse(response_json['is_closed'])
+        self.assertFalse(response_json["is_closed"])
 
         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})
+    @patch_category_acl({"can_close_threads": False})
     def test_close_thread_no_permission(self):
         """api close thread with no permission fails"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-closed',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-closed", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0], "You don't have permission to close this thread."
+            response_json["detail"][0],
+            "You don't have permission to close this thread.",
         )
 
         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})
+    @patch_category_acl({"can_close_threads": False})
     def test_open_thread_no_permission(self):
         """api open thread with no permission fails"""
         self.thread.is_closed = True
         self.thread.save()
 
         thread_json = self.get_thread_json()
-        self.assertTrue(thread_json['is_closed'])
+        self.assertTrue(thread_json["is_closed"])
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-closed',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-closed", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0], "You don't have permission to open this thread."
+            response_json["detail"][0], "You don't have permission to open this thread."
         )
 
         thread_json = self.get_thread_json()
-        self.assertTrue(thread_json['is_closed'])
+        self.assertTrue(thread_json["is_closed"])
 
 
 class ThreadApproveApiTests(ThreadPatchApiTestCase):
-    @patch_category_acl({'can_approve_content': True})
+    @patch_category_acl({"can_approve_content": True})
     def test_approve_thread(self):
         """api makes it possible to approve thread"""
         self.thread.first_post.is_unapproved = True
@@ -853,29 +660,23 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         self.assertTrue(self.thread.has_unapproved_posts)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-unapproved',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-unapproved", "value": False}]
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertFalse(response_json['is_unapproved'])
-        self.assertFalse(response_json['has_unapproved_posts'])
+        self.assertFalse(response_json["is_unapproved"])
+        self.assertFalse(response_json["has_unapproved_posts"])
 
         thread_json = self.get_thread_json()
-        self.assertFalse(thread_json['is_unapproved'])
-        self.assertFalse(thread_json['has_unapproved_posts'])
+        self.assertFalse(thread_json["is_unapproved"])
+        self.assertFalse(thread_json["has_unapproved_posts"])
 
         thread = Thread.objects.get(pk=self.thread.pk)
         self.assertFalse(thread.is_unapproved)
         self.assertFalse(thread.has_unapproved_posts)
 
-    @patch_category_acl({'can_approve_content': True, 'can_close_threads': False})
+    @patch_category_acl({"can_approve_content": True, "can_close_threads": False})
     def test_approve_thread_category_closed_no_permission(self):
         """api checks permission for approving threads in closed categories"""
         self.thread.first_post.is_unapproved = True
@@ -891,20 +692,17 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-unapproved',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-unapproved", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         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})
+    @patch_category_acl({"can_approve_content": True, "can_close_threads": False})
     def test_approve_thread_closed_no_permission(self):
         """api checks permission for approving posts in closed categories"""
         self.thread.first_post.is_unapproved = True
@@ -920,104 +718,77 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-unapproved',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-unapproved", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         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})
+    @patch_category_acl({"can_approve_content": True})
     def test_unapprove_thread(self):
         """api returns permission error on approval removal"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-unapproved',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-unapproved", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         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."
+        )
 
 
 class ThreadHideApiTests(ThreadPatchApiTestCase):
-    @patch_category_acl({'can_hide_threads': True})
+    @patch_category_acl({"can_hide_threads": True})
     def test_hide_thread(self):
         """api makes it possible to hide thread"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertTrue(reponse_json['is_hidden'])
+        self.assertTrue(reponse_json["is_hidden"])
 
         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})
+    @patch_category_acl({"can_hide_threads": False})
     def test_hide_thread_no_permission(self):
         """api hide thread with no permission fails"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0], "You can't hide threads in this category."
+            response_json["detail"][0], "You can't hide threads in this category."
         )
 
         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})
+    @patch_category_acl({"can_hide_threads": False, "can_hide_own_threads": True})
     def test_hide_non_owned_thread(self):
         """api forbids non-moderator from hiding other users threads"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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,
-    })
+    @patch_category_acl(
+        {"can_hide_threads": False, "can_hide_own_threads": True, "thread_edit_time": 1}
+    )
     def test_hide_owned_thread_no_time(self):
         """api forbids non-moderator from hiding other users threads"""
         self.thread.started_on = timezone.now() - timedelta(minutes=5)
@@ -1025,63 +796,47 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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})
+    @patch_category_acl({"can_hide_threads": True, "can_close_threads": False})
     def test_hide_closed_category_no_permission(self):
         """api test permission to hide thread in closed category"""
         self.category.is_closed = True
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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})
+    @patch_category_acl({"can_hide_threads": True, "can_close_threads": False})
     def test_hide_closed_thread_no_permission(self):
         """api test permission to hide closed thread"""
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0], "This thread is closed. You can't hide it."
+            response_json["detail"][0], "This thread is closed. You can't hide it."
         )
 
 
@@ -1092,82 +847,59 @@ class ThreadUnhideApiTests(ThreadPatchApiTestCase):
         self.thread.is_hidden = True
         self.thread.save()
 
-    @patch_category_acl({'can_hide_threads': True})
+    @patch_category_acl({"can_hide_threads": True})
     def test_unhide_thread(self):
         """api makes it possible to unhide thread"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertFalse(reponse_json['is_hidden'])
+        self.assertFalse(reponse_json["is_hidden"])
 
         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})
+    @patch_category_acl({"can_hide_threads": False})
     def test_unhide_thread_no_permission(self):
         """api unhide thread with no permission fails as thread is invisible"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 404)
 
-    @patch_category_acl({'can_hide_threads': True, 'can_close_threads': False})
+    @patch_category_acl({"can_hide_threads": True, "can_close_threads": False})
     def test_unhide_closed_category_no_permission(self):
         """api test permission to unhide thread in closed category"""
         self.category.is_closed = True
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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})
+    @patch_category_acl({"can_hide_threads": True, "can_close_threads": False})
     def test_unhide_closed_thread_no_permission(self):
         """api test permission to unhide closed thread"""
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0], "This thread is closed. You can't reveal it."
+            response_json["detail"][0], "This thread is closed. You can't reveal it."
         )
 
 
@@ -1175,22 +907,17 @@ class ThreadSubscribeApiTests(ThreadPatchApiTestCase):
     def test_subscribe_thread(self):
         """api makes it possible to subscribe thread"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'subscription',
-                    'value': 'notify',
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "subscription", "value": "notify"}],
         )
 
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertFalse(reponse_json['subscription'])
+        self.assertFalse(reponse_json["subscription"])
 
         thread_json = self.get_thread_json()
-        self.assertFalse(thread_json['subscription'])
+        self.assertFalse(thread_json["subscription"])
 
         subscription = self.user.subscription_set.get(thread=self.thread)
         self.assertFalse(subscription.send_email)
@@ -1198,22 +925,16 @@ class ThreadSubscribeApiTests(ThreadPatchApiTestCase):
     def test_subscribe_thread_with_email(self):
         """api makes it possible to subscribe thread with emails"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'subscription',
-                    'value': 'email',
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "subscription", "value": "email"}]
         )
 
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertTrue(reponse_json['subscription'])
+        self.assertTrue(reponse_json["subscription"])
 
         thread_json = self.get_thread_json()
-        self.assertTrue(thread_json['subscription'])
+        self.assertTrue(thread_json["subscription"])
 
         subscription = self.user.subscription_set.get(thread=self.thread)
         self.assertTrue(subscription.send_email)
@@ -1221,22 +942,17 @@ class ThreadSubscribeApiTests(ThreadPatchApiTestCase):
     def test_unsubscribe_thread(self):
         """api makes it possible to unsubscribe thread"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'subscription',
-                    'value': 'remove',
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "subscription", "value": "remove"}],
         )
 
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertIsNone(reponse_json['subscription'])
+        self.assertIsNone(reponse_json["subscription"])
 
         thread_json = self.get_thread_json()
-        self.assertIsNone(thread_json['subscription'])
+        self.assertIsNone(thread_json["subscription"])
 
         self.assertEqual(self.user.subscription_set.count(), 0)
 
@@ -1245,13 +961,7 @@ class ThreadSubscribeApiTests(ThreadPatchApiTestCase):
         self.logout_user()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'subscription',
-                    'value': 'email',
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "subscription", "value": "email"}]
         )
 
         self.assertEqual(response.status_code, 403)
@@ -1263,56 +973,49 @@ class ThreadSubscribeApiTests(ThreadPatchApiTestCase):
         )
 
         response = self.patch(
-            bad_api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'subscription',
-                    'value': 'email',
-                },
-            ]
+            bad_api_link, [{"op": "replace", "path": "subscription", "value": "email"}]
         )
 
         self.assertEqual(response.status_code, 404)
 
 
 class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
-    @patch_category_acl({'can_mark_best_answers': 2})
+    @patch_category_acl({"can_mark_best_answers": 2})
     def test_mark_best_answer(self):
         """api makes it possible to mark best answer"""
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ['ok'],
-
-            'best_answer': best_answer.id,
-            'best_answer_is_protected': False,
-            'best_answer_marked_on': response.json()['best_answer_marked_on'],
-            'best_answer_marked_by': self.user.id,
-            'best_answer_marked_by_name': self.user.username,
-            'best_answer_marked_by_slug': self.user.slug,
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": ["ok"],
+                "best_answer": best_answer.id,
+                "best_answer_is_protected": False,
+                "best_answer_marked_on": response.json()["best_answer_marked_on"],
+                "best_answer_marked_by": self.user.id,
+                "best_answer_marked_by_name": self.user.username,
+                "best_answer_marked_by_slug": self.user.slug,
+            },
+        )
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['best_answer'], best_answer.id)
-        self.assertEqual(thread_json['best_answer_is_protected'], False)
+        self.assertEqual(thread_json["best_answer"], best_answer.id)
+        self.assertEqual(thread_json["best_answer_is_protected"], False)
         self.assertEqual(
-            thread_json['best_answer_marked_on'], response.json()['best_answer_marked_on'])
-        self.assertEqual(thread_json['best_answer_marked_by'], self.user.id)
-        self.assertEqual(thread_json['best_answer_marked_by_name'], self.user.username)
-        self.assertEqual(thread_json['best_answer_marked_by_slug'], self.user.slug)
+            thread_json["best_answer_marked_on"],
+            response.json()["best_answer_marked_on"],
+        )
+        self.assertEqual(thread_json["best_answer_marked_by"], self.user.id)
+        self.assertEqual(thread_json["best_answer_marked_by_name"], self.user.username)
+        self.assertEqual(thread_json["best_answer_marked_by_slug"], self.user.slug)
 
-    @patch_category_acl({'can_mark_best_answers': 2})
+    @patch_category_acl({"can_mark_best_answers": 2})
     def test_mark_best_answer_anonymous(self):
         """api validates that user is authenticated before marking best answer"""
         self.logout_user()
@@ -1320,89 +1023,75 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "This action is not available to guests.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 0})
     def test_mark_best_answer_no_permission(self):
         """api validates permission to mark best answers"""
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                'You don\'t have permission to mark best answers in the "First category" category.'
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    'You don\'t have permission to mark best answers in the "First category" category.'
+                ],
+            },
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 1})
     def test_mark_best_answer_not_thread_starter(self):
         """api validates permission to mark best answers in owned thread"""
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                "You don't have permission to mark best answer in this thread because you didn't "
-                "start it."
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You don't have permission to mark best answer in this thread because you didn't "
+                    "start it."
+                ],
+            },
+        )
 
         thread_json = self.get_thread_json()
-        self.assertIsNone(thread_json['best_answer'])
+        self.assertIsNone(thread_json["best_answer"])
 
         # passing scenario is possible
         self.thread.starter = self.user
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({'can_mark_best_answers': 2, 'can_close_threads': False})
+    @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"""
         best_answer = testutils.reply_thread(self.thread)
@@ -1411,27 +1100,25 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                'You don\'t have permission to mark best answer in this thread because its '
-                'category "First category" is closed.'
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You don't have permission to mark best answer in this thread because its "
+                    'category "First category" is closed.'
+                ],
+            },
+        )
 
         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, 'can_close_threads': True})
+    @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)
@@ -1440,17 +1127,12 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({'can_mark_best_answers': 2, 'can_close_threads': False})
+    @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"""
         best_answer = testutils.reply_thread(self.thread)
@@ -1459,27 +1141,25 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                "You can't mark best answer in this thread because it's closed and you don't have "
-                "permission to open it."
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You can't mark best answer in this thread because it's closed and you don't have "
+                    "permission to open it."
+                ],
+            },
+        )
 
         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, 'can_close_threads': True})
+    @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)
@@ -1488,235 +1168,212 @@ class ThreadMarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
-    
-    @patch_category_acl({'can_mark_best_answers': 2})
+
+    @patch_category_acl({"can_mark_best_answers": 2})
     def test_mark_best_answer_invalid_post_id(self):
         """api validates that post id is int"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': 'd7sd89a7d98sa',
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": "d7sd89a7d98sa"}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ["A valid integer is required."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.thread.id, "detail": ["A valid integer is required."]},
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 2})
     def test_mark_best_answer_post_not_found(self):
         """api validates that post exists"""
         response = self.patch(
-            self.api_link, [
+            self.api_link,
+            [
                 {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': self.thread.last_post_id + 1,
-                },
-            ]
+                    "op": "replace",
+                    "path": "best-answer",
+                    "value": self.thread.last_post_id + 1,
+                }
+            ],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ["NOT FOUND"],
-        })
+        self.assertEqual(
+            response.json(), {"id": self.thread.id, "detail": ["NOT FOUND"]}
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 2})
     def test_mark_best_answer_post_invisible(self):
         """api validates post visibility to action author"""
         unapproved_post = testutils.reply_thread(self.thread, is_unapproved=True)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': unapproved_post.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": unapproved_post.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ["NOT FOUND"],
-        })
+        self.assertEqual(
+            response.json(), {"id": self.thread.id, "detail": ["NOT FOUND"]}
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 2})
     def test_mark_best_answer_post_other_thread(self):
         """api validates post belongs to same thread"""
         other_thread = testutils.post_thread(self.category)
 
         response = self.patch(
-            self.api_link, [
+            self.api_link,
+            [
                 {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': other_thread.first_post_id,
-                },
-            ]
+                    "op": "replace",
+                    "path": "best-answer",
+                    "value": other_thread.first_post_id,
+                }
+            ],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ["NOT FOUND"],
-        })
+        self.assertEqual(
+            response.json(), {"id": self.thread.id, "detail": ["NOT FOUND"]}
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 2})
     def test_mark_best_answer_event_id(self):
         """api validates that post is not an event"""
         best_answer = testutils.reply_thread(self.thread)
         best_answer.is_event = True
         best_answer.save()
-        
+
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ["Events can't be marked as best answers."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": ["Events can't be marked as best answers."],
+            },
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 2})
     def test_mark_best_answer_first_post(self):
         """api validates that post is not a first post in thread"""
         response = self.patch(
-            self.api_link, [
+            self.api_link,
+            [
                 {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': self.thread.first_post_id,
-                },
-            ]
+                    "op": "replace",
+                    "path": "best-answer",
+                    "value": self.thread.first_post_id,
+                }
+            ],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ["First post in a thread can't be marked as best answer."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": ["First post in a thread can't be marked as best answer."],
+            },
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 2})
     def test_mark_best_answer_hidden_post(self):
         """api validates that post is not hidden"""
         best_answer = testutils.reply_thread(self.thread, is_hidden=True)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ["Hidden posts can't be marked as best answers."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": ["Hidden posts can't be marked as best answers."],
+            },
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 2})
     def test_mark_best_answer_unapproved_post(self):
         """api validates that post is not unapproved"""
-        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(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ["Unapproved posts can't be marked as best answers."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": ["Unapproved posts can't be marked as best answers."],
+            },
+        )
 
         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, 'can_protect_posts': False})
+    @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"""
         best_answer = testutils.reply_thread(self.thread, is_protected=True)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                "You don't have permission to mark this post as best answer because a moderator "
-                "has protected it."
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You don't have permission to mark this post as best answer because a moderator "
+                    "has protected it."
+                ],
+            },
+        )
 
         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, 'can_protect_posts': True})
+    @patch_category_acl({"can_mark_best_answers": 2, "can_protect_posts": True})
     def test_mark_best_answer_protected_post(self):
         """api respects post protection"""
         best_answer = testutils.reply_thread(self.thread, is_protected=True)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
 
@@ -1729,160 +1386,148 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.set_best_answer(self.user, self.best_answer)
         self.thread.save()
 
-    @patch_category_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 2})
+    @patch_category_acl({"can_mark_best_answers": 2, "can_change_marked_answers": 2})
     def test_change_best_answer(self):
         """api makes it possible to change best answer"""
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ['ok'],
-
-            'best_answer': best_answer.id,
-            'best_answer_is_protected': False,
-            'best_answer_marked_on': response.json()['best_answer_marked_on'],
-            'best_answer_marked_by': self.user.id,
-            'best_answer_marked_by_name': self.user.username,
-            'best_answer_marked_by_slug': self.user.slug,
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": ["ok"],
+                "best_answer": best_answer.id,
+                "best_answer_is_protected": False,
+                "best_answer_marked_on": response.json()["best_answer_marked_on"],
+                "best_answer_marked_by": self.user.id,
+                "best_answer_marked_by_name": self.user.username,
+                "best_answer_marked_by_slug": self.user.slug,
+            },
+        )
 
         thread_json = self.get_thread_json()
-        self.assertEqual(thread_json['best_answer'], best_answer.id)
-        self.assertEqual(thread_json['best_answer_is_protected'], False)
+        self.assertEqual(thread_json["best_answer"], best_answer.id)
+        self.assertEqual(thread_json["best_answer_is_protected"], False)
         self.assertEqual(
-            thread_json['best_answer_marked_on'], response.json()['best_answer_marked_on'])
-        self.assertEqual(thread_json['best_answer_marked_by'], self.user.id)
-        self.assertEqual(thread_json['best_answer_marked_by_name'], self.user.username)
-        self.assertEqual(thread_json['best_answer_marked_by_slug'], self.user.slug)
+            thread_json["best_answer_marked_on"],
+            response.json()["best_answer_marked_on"],
+        )
+        self.assertEqual(thread_json["best_answer_marked_by"], self.user.id)
+        self.assertEqual(thread_json["best_answer_marked_by_name"], self.user.username)
+        self.assertEqual(thread_json["best_answer_marked_by_slug"], self.user.slug)
 
-    @patch_category_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 2})
+    @patch_category_acl({"can_mark_best_answers": 2, "can_change_marked_answers": 2})
     def test_change_best_answer_same_post(self):
         """api validates if new best answer is same as current one"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ["This post is already marked as thread's best answer."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": ["This post is already marked as thread's best answer."],
+            },
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 0, "can_change_marked_answers": 2})
     def test_change_best_answer_no_permission_to_mark(self):
         """api validates permission to mark best answers before allowing answer change"""
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                'You don\'t have permission to mark best answers in the "First category" category.'
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    'You don\'t have permission to mark best answers in the "First category" category.'
+                ],
+            },
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 2, "can_change_marked_answers": 0})
     def test_change_best_answer_no_permission(self):
         """api validates permission to change best answers"""
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                'You don\'t have permission to change this thread\'s marked answer because it\'s '
-                'in the "First category" category.'
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You don't have permission to change this thread's marked answer because it's "
+                    'in the "First category" category.'
+                ],
+            },
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 2, "can_change_marked_answers": 1})
     def test_change_best_answer_not_starter(self):
         """api validates permission to change best answers"""
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                "You don't have permission to change this thread's marked answer because you are "
-                "not a thread starter."
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You don't have permission to change this thread's marked answer because you are "
+                    "not a thread starter."
+                ],
+            },
+        )
 
         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.thread.starter = self.user
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({
-        'can_mark_best_answers': 2,
-        'can_change_marked_answers': 1,
-        'best_answer_change_time': 5,
-    })
+    @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"""
         best_answer = testutils.reply_thread(self.thread)
@@ -1892,31 +1537,31 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                "You don't have permission to change best answer that was marked for more than "
-                "5 minutes."
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You don't have permission to change best answer that was marked for more than "
+                    "5 minutes."
+                ],
+            },
+        )
 
         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,
-        'best_answer_change_time': 5,
-    })
+    @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)
@@ -1926,21 +1571,18 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({
-        'can_mark_best_answers': 2,
-        'can_change_marked_answers': 2,
-        'can_protect_posts': False,
-    })
+    @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"""
         best_answer = testutils.reply_thread(self.thread)
@@ -1949,31 +1591,31 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                "You don't have permission to change this thread's best answer because a "
-                "moderator has protected it."
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You don't have permission to change this thread's best answer because a "
+                    "moderator has protected it."
+                ],
+            },
+        )
 
         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': 2,
-        'can_protect_posts': True,
-    })
+    @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)
@@ -1982,34 +1624,24 @@ class ThreadChangeBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({'can_mark_best_answers': 2, 'can_change_marked_answers': 2})
+    @patch_category_acl({"can_mark_best_answers": 2, "can_change_marked_answers": 2})
     def test_change_best_answer_post_validation(self):
         """api validates new post'"""
         best_answer = testutils.reply_thread(self.thread, is_hidden=True)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "replace", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        
+
         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)
 
 
 class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
@@ -2020,174 +1652,151 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.set_best_answer(self.user, self.best_answer)
         self.thread.save()
 
-    @patch_category_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
+    @patch_category_acl({"can_mark_best_answers": 0, "can_change_marked_answers": 2})
     def test_unmark_best_answer(self):
         """api makes it possible to unmark best answer"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ['ok'],
-
-            'best_answer': None,
-            'best_answer_is_protected': False,
-            'best_answer_marked_on': None,
-            'best_answer_marked_by': None,
-            'best_answer_marked_by_name': None,
-            'best_answer_marked_by_slug': None,
-        })
-
-        thread_json = self.get_thread_json()
-        self.assertIsNone(thread_json['best_answer'])
-        self.assertFalse(thread_json['best_answer_is_protected'])
-        self.assertIsNone(thread_json['best_answer_marked_on'])
-        self.assertIsNone(thread_json['best_answer_marked_by'])
-        self.assertIsNone(thread_json['best_answer_marked_by_name'])
-        self.assertIsNone(thread_json['best_answer_marked_by_slug'])
-
-    @patch_category_acl({'can_mark_best_answers': 0, 'can_change_marked_answers': 2})
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": ["ok"],
+                "best_answer": None,
+                "best_answer_is_protected": False,
+                "best_answer_marked_on": None,
+                "best_answer_marked_by": None,
+                "best_answer_marked_by_name": None,
+                "best_answer_marked_by_slug": None,
+            },
+        )
+
+        thread_json = self.get_thread_json()
+        self.assertIsNone(thread_json["best_answer"])
+        self.assertFalse(thread_json["best_answer_is_protected"])
+        self.assertIsNone(thread_json["best_answer_marked_on"])
+        self.assertIsNone(thread_json["best_answer_marked_by"])
+        self.assertIsNone(thread_json["best_answer_marked_by_name"])
+        self.assertIsNone(thread_json["best_answer_marked_by_slug"])
+
+    @patch_category_acl({"can_mark_best_answers": 0, "can_change_marked_answers": 2})
     def test_unmark_best_answer_invalid_post_id(self):
         """api validates that post id is int"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': 'd7sd89a7d98sa',
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": "d7sd89a7d98sa"}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ["A valid integer is required."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.thread.id, "detail": ["A valid integer is required."]},
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 0, "can_change_marked_answers": 2})
     def test_unmark_best_answer_post_not_found(self):
         """api validates that post to unmark exists"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id + 1,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id + 1}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': ["NOT FOUND"],
-        })
+        self.assertEqual(
+            response.json(), {"id": self.thread.id, "detail": ["NOT FOUND"]}
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 0, "can_change_marked_answers": 2})
     def test_unmark_best_answer_wrong_post(self):
         """api validates if post given to unmark is best answer"""
         best_answer = testutils.reply_thread(self.thread)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                "This post can't be unmarked because it's not currently marked as best answer."
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "This post can't be unmarked because it's not currently marked as best answer."
+                ],
+            },
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 0, "can_change_marked_answers": 0})
     def test_unmark_best_answer_no_permission(self):
         """api validates if user has permission to unmark best answers"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                'You don\'t have permission to unmark threads answers in the "First category" '
-                'category.'
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    'You don\'t have permission to unmark threads answers in the "First category" '
+                    "category."
+                ],
+            },
+        )
 
         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})
+    @patch_category_acl({"can_mark_best_answers": 0, "can_change_marked_answers": 1})
     def test_unmark_best_answer_not_starter(self):
         """api validates if starter has permission to unmark best answers"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                "You don't have permission to unmark this best answer because you are not a "
-                "thread starter."
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You don't have permission to unmark this best answer because you are not a "
+                    "thread starter."
+                ],
+            },
+        )
 
         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.thread.starter = self.user
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({
-        'can_mark_best_answers': 0,
-        'can_change_marked_answers': 1,
-        'best_answer_change_time': 5,
-    })
+    @patch_category_acl(
+        {
+            "can_mark_best_answers": 0,
+            "can_change_marked_answers": 1,
+            "best_answer_change_time": 5,
+        }
+    )
     def test_unmark_best_answer_timelimit(self):
         """api validates if starter has permission to unmark best answer within time limit"""
         self.thread.best_answer_marked_on = timezone.now() - timedelta(minutes=6)
@@ -2195,193 +1804,177 @@ class ThreadUnmarkBestAnswerApiTests(ThreadPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                "You don't have permission to unmark best answer that was marked for more than "
-                "5 minutes."
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You don't have permission to unmark best answer that was marked for more than "
+                    "5 minutes."
+                ],
+            },
+        )
 
         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.thread.best_answer_marked_on = timezone.now() - timedelta(minutes=2)
         self.thread.save()
-        
+
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({
-        'can_mark_best_answers': 0,
-        'can_change_marked_answers': 2,
-        'can_close_threads': False,
-    })
+    @patch_category_acl(
+        {
+            "can_mark_best_answers": 0,
+            "can_change_marked_answers": 2,
+            "can_close_threads": False,
+        }
+    )
     def test_unmark_best_answer_closed_category_no_permission(self):
         """api validates if user has permission to unmark best answer in closed category"""
         self.category.is_closed = True
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                'You don\'t have permission to unmark this best answer because its category '
-                '"First category" is closed.'
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You don't have permission to unmark this best answer because its category "
+                    '"First category" is closed.'
+                ],
+            },
+        )
 
         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,
-        'can_close_threads': True,
-    })
+    @patch_category_acl(
+        {
+            "can_mark_best_answers": 0,
+            "can_change_marked_answers": 2,
+            "can_close_threads": True,
+        }
+    )
     def test_unmark_best_answer_closed_category(self):
         """api validates if user has permission to unmark best answer in closed category"""
         self.category.is_closed = True
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({
-        'can_mark_best_answers': 0,
-        'can_change_marked_answers': 2,
-        'can_close_threads': False,
-    })
+    @patch_category_acl(
+        {
+            "can_mark_best_answers": 0,
+            "can_change_marked_answers": 2,
+            "can_close_threads": False,
+        }
+    )
     def test_unmark_best_answer_closed_thread_no_permission(self):
         """api validates if user has permission to unmark best answer in closed thread"""
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                "You can't unmark this thread's best answer because it's closed and you don't "
-                "have permission to open it."
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You can't unmark this thread's best answer because it's closed and you don't "
+                    "have permission to open it."
+                ],
+            },
+        )
 
         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,
-        'can_close_threads': True,
-    })
+    @patch_category_acl(
+        {
+            "can_mark_best_answers": 0,
+            "can_change_marked_answers": 2,
+            "can_close_threads": True,
+        }
+    )
     def test_unmark_best_answer_closed_thread(self):
         """api validates if user has permission to unmark best answer in closed thread"""
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({
-        'can_mark_best_answers': 0,
-        'can_change_marked_answers': 2,
-        'can_protect_posts': 0,
-    })
+    @patch_category_acl(
+        {
+            "can_mark_best_answers": 0,
+            "can_change_marked_answers": 2,
+            "can_protect_posts": 0,
+        }
+    )
     def test_unmark_best_answer_protected_no_permission(self):
         """api validates permission to unmark protected best answers"""
         self.thread.best_answer_is_protected = True
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.thread.id,
-            'detail': [
-                "You don't have permission to unmark this thread's best answer because a "
-                "moderator has protected it."
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.thread.id,
+                "detail": [
+                    "You don't have permission to unmark this thread's best answer because a "
+                    "moderator has protected it."
+                ],
+            },
+        )
 
         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,
-        'can_protect_posts': 1,
-    })
+    @patch_category_acl(
+        {
+            "can_mark_best_answers": 0,
+            "can_change_marked_answers": 2,
+            "can_protect_posts": 1,
+        }
+    )
     def test_unmark_best_answer_protected(self):
         """api validates permission to unmark protected best answers"""
         self.thread.best_answer_is_protected = True
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'remove',
-                    'path': 'best-answer',
-                    'value': self.best_answer.id,
-                },
-            ]
+            self.api_link,
+            [{"op": "remove", "path": "best-answer", "value": self.best_answer.id}],
         )
         self.assertEqual(response.status_code, 200)

+ 10 - 11
misago/threads/tests/test_thread_poll_api.py

@@ -11,28 +11,27 @@ class ThreadPollApiTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        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.api_link = reverse(
-            'misago:api:thread-poll-list', kwargs={
-                'thread_pk': self.thread.pk,
-            }
+            "misago:api:thread-poll-list", kwargs={"thread_pk": self.thread.pk}
         )
 
     def post(self, url, data=None):
-        return self.client.post(url, json.dumps(data or {}), content_type='application/json')
+        return self.client.post(
+            url, json.dumps(data or {}), content_type="application/json"
+        )
 
     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 mock_poll(self):
         self.poll = self.thread.poll = testutils.post_poll(self.thread, self.user)
 
         self.api_link = reverse(
-            'misago:api:thread-poll-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.poll.pk,
-            }
+            "misago:api:thread-poll-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.poll.pk},
         )

+ 78 - 121
misago/threads/tests/test_thread_pollcreate_api.py

@@ -19,9 +19,7 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
     def test_invalid_thread_id(self):
         """api validates that thread id is integer"""
         api_link = reverse(
-            'misago:api:thread-poll-list', kwargs={
-                'thread_pk': 'kjha6dsa687sa',
-            }
+            "misago:api:thread-poll-list", kwargs={"thread_pk": "kjha6dsa687sa"}
         )
 
         response = self.post(api_link)
@@ -30,9 +28,7 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
     def test_nonexistant_thread_id(self):
         """api validates that thread exists"""
         api_link = reverse(
-            'misago:api:thread-poll-list', kwargs={
-                'thread_pk': self.thread.pk + 1,
-            }
+            "misago:api:thread-poll-list", kwargs={"thread_pk": self.thread.pk + 1}
         )
 
         response = self.post(api_link)
@@ -43,9 +39,7 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         """api validates that user has permission to start poll in thread"""
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't start polls."
-        })
+        self.assertEqual(response.json(), {"detail": "You can't start polls."})
 
     @patch_user_acl({"can_start_polls": 1})
     @patch_category_acl({"can_close_threads": False})
@@ -56,9 +50,10 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This thread is closed. You can't start polls in it."
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This thread is closed. You can't start polls in it."},
+        )
 
     @patch_user_acl({"can_start_polls": 1})
     @patch_category_acl({"can_close_threads": True})
@@ -79,9 +74,10 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't start polls in it."
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This category is closed. You can't start polls in it."},
+        )
 
     @patch_user_acl({"can_start_polls": 1})
     @patch_category_acl({"can_close_threads": True})
@@ -101,9 +97,9 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't start polls in other users threads."
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't start polls in other users threads."}
+        )
 
     @patch_user_acl({"can_start_polls": 2})
     def test_other_user_thread(self):
@@ -119,23 +115,19 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         self.thread.poll = Poll.objects.create(
             thread=self.thread,
             category=self.category,
-            poster_name='Test',
-            poster_slug='test',
+            poster_name="Test",
+            poster_slug="test",
             length=30,
-            question='Test',
-            choices=[
-                {
-                    'hash': 't3st'
-                },
-            ],
+            question="Test",
+            choices=[{"hash": "t3st"}],
             allowed_choices=1,
         )
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "There's already a poll in this thread."
-        })
+        self.assertEqual(
+            response.json(), {"detail": "There's already a poll in this thread."}
+        )
 
     def test_empty_data(self):
         """api handles empty request data"""
@@ -147,97 +139,73 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
 
     def test_length_validation(self):
         """api validates poll's length"""
-        response = self.post(
-            self.api_link, data={
-                'length': -1,
-            }
-        )
+        response = self.post(self.api_link, data={"length": -1})
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['length'], ["Ensure this value is greater than or equal to 0."]
+            response_json["length"],
+            ["Ensure this value is greater than or equal to 0."],
         )
 
-        response = self.post(
-            self.api_link, data={
-                'length': 200,
-            }
-        )
+        response = self.post(self.api_link, data={"length": 200})
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['length'], ["Ensure this value is less than or equal to 180."]
+            response_json["length"], ["Ensure this value is less than or equal to 180."]
         )
 
     def test_question_validation(self):
         """api validates question length"""
-        response = self.post(
-            self.api_link, data={
-                'question': 'abcd' * 255,
-            }
-        )
+        response = self.post(self.api_link, data={"question": "abcd" * 255})
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['question'], ["Ensure this field has no more than 255 characters."]
+            response_json["question"],
+            ["Ensure this field has no more than 255 characters."],
         )
 
     def test_validate_choice_length(self):
         """api validates single choice length"""
         response = self.post(
-            self.api_link, data={
-                'choices': [
-                    {
-                        'hash': 'qwertyuiopas',
-                        'label': '',
-                    },
-                ],
-            }
+            self.api_link, data={"choices": [{"hash": "qwertyuiopas", "label": ""}]}
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
-        self.assertEqual(response_json['choices'], ["One or more poll choices are invalid."])
+        self.assertEqual(
+            response_json["choices"], ["One or more poll choices are invalid."]
+        )
 
         response = self.post(
             self.api_link,
-            data={
-                'choices': [
-                    {
-                        'hash': 'qwertyuiopas',
-                        'label': 'abcd' * 255,
-                    },
-                ],
-            }
+            data={"choices": [{"hash": "qwertyuiopas", "label": "abcd" * 255}]},
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
-        self.assertEqual(response_json['choices'], ["One or more poll choices are invalid."])
+        self.assertEqual(
+            response_json["choices"], ["One or more poll choices are invalid."]
+        )
 
     def test_validate_two_choices(self):
         """api validates that there are at least two choices in poll"""
-        response = self.post(self.api_link, data={'choices': [{'label': 'Choice'}]})
+        response = self.post(self.api_link, data={"choices": [{"label": "Choice"}]})
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['choices'], ["You need to add at least two choices to a poll."]
+            response_json["choices"],
+            ["You need to add at least two choices to a poll."],
         )
 
     def test_validate_max_choices(self):
         """api validates that there are no more choices in poll than allowed number"""
         response = self.post(
-            self.api_link, data={
-                'choices': [
-                    {
-                        'label': 'Choice',
-                    },
-                ] * (MAX_POLL_OPTIONS + 1),
-            }
+            self.api_link,
+            data={"choices": [{"label": "Choice"}] * (MAX_POLL_OPTIONS + 1)},
         )
         self.assertEqual(response.status_code, 400)
 
@@ -245,42 +213,39 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
 
         response_json = response.json()
         self.assertEqual(
-            response_json['choices'],
-            ["You can't add more than %s options to a single poll (added %s)." % error_formats]
+            response_json["choices"],
+            [
+                "You can't add more than %s options to a single poll (added %s)."
+                % error_formats
+            ],
         )
 
     def test_allowed_choices_validation(self):
         """api validates allowed choices number"""
-        response = self.post(self.api_link, data={'allowed_choices': 0})
+        response = self.post(self.api_link, data={"allowed_choices": 0})
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['allowed_choices'], ["Ensure this value is greater than or equal to 1."]
+            response_json["allowed_choices"],
+            ["Ensure this value is greater than or equal to 1."],
         )
 
         response = self.post(
             self.api_link,
             data={
-                'length': 0,
-                'question': "Lorem ipsum",
-                'allowed_choices': 3,
-                'choices': [
-                    {
-                        'label': 'Choice',
-                    },
-                    {
-                        'label': 'Choice',
-                    },
-                ],
-            }
+                "length": 0,
+                "question": "Lorem ipsum",
+                "allowed_choices": 3,
+                "choices": [{"label": "Choice"}, {"label": "Choice"}],
+            },
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['non_field_errors'],
-            ["Number of allowed choices can't be greater than number of all choices."]
+            response_json["non_field_errors"],
+            ["Number of allowed choices can't be greater than number of all choices."],
         )
 
     def test_poll_created(self):
@@ -288,39 +253,31 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         response = self.post(
             self.api_link,
             data={
-                'length': 40,
-                'question': "Select two best colors",
-                'allowed_choices': 2,
-                'allow_revotes': True,
-                'is_public': True,
-                'choices': [
-                    {
-                        'label': '\nRed ',
-                    },
-                    {
-                        'label': 'Green',
-                    },
-                    {
-                        'label': 'Blue',
-                    },
-                ],
-            }
+                "length": 40,
+                "question": "Select two best colors",
+                "allowed_choices": 2,
+                "allow_revotes": True,
+                "is_public": True,
+                "choices": [{"label": "\nRed "}, {"label": "Green"}, {"label": "Blue"}],
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
 
-        self.assertEqual(response_json['poster_name'], self.user.username)
-        self.assertEqual(response_json['length'], 40)
-        self.assertEqual(response_json['question'], "Select two best colors")
-        self.assertEqual(response_json['allowed_choices'], 2)
-        self.assertTrue(response_json['allow_revotes'])
-        self.assertEqual(response_json['votes'], 0)
-        self.assertTrue(response_json['is_public'])
+        self.assertEqual(response_json["poster_name"], self.user.username)
+        self.assertEqual(response_json["length"], 40)
+        self.assertEqual(response_json["question"], "Select two best colors")
+        self.assertEqual(response_json["allowed_choices"], 2)
+        self.assertTrue(response_json["allow_revotes"])
+        self.assertEqual(response_json["votes"], 0)
+        self.assertTrue(response_json["is_public"])
 
-        self.assertEqual(len(response_json['choices']), 3)
-        self.assertEqual(len(set([c['hash'] for c in response_json['choices']])), 3)
-        self.assertEqual([c['label'] for c in response_json['choices']], ['Red', 'Green', 'Blue'])
+        self.assertEqual(len(response_json["choices"]), 3)
+        self.assertEqual(len(set([c["hash"] for c in response_json["choices"]])), 3)
+        self.assertEqual(
+            [c["label"] for c in response_json["choices"]], ["Red", "Green", "Blue"]
+        )
 
         thread = Thread.objects.get(pk=self.thread.pk)
         self.assertTrue(thread.has_poll)
@@ -340,6 +297,6 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         self.assertTrue(poll.is_public)
 
         self.assertEqual(len(poll.choices), 3)
-        self.assertEqual(len(set([c['hash'] for c in poll.choices])), 3)
-        
+        self.assertEqual(len(set([c["hash"] for c in poll.choices])), 3)
+
         self.assertEqual(self.user.audittrail_set.count(), 1)

+ 30 - 40
misago/threads/tests/test_thread_polldelete_api.py

@@ -26,11 +26,8 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
     def test_invalid_thread_id(self):
         """api validates that thread id is integer"""
         api_link = reverse(
-            'misago:api:thread-poll-detail',
-            kwargs={
-                'thread_pk': 'kjha6dsa687sa',
-                'pk': self.poll.pk,
-            }
+            "misago:api:thread-poll-detail",
+            kwargs={"thread_pk": "kjha6dsa687sa", "pk": self.poll.pk},
         )
 
         response = self.client.delete(api_link)
@@ -39,11 +36,8 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
     def test_nonexistant_thread_id(self):
         """api validates that thread exists"""
         api_link = reverse(
-            'misago:api:thread-poll-detail',
-            kwargs={
-                'thread_pk': self.thread.pk + 1,
-                'pk': self.poll.pk,
-            }
+            "misago:api:thread-poll-detail",
+            kwargs={"thread_pk": self.thread.pk + 1, "pk": self.poll.pk},
         )
 
         response = self.client.delete(api_link)
@@ -52,11 +46,8 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
     def test_invalid_poll_id(self):
         """api validates that poll id is integer"""
         api_link = reverse(
-            'misago:api:thread-poll-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': 'sad98as7d97sa98',
-            }
+            "misago:api:thread-poll-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": "sad98as7d97sa98"},
         )
 
         response = self.client.delete(api_link)
@@ -65,11 +56,8 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
     def test_nonexistant_poll_id(self):
         """api validates that poll exists"""
         api_link = reverse(
-            'misago:api:thread-poll-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.poll.pk + 123,
-            }
+            "misago:api:thread-poll-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.poll.pk + 123},
         )
 
         response = self.client.delete(api_link)
@@ -80,9 +68,7 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         """api validates that user has permission to delete poll in thread"""
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete polls."
-        })
+        self.assertEqual(response.json(), {"detail": "You can't delete polls."})
 
     @patch_user_acl({"can_delete_polls": 1, "poll_edit_time": 5})
     def test_no_permission_timeout(self):
@@ -92,9 +78,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete polls that are older than 5 minutes."
-        })
+        self.assertEqual(
+            response.json(),
+            {"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):
@@ -105,9 +92,9 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This poll is over. You can't delete it."
-        })
+        self.assertEqual(
+            response.json(), {"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):
@@ -117,9 +104,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete other users polls in this category."
-        })
+        self.assertEqual(
+            response.json(),
+            {"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})
@@ -130,9 +118,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This thread is closed. You can't delete polls in it."
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This thread is closed. You can't delete polls in it."},
+        )
 
     @patch_user_acl({"can_delete_polls": 1})
     @patch_category_acl({"can_close_threads": True})
@@ -153,9 +142,10 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't delete polls in it."
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This category is closed. You can't delete polls in it."},
+        )
 
     @patch_user_acl({"can_delete_polls": 1})
     @patch_category_acl({"can_close_threads": True})
@@ -173,7 +163,7 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         response = self.client.delete(self.api_link)
 
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {'can_start_poll': True})
+        self.assertEqual(response.json(), {"can_start_poll": True})
 
         self.assertEqual(Poll.objects.count(), 0)
         self.assertEqual(PollVote.objects.count(), 0)
@@ -192,7 +182,7 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {'can_start_poll': True})
+        self.assertEqual(response.json(), {"can_start_poll": True})
 
         self.assertEqual(Poll.objects.count(), 0)
         self.assertEqual(PollVote.objects.count(), 0)

+ 185 - 254
misago/threads/tests/test_thread_polledit_api.py

@@ -26,11 +26,8 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
     def test_invalid_thread_id(self):
         """api validates that thread id is integer"""
         api_link = reverse(
-            'misago:api:thread-poll-detail',
-            kwargs={
-                'thread_pk': 'kjha6dsa687sa',
-                'pk': self.poll.pk,
-            }
+            "misago:api:thread-poll-detail",
+            kwargs={"thread_pk": "kjha6dsa687sa", "pk": self.poll.pk},
         )
 
         response = self.put(api_link)
@@ -39,11 +36,8 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
     def test_nonexistant_thread_id(self):
         """api validates that thread exists"""
         api_link = reverse(
-            'misago:api:thread-poll-detail',
-            kwargs={
-                'thread_pk': self.thread.pk + 1,
-                'pk': self.poll.pk,
-            }
+            "misago:api:thread-poll-detail",
+            kwargs={"thread_pk": self.thread.pk + 1, "pk": self.poll.pk},
         )
 
         response = self.put(api_link)
@@ -52,11 +46,8 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
     def test_invalid_poll_id(self):
         """api validates that poll id is integer"""
         api_link = reverse(
-            'misago:api:thread-poll-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': 'sad98as7d97sa98',
-            }
+            "misago:api:thread-poll-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": "sad98as7d97sa98"},
         )
 
         response = self.put(api_link)
@@ -65,11 +56,8 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
     def test_nonexistant_poll_id(self):
         """api validates that poll exists"""
         api_link = reverse(
-            'misago:api:thread-poll-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.poll.pk + 123,
-            }
+            "misago:api:thread-poll-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.poll.pk + 123},
         )
 
         response = self.put(api_link)
@@ -80,9 +68,7 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         """api validates that user has permission to edit poll in thread"""
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't edit polls.",
-        })
+        self.assertEqual(response.json(), {"detail": "You can't edit polls."})
 
     @patch_user_acl({"can_edit_polls": 1, "poll_edit_time": 5})
     def test_no_permission_timeout(self):
@@ -92,9 +78,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't edit polls that are older than 5 minutes.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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):
@@ -105,9 +92,9 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This poll is over. You can't edit it.",
-        })
+        self.assertEqual(
+            response.json(), {"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):
@@ -117,9 +104,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't edit other users polls in this category.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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})
@@ -130,9 +118,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This thread is closed. You can't edit polls in it.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This thread is closed. You can't edit polls in it."},
+        )
 
     @patch_user_acl({"can_edit_polls": 1})
     @patch_category_acl({"can_close_threads": True})
@@ -153,9 +142,10 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't edit polls in it.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This category is closed. You can't edit polls in it."},
+        )
 
     @patch_user_acl({"can_edit_polls": 1})
     @patch_category_acl({"can_close_threads": True})
@@ -177,97 +167,73 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
     def test_length_validation(self):
         """api validates poll's length"""
-        response = self.put(
-            self.api_link, data={
-                'length': -1,
-            }
-        )
+        response = self.put(self.api_link, data={"length": -1})
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['length'], ["Ensure this value is greater than or equal to 0."]
+            response_json["length"],
+            ["Ensure this value is greater than or equal to 0."],
         )
 
-        response = self.put(self.api_link, data={'length': 200})
+        response = self.put(self.api_link, data={"length": 200})
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['length'], ["Ensure this value is less than or equal to 180."]
+            response_json["length"], ["Ensure this value is less than or equal to 180."]
         )
 
     def test_question_validation(self):
         """api validates question length"""
-        response = self.put(self.api_link, data={'question': 'abcd' * 255})
+        response = self.put(self.api_link, data={"question": "abcd" * 255})
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['question'], ["Ensure this field has no more than 255 characters."]
+            response_json["question"],
+            ["Ensure this field has no more than 255 characters."],
         )
 
     def test_validate_choice_length(self):
         """api validates single choice length"""
         response = self.put(
-            self.api_link, data={
-                'choices': [
-                    {
-                        'hash': 'qwertyuiopas',
-                        'label': '',
-                    },
-                ],
-            }
+            self.api_link, data={"choices": [{"hash": "qwertyuiopas", "label": ""}]}
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
-        self.assertEqual(response_json['choices'], ["One or more poll choices are invalid."])
+        self.assertEqual(
+            response_json["choices"], ["One or more poll choices are invalid."]
+        )
 
         response = self.put(
             self.api_link,
-            data={
-                'choices': [
-                    {
-                        'hash': 'qwertyuiopas',
-                        'label': 'abcd' * 255,
-                    },
-                ],
-            }
+            data={"choices": [{"hash": "qwertyuiopas", "label": "abcd" * 255}]},
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
-        self.assertEqual(response_json['choices'], ["One or more poll choices are invalid."])
+        self.assertEqual(
+            response_json["choices"], ["One or more poll choices are invalid."]
+        )
 
     def test_validate_two_choices(self):
         """api validates that there are at least two choices in poll"""
-        response = self.put(
-            self.api_link, data={
-                'choices': [
-                    {
-                        'label': 'Choice',
-                    },
-                ],
-            }
-        )
+        response = self.put(self.api_link, data={"choices": [{"label": "Choice"}]})
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['choices'], ["You need to add at least two choices to a poll."]
+            response_json["choices"],
+            ["You need to add at least two choices to a poll."],
         )
 
     def test_validate_max_choices(self):
         """api validates that there are no more choices in poll than allowed number"""
         response = self.put(
-            self.api_link, data={
-                'choices': [
-                    {
-                        'label': 'Choice',
-                    },
-                ] * (MAX_POLL_OPTIONS + 1),
-            }
+            self.api_link,
+            data={"choices": [{"label": "Choice"}] * (MAX_POLL_OPTIONS + 1)},
         )
         self.assertEqual(response.status_code, 400)
 
@@ -275,42 +241,39 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
         response_json = response.json()
         self.assertEqual(
-            response_json['choices'],
-            ["You can't add more than %s options to a single poll (added %s)." % error_formats]
+            response_json["choices"],
+            [
+                "You can't add more than %s options to a single poll (added %s)."
+                % error_formats
+            ],
         )
 
     def test_allowed_choices_validation(self):
         """api validates allowed choices number"""
-        response = self.put(self.api_link, data={'allowed_choices': 0})
+        response = self.put(self.api_link, data={"allowed_choices": 0})
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['allowed_choices'], ["Ensure this value is greater than or equal to 1."]
+            response_json["allowed_choices"],
+            ["Ensure this value is greater than or equal to 1."],
         )
 
         response = self.put(
             self.api_link,
             data={
-                'length': 0,
-                'question': "Lorem ipsum",
-                'allowed_choices': 3,
-                'choices': [
-                    {
-                        'label': 'Choice',
-                    },
-                    {
-                        'label': 'Choice',
-                    },
-                ],
-            }
+                "length": 0,
+                "question": "Lorem ipsum",
+                "allowed_choices": 3,
+                "choices": [{"label": "Choice"}, {"label": "Choice"}],
+            },
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['non_field_errors'],
-            ["Number of allowed choices can't be greater than number of all choices."]
+            response_json["non_field_errors"],
+            ["Number of allowed choices can't be greater than number of all choices."],
         )
 
     def test_poll_all_choices_replaced(self):
@@ -318,48 +281,46 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         response = self.put(
             self.api_link,
             data={
-                'length': 40,
-                'question': "Select two best colors",
-                'allowed_choices': 2,
-                'allow_revotes': True,
-                'is_public': True,
-                'choices': [
-                    {
-                        'label': '\nRed  ',
-                    },
-                    {
-                        'label': 'Green',
-                    },
-                    {
-                        'label': 'Blue',
-                    },
+                "length": 40,
+                "question": "Select two best colors",
+                "allowed_choices": 2,
+                "allow_revotes": True,
+                "is_public": True,
+                "choices": [
+                    {"label": "\nRed  "},
+                    {"label": "Green"},
+                    {"label": "Blue"},
                 ],
-            }
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
 
-        self.assertEqual(response_json['poster_name'], self.user.username)
-        self.assertEqual(response_json['length'], 40)
-        self.assertEqual(response_json['question'], "Select two best colors")
-        self.assertEqual(response_json['allowed_choices'], 2)
-        self.assertTrue(response_json['allow_revotes'])
+        self.assertEqual(response_json["poster_name"], self.user.username)
+        self.assertEqual(response_json["length"], 40)
+        self.assertEqual(response_json["question"], "Select two best colors")
+        self.assertEqual(response_json["allowed_choices"], 2)
+        self.assertTrue(response_json["allow_revotes"])
 
         # you can't change poll's type after its posted
-        self.assertFalse(response_json['is_public'])
+        self.assertFalse(response_json["is_public"])
 
         # choices were updated
-        self.assertEqual(len(response_json['choices']), 3)
-        self.assertEqual(len(set([c['hash'] for c in response_json['choices']])), 3)
-        self.assertEqual([c['label'] for c in response_json['choices']], ['Red', 'Green', 'Blue'])
-        self.assertEqual([c['votes'] for c in response_json['choices']], [0, 0, 0])
-        self.assertEqual([c['selected'] for c in response_json['choices']], [False, False, False])
+        self.assertEqual(len(response_json["choices"]), 3)
+        self.assertEqual(len(set([c["hash"] for c in response_json["choices"]])), 3)
+        self.assertEqual(
+            [c["label"] for c in response_json["choices"]], ["Red", "Green", "Blue"]
+        )
+        self.assertEqual([c["votes"] for c in response_json["choices"]], [0, 0, 0])
+        self.assertEqual(
+            [c["selected"] for c in response_json["choices"]], [False, False, False]
+        )
 
         # votes were removed
-        self.assertEqual(response_json['votes'], 0)
+        self.assertEqual(response_json["votes"], 0)
         self.assertEqual(self.poll.pollvote_set.count(), 0)
-        
+
         self.assertEqual(self.user.audittrail_set.count(), 1)
 
     def test_poll_current_choices_edited(self):
@@ -367,84 +328,68 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         response = self.put(
             self.api_link,
             data={
-                'length': 40,
-                'question': "Select two best colors",
-                'allowed_choices': 2,
-                'allow_revotes': True,
-                'is_public': True,
-                'choices': [
-                    {
-                        'hash': 'aaaaaaaaaaaa',
-                        'label': '\nFirst  ',
-                        'votes': 5555,
-                    },
-                    {
-                        'hash': 'bbbbbbbbbbbb',
-                        'label': 'Second',
-                        'votes': 5555,
-                    },
-                    {
-                        'hash': 'gggggggggggg',
-                        'label': 'Third',
-                        'votes': 5555,
-                    },
-                    {
-                        'hash': 'dddddddddddd',
-                        'label': 'Fourth',
-                        'votes': 5555,
-                    },
+                "length": 40,
+                "question": "Select two best colors",
+                "allowed_choices": 2,
+                "allow_revotes": True,
+                "is_public": True,
+                "choices": [
+                    {"hash": "aaaaaaaaaaaa", "label": "\nFirst  ", "votes": 5555},
+                    {"hash": "bbbbbbbbbbbb", "label": "Second", "votes": 5555},
+                    {"hash": "gggggggggggg", "label": "Third", "votes": 5555},
+                    {"hash": "dddddddddddd", "label": "Fourth", "votes": 5555},
                 ],
-            }
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
 
-        self.assertEqual(response_json['poster_name'], self.user.username)
-        self.assertEqual(response_json['length'], 40)
-        self.assertEqual(response_json['question'], "Select two best colors")
-        self.assertEqual(response_json['allowed_choices'], 2)
-        self.assertTrue(response_json['allow_revotes'])
+        self.assertEqual(response_json["poster_name"], self.user.username)
+        self.assertEqual(response_json["length"], 40)
+        self.assertEqual(response_json["question"], "Select two best colors")
+        self.assertEqual(response_json["allowed_choices"], 2)
+        self.assertTrue(response_json["allow_revotes"])
 
         # you can't change poll's type after its posted
-        self.assertFalse(response_json['is_public'])
+        self.assertFalse(response_json["is_public"])
 
         # choices were updated
-        self.assertEqual(len(response_json['choices']), 4)
+        self.assertEqual(len(response_json["choices"]), 4)
         self.assertEqual(
-            response_json['choices'],
+            response_json["choices"],
             [
                 {
-                    'hash': 'aaaaaaaaaaaa',
-                    'label': 'First',
-                    'votes': 1,
-                    'selected': False,
+                    "hash": "aaaaaaaaaaaa",
+                    "label": "First",
+                    "votes": 1,
+                    "selected": False,
                 },
                 {
-                    'hash': 'bbbbbbbbbbbb',
-                    'label': 'Second',
-                    'votes': 0,
-                    'selected': False,
+                    "hash": "bbbbbbbbbbbb",
+                    "label": "Second",
+                    "votes": 0,
+                    "selected": False,
                 },
                 {
-                    'hash': 'gggggggggggg',
-                    'label': 'Third',
-                    'votes': 2,
-                    'selected': True,
+                    "hash": "gggggggggggg",
+                    "label": "Third",
+                    "votes": 2,
+                    "selected": True,
                 },
                 {
-                    'hash': 'dddddddddddd',
-                    'label': 'Fourth',
-                    'votes': 1,
-                    'selected': True,
+                    "hash": "dddddddddddd",
+                    "label": "Fourth",
+                    "votes": 1,
+                    "selected": True,
                 },
             ],
         )
 
         # no votes were removed
-        self.assertEqual(response_json['votes'], 4)
+        self.assertEqual(response_json["votes"], 4)
         self.assertEqual(self.poll.pollvote_set.count(), 4)
-        
+
         self.assertEqual(self.user.audittrail_set.count(), 1)
 
     def test_poll_some_choices_edited(self):
@@ -452,71 +397,59 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         response = self.put(
             self.api_link,
             data={
-                'length': 40,
-                'question': "Select two best colors",
-                'allowed_choices': 2,
-                'allow_revotes': True,
-                'is_public': True,
-                'choices': [
-                    {
-                        'hash': 'aaaaaaaaaaaa',
-                        'label': '\nFirst ',
-                        'votes': 5555,
-                    },
-                    {
-                        'hash': 'bbbbbbbbbbbb',
-                        'label': 'Second',
-                        'votes': 5555,
-                    },
-                    {
-                        'hash': 'dsadsadsa788',
-                        'label': 'New Option',
-                        'votes': 5555,
-                    },
+                "length": 40,
+                "question": "Select two best colors",
+                "allowed_choices": 2,
+                "allow_revotes": True,
+                "is_public": True,
+                "choices": [
+                    {"hash": "aaaaaaaaaaaa", "label": "\nFirst ", "votes": 5555},
+                    {"hash": "bbbbbbbbbbbb", "label": "Second", "votes": 5555},
+                    {"hash": "dsadsadsa788", "label": "New Option", "votes": 5555},
                 ],
-            }
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
 
-        self.assertEqual(response_json['poster_name'], self.user.username)
-        self.assertEqual(response_json['length'], 40)
-        self.assertEqual(response_json['question'], "Select two best colors")
-        self.assertEqual(response_json['allowed_choices'], 2)
-        self.assertTrue(response_json['allow_revotes'])
+        self.assertEqual(response_json["poster_name"], self.user.username)
+        self.assertEqual(response_json["length"], 40)
+        self.assertEqual(response_json["question"], "Select two best colors")
+        self.assertEqual(response_json["allowed_choices"], 2)
+        self.assertTrue(response_json["allow_revotes"])
 
         # you can't change poll's type after its posted
-        self.assertFalse(response_json['is_public'])
+        self.assertFalse(response_json["is_public"])
 
         # choices were updated
-        self.assertEqual(len(response_json['choices']), 3)
+        self.assertEqual(len(response_json["choices"]), 3)
         self.assertEqual(
-            response_json['choices'],
+            response_json["choices"],
             [
                 {
-                    'hash': 'aaaaaaaaaaaa',
-                    'label': 'First',
-                    'votes': 1,
-                    'selected': False,
+                    "hash": "aaaaaaaaaaaa",
+                    "label": "First",
+                    "votes": 1,
+                    "selected": False,
                 },
                 {
-                    'hash': 'bbbbbbbbbbbb',
-                    'label': 'Second',
-                    'votes': 0,
-                    'selected': False,
+                    "hash": "bbbbbbbbbbbb",
+                    "label": "Second",
+                    "votes": 0,
+                    "selected": False,
                 },
                 {
-                    'hash': response_json['choices'][2]['hash'],
-                    'label': 'New Option',
-                    'votes': 0,
-                    'selected': False,
+                    "hash": response_json["choices"][2]["hash"],
+                    "label": "New Option",
+                    "votes": 0,
+                    "selected": False,
                 },
             ],
         )
 
         # no votes were removed
-        self.assertEqual(response_json['votes'], 1)
+        self.assertEqual(response_json["votes"], 1)
         self.assertEqual(self.poll.pollvote_set.count(), 1)
 
         self.assertEqual(self.user.audittrail_set.count(), 1)
@@ -532,46 +465,44 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         response = self.put(
             self.api_link,
             data={
-                'length': 40,
-                'question': "Select two best colors",
-                'allowed_choices': 2,
-                'allow_revotes': True,
-                'is_public': True,
-                'choices': [
-                    {
-                        'label': '\nRed  ',
-                    },
-                    {
-                        'label': 'Green',
-                    },
-                    {
-                        'label': 'Blue',
-                    },
+                "length": 40,
+                "question": "Select two best colors",
+                "allowed_choices": 2,
+                "allow_revotes": True,
+                "is_public": True,
+                "choices": [
+                    {"label": "\nRed  "},
+                    {"label": "Green"},
+                    {"label": "Blue"},
                 ],
-            }
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
 
-        self.assertEqual(response_json['poster_name'], self.user.username)
-        self.assertEqual(response_json['length'], 40)
-        self.assertEqual(response_json['question'], "Select two best colors")
-        self.assertEqual(response_json['allowed_choices'], 2)
-        self.assertTrue(response_json['allow_revotes'])
+        self.assertEqual(response_json["poster_name"], self.user.username)
+        self.assertEqual(response_json["length"], 40)
+        self.assertEqual(response_json["question"], "Select two best colors")
+        self.assertEqual(response_json["allowed_choices"], 2)
+        self.assertTrue(response_json["allow_revotes"])
 
         # you can't change poll's type after its posted
-        self.assertFalse(response_json['is_public'])
+        self.assertFalse(response_json["is_public"])
 
         # choices were updated
-        self.assertEqual(len(response_json['choices']), 3)
-        self.assertEqual(len(set([c['hash'] for c in response_json['choices']])), 3)
-        self.assertEqual([c['label'] for c in response_json['choices']], ['Red', 'Green', 'Blue'])
-        self.assertEqual([c['votes'] for c in response_json['choices']], [0, 0, 0])
-        self.assertEqual([c['selected'] for c in response_json['choices']], [False, False, False])
+        self.assertEqual(len(response_json["choices"]), 3)
+        self.assertEqual(len(set([c["hash"] for c in response_json["choices"]])), 3)
+        self.assertEqual(
+            [c["label"] for c in response_json["choices"]], ["Red", "Green", "Blue"]
+        )
+        self.assertEqual([c["votes"] for c in response_json["choices"]], [0, 0, 0])
+        self.assertEqual(
+            [c["selected"] for c in response_json["choices"]], [False, False, False]
+        )
 
         # votes were removed
-        self.assertEqual(response_json['votes'], 0)
+        self.assertEqual(response_json["votes"], 0)
         self.assertEqual(self.poll.pollvote_set.count(), 0)
 
         self.assertEqual(self.user.audittrail_set.count(), 1)

+ 105 - 111
misago/threads/tests/test_thread_pollvotes_api.py

@@ -24,11 +24,8 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
         self.poll.save()
 
         self.api_link = reverse(
-            'misago:api:thread-poll-votes',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.poll.pk,
-            }
+            "misago:api:thread-poll-votes",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.poll.pk},
         )
 
     def test_anonymous(self):
@@ -41,11 +38,8 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
     def test_invalid_thread_id(self):
         """api validates that thread id is integer"""
         api_link = reverse(
-            'misago:api:thread-poll-votes',
-            kwargs={
-                'thread_pk': 'kjha6dsa687sa',
-                'pk': self.poll.pk,
-            }
+            "misago:api:thread-poll-votes",
+            kwargs={"thread_pk": "kjha6dsa687sa", "pk": self.poll.pk},
         )
 
         response = self.client.get(api_link)
@@ -54,11 +48,8 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
     def test_nonexistant_thread_id(self):
         """api validates that thread exists"""
         api_link = reverse(
-            'misago:api:thread-poll-votes',
-            kwargs={
-                'thread_pk': self.thread.pk + 1,
-                'pk': self.poll.pk,
-            }
+            "misago:api:thread-poll-votes",
+            kwargs={"thread_pk": self.thread.pk + 1, "pk": self.poll.pk},
         )
 
         response = self.client.get(api_link)
@@ -67,11 +58,8 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
     def test_invalid_poll_id(self):
         """api validates that poll id is integer"""
         api_link = reverse(
-            'misago:api:thread-poll-votes',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': 'sad98as7d97sa98',
-            }
+            "misago:api:thread-poll-votes",
+            kwargs={"thread_pk": self.thread.pk, "pk": "sad98as7d97sa98"},
         )
 
         response = self.client.get(api_link)
@@ -80,11 +68,8 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
     def test_nonexistant_poll_id(self):
         """api validates that poll exists"""
         api_link = reverse(
-            'misago:api:thread-poll-votes',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.poll.pk + 123,
-            }
+            "misago:api:thread-poll-votes",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.poll.pk + 123},
         )
 
         response = self.client.get(api_link)
@@ -117,17 +102,22 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json), 4)
 
-        self.assertEqual([c['label'] for c in response_json], ['Alpha', 'Beta', 'Gamma', 'Delta'])
-        self.assertEqual([c['votes'] for c in response_json], [1, 0, 2, 1])
-        self.assertEqual([len(c['voters']) for c in response_json], [1, 0, 2, 1])
+        self.assertEqual(
+            [c["label"] for c in response_json], ["Alpha", "Beta", "Gamma", "Delta"]
+        )
+        self.assertEqual([c["votes"] for c in response_json], [1, 0, 2, 1])
+        self.assertEqual([len(c["voters"]) for c in response_json], [1, 0, 2, 1])
 
-        self.assertEqual([[v['username'] for v in c['voters']] for c in response_json][0][0],
-                         'bob')
+        self.assertEqual(
+            [[v["username"] for v in c["voters"]] for c in response_json][0][0], "bob"
+        )
 
-        user = UserModel.objects.get(slug='bob')
+        user = UserModel.objects.get(slug="bob")
 
-        self.assertEqual([[v['url'] for v in c['voters']] for c in response_json][0][0],
-                         user.get_absolute_url())
+        self.assertEqual(
+            [[v["url"] for v in c["voters"]] for c in response_json][0][0],
+            user.get_absolute_url(),
+        )
 
     @patch_user_acl({"can_always_see_poll_voters": True})
     def test_get_votes_private_poll(self):
@@ -141,17 +131,22 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
         response_json = response.json()
         self.assertEqual(len(response_json), 4)
 
-        self.assertEqual([c['label'] for c in response_json], ['Alpha', 'Beta', 'Gamma', 'Delta'])
-        self.assertEqual([c['votes'] for c in response_json], [1, 0, 2, 1])
-        self.assertEqual([len(c['voters']) for c in response_json], [1, 0, 2, 1])
+        self.assertEqual(
+            [c["label"] for c in response_json], ["Alpha", "Beta", "Gamma", "Delta"]
+        )
+        self.assertEqual([c["votes"] for c in response_json], [1, 0, 2, 1])
+        self.assertEqual([len(c["voters"]) for c in response_json], [1, 0, 2, 1])
 
-        self.assertEqual([[v['username'] for v in c['voters']] for c in response_json][0][0],
-                         'bob')
+        self.assertEqual(
+            [[v["username"] for v in c["voters"]] for c in response_json][0][0], "bob"
+        )
 
-        user = UserModel.objects.get(slug='bob')
+        user = UserModel.objects.get(slug="bob")
 
-        self.assertEqual([[v['url'] for v in c['voters']] for c in response_json][0][0],
-                         user.get_absolute_url())
+        self.assertEqual(
+            [[v["url"] for v in c["voters"]] for c in response_json][0][0],
+            user.get_absolute_url(),
+        )
 
 
 class ThreadPostVotesTests(ThreadPollApiTestCase):
@@ -161,16 +156,13 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         self.mock_poll()
 
         self.api_link = reverse(
-            'misago:api:thread-poll-votes',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.poll.pk,
-            }
+            "misago:api:thread-poll-votes",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.poll.pk},
         )
 
     def delete_user_votes(self):
-        self.poll.choices[2]['votes'] = 1
-        self.poll.choices[3]['votes'] = 0
+        self.poll.choices[2]["votes"] = 1
+        self.poll.choices[3]["votes"] = 0
         self.poll.votes = 2
         self.poll.save()
 
@@ -188,12 +180,10 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         self.delete_user_votes()
 
         response = self.client.post(
-            self.api_link, '[]', content_type='application/json'
+            self.api_link, "[]", content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to make a choice.",
-        })
+        self.assertEqual(response.json(), {"detail": "You have to make a choice."})
 
     def test_empty_vote_form(self):
         """api validates if vote that user has made was empty"""
@@ -201,9 +191,7 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to make a choice.",
-        })
+        self.assertEqual(response.json(), {"detail": "You have to make a choice."})
 
     def test_malformed_vote(self):
         """api validates if vote that user has made was correctly structured"""
@@ -211,37 +199,37 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "dict".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "dict".'}
+        )
 
         response = self.post(self.api_link, data={})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "dict".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "dict".'}
+        )
 
-        response = self.post(self.api_link, data='hello')
+        response = self.post(self.api_link, data="hello")
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "str".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "str".'}
+        )
 
         response = self.post(self.api_link, data=123)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "int".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "int".'}
+        )
 
     def test_invalid_choices(self):
         """api validates if vote that user has made overlaps with allowed votes"""
         self.delete_user_votes()
 
-        response = self.post(self.api_link, data=['lorem', 'ipsum'])
+        response = self.post(self.api_link, data=["lorem", "ipsum"])
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "One or more of poll choices were invalid.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "One or more of poll choices were invalid."}
+        )
 
     def test_too_many_choices(self):
         """api validates if vote that user has made overlaps with allowed votes"""
@@ -249,27 +237,28 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         self.poll.allow_revotes = True
         self.poll.save()
 
-        response = self.post(self.api_link, data=['aaaaaaaaaaaa', 'bbbbbbbbbbbb'])
+        response = self.post(self.api_link, data=["aaaaaaaaaaaa", "bbbbbbbbbbbb"])
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "This poll disallows voting for more than 1 choice.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This poll disallows voting for more than 1 choice."},
+        )
 
     def test_revote(self):
         """api validates if user is trying to change vote in poll that disallows revoting"""
-        response = self.post(self.api_link, data=['lorem', 'ipsum'])
+        response = self.post(self.api_link, data=["lorem", "ipsum"])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You have already voted in this poll.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You have already voted in this poll."}
+        )
 
         self.delete_user_votes()
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "dict".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "dict".'}
+        )
 
     @patch_category_acl({"can_close_threads": False})
     def test_vote_in_closed_thread_no_permission(self):
@@ -281,9 +270,9 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This thread is closed. You can't vote in it.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This thread is closed. You can't vote in it."}
+        )
 
     @patch_category_acl({"can_close_threads": True})
     def test_vote_in_closed_thread(self):
@@ -306,9 +295,10 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't vote in it.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This category is closed. You can't vote in it."},
+        )
 
     @patch_category_acl({"can_close_threads": True})
     def test_vote_in_closed_category(self):
@@ -331,68 +321,72 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This poll is over. You can't vote in it.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This poll is over. You can't vote in it."}
+        )
 
         self.poll.length = 50
         self.poll.save()
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "dict".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "dict".'}
+        )
 
     def test_fresh_vote(self):
         """api handles first vote in poll"""
         self.delete_user_votes()
 
-        response = self.post(self.api_link, data=['aaaaaaaaaaaa', 'bbbbbbbbbbbb'])
+        response = self.post(self.api_link, data=["aaaaaaaaaaaa", "bbbbbbbbbbbb"])
         self.assertEqual(response.status_code, 200)
 
         # validate state change
         poll = Poll.objects.get(pk=self.poll.pk)
         self.assertEqual(poll.votes, 4)
-        self.assertEqual([c['votes'] for c in poll.choices], [2, 1, 1, 0])
+        self.assertEqual([c["votes"] for c in poll.choices], [2, 1, 1, 0])
 
         for choice in poll.choices:
-            self.assertNotIn('selected', choice)
+            self.assertNotIn("selected", choice)
 
         self.assertEqual(poll.pollvote_set.count(), 4)
 
         # validate response json
         response_json = response.json()
-        self.assertEqual(response_json['votes'], 4)
-        self.assertEqual([c['votes'] for c in response_json['choices']], [2, 1, 1, 0])
-        self.assertEqual([c['selected'] for c in response_json['choices']],
-                         [True, True, False, False])
+        self.assertEqual(response_json["votes"], 4)
+        self.assertEqual([c["votes"] for c in response_json["choices"]], [2, 1, 1, 0])
+        self.assertEqual(
+            [c["selected"] for c in response_json["choices"]],
+            [True, True, False, False],
+        )
 
-        self.assertFalse(response_json['acl']['can_vote'])
+        self.assertFalse(response_json["acl"]["can_vote"])
 
     def test_vote_change(self):
         """api handles vote change"""
         self.poll.allow_revotes = True
         self.poll.save()
 
-        response = self.post(self.api_link, data=['aaaaaaaaaaaa', 'bbbbbbbbbbbb'])
+        response = self.post(self.api_link, data=["aaaaaaaaaaaa", "bbbbbbbbbbbb"])
         self.assertEqual(response.status_code, 200)
 
         # validate state change
         poll = Poll.objects.get(pk=self.poll.pk)
         self.assertEqual(poll.votes, 4)
-        self.assertEqual([c['votes'] for c in poll.choices], [2, 1, 1, 0])
+        self.assertEqual([c["votes"] for c in poll.choices], [2, 1, 1, 0])
 
         for choice in poll.choices:
-            self.assertNotIn('selected', choice)
+            self.assertNotIn("selected", choice)
 
         self.assertEqual(poll.pollvote_set.count(), 4)
 
         # validate response json
         response_json = response.json()
-        self.assertEqual(response_json['votes'], 4)
-        self.assertEqual([c['votes'] for c in response_json['choices']], [2, 1, 1, 0])
-        self.assertEqual([c['selected'] for c in response_json['choices']],
-                         [True, True, False, False])
+        self.assertEqual(response_json["votes"], 4)
+        self.assertEqual([c["votes"] for c in response_json["choices"]], [2, 1, 1, 0])
+        self.assertEqual(
+            [c["selected"] for c in response_json["choices"]],
+            [True, True, False, False],
+        )
 
-        self.assertTrue(response_json['acl']['can_vote'])
+        self.assertTrue(response_json["acl"]["can_vote"])

+ 114 - 118
misago/threads/tests/test_thread_postbulkdelete_api.py

@@ -22,14 +22,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
         ]
 
         self.api_link = reverse(
-            'misago:api:thread-post-list',
-            kwargs={
-                'thread_pk': self.thread.pk,
-            }
+            "misago:api:thread-post-list", kwargs={"thread_pk": self.thread.pk}
         )
 
     def delete(self, url, data=None):
-        return self.client.delete(url, json.dumps(data), content_type="application/json")
+        return self.client.delete(
+            url, json.dumps(data), content_type="application/json"
+        )
 
     def test_delete_anonymous(self):
         """api validates if deleting user is authenticated"""
@@ -37,74 +36,78 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This action is not available to guests.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
 
     def test_delete_no_data(self):
         """api handles empty data"""
         response = self.client.delete(self.api_link, content_type="application/json")
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "dict".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "dict".'}
+        )
 
     def test_delete_no_ids(self):
         """api requires ids to delete"""
         response = self.delete(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to specify at least one post to delete.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You have to specify at least one post to delete."},
+        )
 
     def test_delete_empty_ids(self):
         """api requires ids to delete"""
         response = self.delete(self.api_link, [])
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to specify at least one post to delete.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You have to specify at least one post to delete."},
+        )
 
-    @patch_category_acl({'can_hide_posts': 2})
+    @patch_category_acl({"can_hide_posts": 2})
     def test_validate_ids(self):
         """api validates that ids are list of ints"""
         response = self.delete(self.api_link, True)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "bool".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "bool".'}
+        )
 
-        response = self.delete(self.api_link, 'abbss')
+        response = self.delete(self.api_link, "abbss")
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "str".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "str".'}
+        )
 
-        response = self.delete(self.api_link, [1, 2, 3, 'a', 'b', 'x'])
+        response = self.delete(self.api_link, [1, 2, 3, "a", "b", "x"])
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "One or more post ids received were invalid.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "One or more post ids received were invalid."}
+        )
 
-    @patch_category_acl({'can_hide_posts': 2})
+    @patch_category_acl({"can_hide_posts": 2})
     def test_validate_ids_length(self):
         """api validates that ids are list of ints"""
         response = self.delete(self.api_link, list(range(100)))
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "No more than 24 posts can be deleted at single time.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "No more than 24 posts can be deleted at single time."},
+        )
 
-    @patch_category_acl({'can_hide_posts': 2})
+    @patch_category_acl({"can_hide_posts": 2})
     def test_validate_posts_exist(self):
         """api validates that ids are visible posts"""
         response = self.delete(self.api_link, [p.id * 10 for p in self.posts])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "One or more posts to delete could not be found.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "One or more posts to delete could not be found."},
+        )
 
-    @patch_category_acl({'can_hide_posts': 2})
+    @patch_category_acl({"can_hide_posts": 2})
     def test_validate_posts_visibility(self):
         """api validates that ids are visible posts"""
         self.posts[1].is_unapproved = True
@@ -112,11 +115,12 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "One or more posts to delete could not be found.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "One or more posts to delete could not be found."},
+        )
 
-    @patch_category_acl({'can_hide_posts': 2})
+    @patch_category_acl({"can_hide_posts": 2})
     def test_validate_posts_same_thread(self):
         """api validates that ids are same thread posts"""
         other_thread = testutils.post_thread(category=self.category)
@@ -124,37 +128,35 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "One or more posts to delete could not be found.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "One or more posts to delete could not be found."},
+        )
 
-    @patch_category_acl({'can_hide_posts': 1, 'can_hide_own_posts': 1})
+    @patch_category_acl({"can_hide_posts": 1, "can_hide_own_posts": 1})
     def test_no_permission(self):
         """api validates permission to delete"""
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete posts in this category.",
-        })
-
-    @patch_category_acl({
-        'can_hide_posts': 0,
-        'can_hide_own_posts': 2,
-        'post_edit_time': 10,
-    })
+        self.assertEqual(
+            response.json(), {"detail": "You can't delete posts in this category."}
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 0, "can_hide_own_posts": 2, "post_edit_time": 10}
+    )
     def test_delete_other_user_post_no_permission(self):
         """api valdiates if user can delete other users posts"""
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete other users posts in this category.",
-        })
-
-    @patch_category_acl({
-        'can_hide_posts': 0,
-        'can_hide_own_posts': 2,
-        'can_protect_posts': False,
-    })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't delete other users posts in this category."},
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 0, "can_hide_own_posts": 2, "can_protect_posts": False}
+    )
     def test_delete_protected_post_no_permission(self):
         """api validates if user can delete protected post"""
         self.posts[0].is_protected = True
@@ -162,15 +164,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "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,
-    })
+        self.assertEqual(
+            response.json(), {"detail": "This post is protected. You can't delete it."}
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 0, "can_hide_own_posts": 2, "post_edit_time": 1}
+    )
     def test_delete_protected_post_after_edit_time(self):
         """api validates if user can delete delete post after edit time"""
         self.posts[0].posted_on = timezone.now() - timedelta(minutes=10)
@@ -178,15 +178,14 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete posts that are older than 1 minute.",
-        })
-
-    @patch_category_acl({
-        'can_hide_posts': 2,
-        'can_hide_own_posts': 2,
-        'can_close_threads': False,
-    })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't delete posts that are older than 1 minute."},
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 2, "can_hide_own_posts": 2, "can_close_threads": False}
+    )
     def test_delete_post_closed_thread_no_permission(self):
         """api valdiates if user can delete posts in closed threads"""
         self.thread.is_closed = True
@@ -194,15 +193,14 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "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,
-    })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This thread is closed. You can't delete posts in it."},
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 2, "can_hide_own_posts": 2, "can_close_threads": False}
+    )
     def test_delete_post_closed_category_no_permission(self):
         """api valdiates if user can delete posts in closed categories"""
         self.category.is_closed = True
@@ -210,11 +208,12 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't delete posts in it.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This category is closed. You can't delete posts in it."},
+        )
 
-    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 2})
+    @patch_category_acl({"can_hide_posts": 2, "can_hide_own_posts": 2})
     def test_delete_first_post(self):
         """api disallows first post's deletion"""
         ids = [p.id for p in self.posts]
@@ -222,11 +221,11 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link, ids)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete thread's first post.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't delete thread's first post."}
+        )
 
-    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 2})
+    @patch_category_acl({"can_hide_posts": 2, "can_hide_own_posts": 2})
     def test_delete_best_answer(self):
         """api disallows best answer deletion"""
         self.thread.set_best_answer(self.user, self.posts[0])
@@ -234,15 +233,14 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete this post because its marked as best answer.",
-        })
-
-    @patch_category_acl({
-        'can_hide_posts': 2, 
-        'can_hide_own_posts': 2, 
-        'can_hide_events': 0,
-    })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't delete this post because its marked as best answer."},
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 2, "can_hide_own_posts": 2, "can_hide_events": 0}
+    )
     def test_delete_event(self):
         """api differs posts from events"""
         self.posts[1].is_event = True
@@ -250,15 +248,13 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link, [p.id for p in self.posts])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete events in this category.",
-        })
-
-    @patch_category_acl({
-        'can_hide_posts': 0, 
-        'can_hide_own_posts': 2, 
-        'post_edit_time': 10,
-    })
+        self.assertEqual(
+            response.json(), {"detail": "You can't delete events in this category."}
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 0, "can_hide_own_posts": 2, "post_edit_time": 10}
+    )
     def test_delete_owned_posts(self):
         """api deletes owned thread posts"""
         ids = [self.posts[0].id, self.posts[-1].id]
@@ -271,7 +267,7 @@ class PostBulkDeleteApiTests(ThreadsApiTestCase):
             with self.assertRaises(Post.DoesNotExist):
                 self.thread.post_set.get(pk=post)
 
-    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 0})
+    @patch_category_acl({"can_hide_posts": 2, "can_hide_own_posts": 0})
     def test_delete_posts(self):
         """api deletes thread posts"""
         response = self.delete(self.api_link, [p.id for p in self.posts])

+ 93 - 137
misago/threads/tests/test_thread_postbulkpatch_api.py

@@ -15,7 +15,7 @@ class ThreadPostBulkPatchApiTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         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.posts = [
             testutils.reply_thread(self.thread, poster=self.user),
@@ -26,14 +26,13 @@ class ThreadPostBulkPatchApiTestCase(AuthenticatedUserTestCase):
         self.ids = [p.id for p in self.posts]
 
         self.api_link = reverse(
-            'misago:api:thread-post-list',
-            kwargs={
-                'thread_pk': self.thread.pk,
-            }
+            "misago:api:thread-post-list", kwargs={"thread_pk": self.thread.pk}
         )
 
     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"
+        )
 
 
 class BulkPatchSerializerTests(ThreadPostBulkPatchApiTestCase):
@@ -42,90 +41,76 @@ class BulkPatchSerializerTests(ThreadPostBulkPatchApiTestCase):
         response = self.patch(self.api_link, [1, 2, 3])
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'non_field_errors': [
-                "Invalid data. Expected a dictionary, but got list.",
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "non_field_errors": [
+                    "Invalid data. Expected a dictionary, but got list."
+                ]
+            },
+        )
 
     def test_missing_input_keys(self):
         """api rejects input with missing keys"""
         response = self.patch(self.api_link, {})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'ids': [
-                "This field is required.",
-            ],
-            'ops': [
-                "This field is required.",
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {"ids": ["This field is required."], "ops": ["This field is required."]},
+        )
 
     def test_empty_input_keys(self):
         """api rejects input with empty keys"""
-        response = self.patch(self.api_link, {
-            'ids': [],
-            'ops': [],
-        })
+        response = self.patch(self.api_link, {"ids": [], "ops": []})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'ids': [
-                "Ensure this field has at least 1 elements.",
-            ],
-            'ops': [
-                "Ensure this field has at least 1 elements.",
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "ids": ["Ensure this field has at least 1 elements."],
+                "ops": ["Ensure this field has at least 1 elements."],
+            },
+        )
 
     def test_invalid_input_keys(self):
         """api rejects input with invalid keys"""
-        response = self.patch(self.api_link, {
-            'ids': ['a'],
-            'ops': [1],
-        })
+        response = self.patch(self.api_link, {"ids": ["a"], "ops": [1]})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'ids': [
-                "A valid integer is required.",
-            ],
-            'ops': [
-                'Expected a dictionary of items but got type "int".',
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "ids": ["A valid integer is required."],
+                "ops": ['Expected a dictionary of items but got type "int".'],
+            },
+        )
 
     def test_too_small_id(self):
         """api rejects input with implausiple id"""
-        response = self.patch(self.api_link, {
-            'ids': [0],
-            'ops': [{}],
-        })
+        response = self.patch(self.api_link, {"ids": [0], "ops": [{}]})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'ids': [
-                "Ensure this value is greater than or equal to 1.",
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {"ids": ["Ensure this value is greater than or equal to 1."]},
+        )
 
     def test_too_large_input(self):
         """api rejects too large input"""
-        response = self.patch(self.api_link, {
-            'ids': [i + 1 for i in range(200)],
-            'ops': [{} for i in range(200)],
-        })
+        response = self.patch(
+            self.api_link,
+            {"ids": [i + 1 for i in range(200)], "ops": [{} for i in range(200)]},
+        )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'ids': [
-                "Ensure this field has no more than 24 elements.",
-            ],
-            'ops': [
-                "Ensure this field has no more than 10 elements.",
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "ids": ["Ensure this field has no more than 24 elements."],
+                "ops": ["Ensure this field has no more than 10 elements."],
+            },
+        )
 
     def test_posts_not_found(self):
         """api fails to find posts"""
@@ -134,36 +119,30 @@ class BulkPatchSerializerTests(ThreadPostBulkPatchApiTestCase):
             testutils.reply_thread(self.thread, is_unapproved=True),
         ]
 
-        response = self.patch(self.api_link, {
-            'ids': [p.id for p in posts],
-            'ops': [{}],
-        })
+        response = self.patch(
+            self.api_link, {"ids": [p.id for p in posts], "ops": [{}]}
+        )
 
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "One or more posts to update could not be found.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "One or more posts to update could not be found."},
+        )
 
     def test_ops_invalid(self):
         """api validates descriptions"""
-        response = self.patch(self.api_link, {
-            'ids': self.ids[:1],
-            'ops': [{}],
-        })
+        response = self.patch(self.api_link, {"ids": self.ids[:1], "ops": [{}]})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), [
-            {'id': self.ids[0], 'detail': ['undefined op']},
-        ])
+        self.assertEqual(
+            response.json(), [{"id": self.ids[0], "detail": ["undefined op"]}]
+        )
 
     def test_anonymous_user(self):
         """anonymous users can't use bulk actions"""
         self.logout_user()
 
-        response = self.patch(self.api_link, {
-            'ids': self.ids[:1],
-            'ops': [{}],
-        })
+        response = self.patch(self.api_link, {"ids": self.ids[:1], "ops": [{}]})
         self.assertEqual(response.status_code, 403)
 
     def test_events(self):
@@ -172,36 +151,28 @@ class BulkPatchSerializerTests(ThreadPostBulkPatchApiTestCase):
             post.is_event = True
             post.save()
 
-        response = self.patch(self.api_link, {
-            'ids': self.ids,
-            'ops': [{}],
-        })
+        response = self.patch(self.api_link, {"ids": self.ids, "ops": [{}]})
 
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "One or more posts to update could not be found.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "One or more posts to update could not be found."},
+        )
 
 
 class PostsAddAclApiTests(ThreadPostBulkPatchApiTestCase):
     def test_add_acl_true(self):
         """api adds posts acls to response"""
-        response = self.patch(self.api_link, {
-            'ids': self.ids,
-            'ops': [
-                {
-                    'op': 'add',
-                    'path': 'acl',
-                    'value': True,
-                },
-            ]
-        })
+        response = self.patch(
+            self.api_link,
+            {"ids": self.ids, "ops": [{"op": "add", "path": "acl", "value": True}]},
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         for i, post in enumerate(self.posts):
-            self.assertEqual(response_json[i]['id'], post.id)
-            self.assertTrue(response_json[i]['acl'])
+            self.assertEqual(response_json[i]["id"], post.id)
+            self.assertTrue(response_json[i]["acl"])
 
 
 class BulkPostProtectApiTests(ThreadPostBulkPatchApiTestCase):
@@ -209,23 +180,18 @@ class BulkPostProtectApiTests(ThreadPostBulkPatchApiTestCase):
     def test_protect_post(self):
         """api makes it possible to protect posts"""
         response = self.patch(
-            self.api_link, {
-                'ids': self.ids,
-                'ops': [
-                    {
-                        'op': 'replace',
-                        'path': 'is-protected',
-                        'value': True,
-                    },
-                ]
-            }
+            self.api_link,
+            {
+                "ids": self.ids,
+                "ops": [{"op": "replace", "path": "is-protected", "value": True}],
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         for i, post in enumerate(self.posts):
-            self.assertEqual(response_json[i]['id'], post.id)
-            self.assertTrue(response_json[i]['is_protected'])
+            self.assertEqual(response_json[i]["id"], post.id)
+            self.assertTrue(response_json[i]["is_protected"])
 
         for post in Post.objects.filter(id__in=self.ids):
             self.assertTrue(post.is_protected)
@@ -234,24 +200,19 @@ class BulkPostProtectApiTests(ThreadPostBulkPatchApiTestCase):
     def test_protect_post_no_permission(self):
         """api validates permission to protect posts and returns errors"""
         response = self.patch(
-            self.api_link, {
-                'ids': self.ids,
-                'ops': [
-                    {
-                        'op': 'replace',
-                        'path': 'is-protected',
-                        'value': True,
-                    },
-                ]
-            }
+            self.api_link,
+            {
+                "ids": self.ids,
+                "ops": [{"op": "replace", "path": "is-protected", "value": True}],
+            },
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         for i, post in enumerate(self.posts):
-            self.assertEqual(response_json[i]['id'], post.id)
+            self.assertEqual(response_json[i]["id"], post.id)
             self.assertEqual(
-                response_json[i]['detail'],
+                response_json[i]["detail"],
                 ["You can't protect posts in this category."],
             )
 
@@ -273,23 +234,18 @@ class BulkPostsApproveApiTests(ThreadPostBulkPatchApiTestCase):
         self.assertNotIn(self.thread.last_post_id, self.ids)
 
         response = self.patch(
-            self.api_link, {
-                'ids': self.ids,
-                'ops': [
-                    {
-                        'op': 'replace',
-                        'path': 'is-unapproved',
-                        'value': False,
-                    },
-                ]
-            }
+            self.api_link,
+            {
+                "ids": self.ids,
+                "ops": [{"op": "replace", "path": "is-unapproved", "value": False}],
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
         for i, post in enumerate(self.posts):
-            self.assertEqual(response_json[i]['id'], post.id)
-            self.assertFalse(response_json[i]['is_unapproved'])
+            self.assertEqual(response_json[i]["id"], post.id)
+            self.assertFalse(response_json[i]["is_unapproved"])
 
         for post in Post.objects.filter(id__in=self.ids):
             self.assertFalse(post.is_unapproved)

+ 111 - 117
misago/threads/tests/test_thread_postdelete_api.py

@@ -17,11 +17,8 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.post = testutils.reply_thread(self.thread, poster=self.user)
 
         self.api_link = reverse(
-            'misago:api:thread-post-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.post.pk,
-            }
+            "misago:api:thread-post-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.post.pk},
         )
 
     def test_delete_anonymous(self):
@@ -30,24 +27,22 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This action is not available to guests.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
 
-    @patch_category_acl({'can_hide_posts': 1, 'can_hide_own_posts': 1})
+    @patch_category_acl({"can_hide_posts": 1, "can_hide_own_posts": 1})
     def test_no_permission(self):
         """api validates permission to delete post"""
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete posts in this category.",
-        })
-
-    @patch_category_acl({
-        'can_hide_posts': 1, 
-        'can_hide_own_posts': 2,
-        'post_edit_time': 0,
-    })
+        self.assertEqual(
+            response.json(), {"detail": "You can't delete posts in this category."}
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 1, "can_hide_own_posts": 2, "post_edit_time": 0}
+    )
     def test_delete_other_user_post_no_permission(self):
         """api valdiates if user can delete other users posts"""
         self.post.poster = None
@@ -55,15 +50,14 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete other users posts in this category.",
-        })
-
-    @patch_category_acl({
-        'can_hide_posts': 1, 
-        'can_hide_own_posts': 2,
-        'post_edit_time': 0,
-    })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't delete other users posts in this category."},
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 1, "can_hide_own_posts": 2, "post_edit_time": 0}
+    )
     def test_delete_protected_post_no_permission(self):
         """api validates if user can delete protected post"""
         self.post.is_protected = True
@@ -71,15 +65,13 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "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,
-    })
+        self.assertEqual(
+            response.json(), {"detail": "This post is protected. You can't delete it."}
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 1, "can_hide_own_posts": 2, "post_edit_time": 1}
+    )
     def test_delete_protected_post_after_edit_time(self):
         """api validates if user can delete delete post after edit time"""
         self.post.posted_on = timezone.now() - timedelta(minutes=10)
@@ -87,16 +79,19 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "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,
-    })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't delete posts that are older than 1 minute."},
+        )
+
+    @patch_category_acl(
+        {
+            "can_hide_posts": 0,
+            "can_hide_own_posts": 2,
+            "post_edit_time": 0,
+            "can_close_threads": False,
+        }
+    )
     def test_delete_post_closed_thread_no_permission(self):
         """api valdiates if user can delete posts in closed threads"""
         self.thread.is_closed = True
@@ -104,16 +99,19 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This thread is closed. You can't delete posts in it.",
-        })
-
-    @patch_category_acl({
-        'can_hide_posts': 0, 
-        'can_hide_own_posts': 2,
-        'post_edit_time': 0,
-        'can_close_threads': False,
-    })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This thread is closed. You can't delete posts in it."},
+        )
+
+    @patch_category_acl(
+        {
+            "can_hide_posts": 0,
+            "can_hide_own_posts": 2,
+            "post_edit_time": 0,
+            "can_close_threads": False,
+        }
+    )
     def test_delete_post_closed_category_no_permission(self):
         """api valdiates if user can delete posts in closed categories"""
         self.category.is_closed = True
@@ -121,28 +119,26 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't delete posts in it.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This category is closed. You can't delete posts in it."},
+        )
 
-    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 2})
+    @patch_category_acl({"can_hide_posts": 2, "can_hide_own_posts": 2})
     def test_delete_first_post(self):
         """api disallows first post deletion"""
         api_link = reverse(
-            'misago:api:thread-post-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.thread.first_post_id,
-            }
+            "misago:api:thread-post-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.thread.first_post_id},
         )
 
         response = self.client.delete(api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete thread's first post.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't delete thread's first post."}
+        )
 
-    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 2})
+    @patch_category_acl({"can_hide_posts": 2, "can_hide_own_posts": 2})
     def test_delete_best_answer(self):
         """api disallows best answer deletion"""
         self.thread.set_best_answer(self.user, self.post)
@@ -150,15 +146,14 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            '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
-    })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't delete this post because its marked as best answer."},
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 0, "can_hide_own_posts": 2, "post_edit_time": 0}
+    )
     def test_delete_owned_post(self):
         """api deletes owned thread post"""
         response = self.client.delete(self.api_link)
@@ -170,7 +165,7 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         with self.assertRaises(Post.DoesNotExist):
             self.thread.post_set.get(pk=self.post.pk)
 
-    @patch_category_acl({'can_hide_posts': 2, 'can_hide_own_posts': 0})
+    @patch_category_acl({"can_hide_posts": 2, "can_hide_own_posts": 0})
     def test_delete_post(self):
         """api deletes thread post"""
         response = self.client.delete(self.api_link)
@@ -187,14 +182,13 @@ class EventDeleteApiTests(ThreadsApiTestCase):
     def setUp(self):
         super().setUp()
 
-        self.event = testutils.reply_thread(self.thread, poster=self.user, is_event=True)
+        self.event = testutils.reply_thread(
+            self.thread, poster=self.user, is_event=True
+        )
 
         self.api_link = reverse(
-            'misago:api:thread-post-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.event.pk,
-            }
+            "misago:api:thread-post-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.event.pk},
         )
 
     def test_delete_anonymous(self):
@@ -203,29 +197,29 @@ class EventDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This action is not available to guests.",
-        })
-
-    @patch_category_acl({
-        'can_hide_posts': 2,
-        'can_hide_own_posts': 0,
-        'can_hide_events': 0,
-    })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 2, "can_hide_own_posts": 0, "can_hide_events": 0}
+    )
     def test_no_permission(self):
         """api validates permission to delete event"""
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't delete events in this category.",
-        })
-
-    @patch_category_acl({
-        'can_hide_posts': 2,
-        'can_hide_own_posts': 0,
-        'can_hide_events': 2,
-        'can_close_threads': False,
-    })
+        self.assertEqual(
+            response.json(), {"detail": "You can't delete events in this category."}
+        )
+
+    @patch_category_acl(
+        {
+            "can_hide_posts": 2,
+            "can_hide_own_posts": 0,
+            "can_hide_events": 2,
+            "can_close_threads": False,
+        }
+    )
     def test_delete_event_closed_thread_no_permission(self):
         """api valdiates if user can delete events in closed threads"""
         self.thread.is_closed = True
@@ -233,15 +227,14 @@ class EventDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This thread is closed. You can't delete events in it.",
-        })
-
-    @patch_category_acl({
-        'can_hide_posts': 2,
-        'can_hide_events': 2,
-        'can_close_threads': False,
-    })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This thread is closed. You can't delete events in it."},
+        )
+
+    @patch_category_acl(
+        {"can_hide_posts": 2, "can_hide_events": 2, "can_close_threads": False}
+    )
     def test_delete_event_closed_category_no_permission(self):
         """api valdiates if user can delete events in closed categories"""
         self.category.is_closed = True
@@ -249,11 +242,12 @@ class EventDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't delete events in it.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This category is closed. You can't delete events in it."},
+        )
 
-    @patch_category_acl({'can_hide_posts': 0, 'can_hide_events': 2})
+    @patch_category_acl({"can_hide_posts": 0, "can_hide_events": 2})
     def test_delete_event(self):
         """api differs posts from events"""
         response = self.client.delete(self.api_link)

+ 34 - 37
misago/threads/tests/test_thread_postedits_api.py

@@ -13,11 +13,8 @@ class ThreadPostEditsApiTestCase(ThreadsApiTestCase):
         self.post = testutils.reply_thread(self.thread, poster=self.user)
 
         self.api_link = reverse(
-            'misago:api:thread-post-edits',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.post.pk,
-            }
+            "misago:api:thread-post-edits",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.post.pk},
         )
 
     def mock_edit_record(self):
@@ -34,8 +31,8 @@ class ThreadPostEditsApiTestCase(ThreadsApiTestCase):
             self.post.edits_record.create(
                 category=self.category,
                 thread=self.thread,
-                editor_name='Deleted',
-                editor_slug='deleted',
+                editor_name="Deleted",
+                editor_slug="deleted",
                 edited_from="First Edit",
                 edited_to="Second Edit",
             ),
@@ -50,8 +47,8 @@ class ThreadPostEditsApiTestCase(ThreadsApiTestCase):
             ),
         ]
 
-        self.post.original = 'Last Edit'
-        self.post.parsed = '<p>Last Edit</p>'
+        self.post.original = "Last Edit"
+        self.post.parsed = "<p>Last Edit</p>"
         self.post.save()
 
         return edits_record
@@ -62,31 +59,31 @@ class ThreadPostGetEditTests(ThreadPostEditsApiTestCase):
         """api returns 403 if post has no edits record"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "Edits record is unavailable for this post."
-        })
+        self.assertEqual(
+            response.json(), {"detail": "Edits record is unavailable for this post."}
+        )
 
         self.logout_user()
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "Edits record is unavailable for this post."
-        })
+        self.assertEqual(
+            response.json(), {"detail": "Edits record is unavailable for this post."}
+        )
 
     def test_empty_edit_id(self):
         """api handles empty edit in querystring"""
-        response = self.client.get('%s?edit=' % self.api_link)
+        response = self.client.get("%s?edit=" % self.api_link)
         self.assertEqual(response.status_code, 404)
 
     def test_invalid_edit_id(self):
         """api handles invalid edit in querystring"""
-        response = self.client.get('%s?edit=dsa67d8sa68' % self.api_link)
+        response = self.client.get("%s?edit=dsa67d8sa68" % self.api_link)
         self.assertEqual(response.status_code, 404)
 
     def test_nonexistant_edit_id(self):
         """api handles nonexistant edit in querystring"""
-        response = self.client.get('%s?edit=1321' % self.api_link)
+        response = self.client.get("%s?edit=1321" % self.api_link)
         self.assertEqual(response.status_code, 404)
 
     def test_get_last_edit(self):
@@ -98,35 +95,35 @@ class ThreadPostGetEditTests(ThreadPostEditsApiTestCase):
 
         response_json = response.json()
 
-        self.assertEqual(response_json['id'], edits[-1].id)
-        self.assertIsNone(response_json['next'])
-        self.assertEqual(response_json['previous'], edits[1].id)
+        self.assertEqual(response_json["id"], edits[-1].id)
+        self.assertIsNone(response_json["next"])
+        self.assertEqual(response_json["previous"], edits[1].id)
 
     def test_get_middle_edit(self):
         """api returns middle edit record"""
         edits = self.mock_edit_record()
 
-        response = self.client.get('%s?edit=%s' % (self.api_link, edits[1].id))
+        response = self.client.get("%s?edit=%s" % (self.api_link, edits[1].id))
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
 
-        self.assertEqual(response_json['id'], edits[1].id)
-        self.assertEqual(response_json['next'], edits[-1].id)
-        self.assertEqual(response_json['previous'], edits[0].id)
+        self.assertEqual(response_json["id"], edits[1].id)
+        self.assertEqual(response_json["next"], edits[-1].id)
+        self.assertEqual(response_json["previous"], edits[0].id)
 
     def test_get_first_edit(self):
         """api returns middle edit record"""
         edits = self.mock_edit_record()
 
-        response = self.client.get('%s?edit=%s' % (self.api_link, edits[0].id))
+        response = self.client.get("%s?edit=%s" % (self.api_link, edits[0].id))
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
 
-        self.assertEqual(response_json['id'], edits[0].id)
-        self.assertEqual(response_json['next'], edits[1].id)
-        self.assertIsNone(response_json['previous'])
+        self.assertEqual(response_json["id"], edits[0].id)
+        self.assertEqual(response_json["next"], edits[1].id)
+        self.assertIsNone(response_json["previous"])
 
 
 class ThreadPostPostEditTests(ThreadPostEditsApiTestCase):
@@ -137,43 +134,43 @@ class ThreadPostPostEditTests(ThreadPostEditsApiTestCase):
     @patch_category_acl({"can_edit_posts": 2})
     def test_empty_edit_id(self):
         """api handles empty edit in querystring"""
-        response = self.client.post('%s?edit=' % self.api_link)
+        response = self.client.post("%s?edit=" % self.api_link)
         self.assertEqual(response.status_code, 404)
 
     @patch_category_acl({"can_edit_posts": 2})
     def test_invalid_edit_id(self):
         """api handles invalid edit in querystring"""
-        response = self.client.post('%s?edit=dsa67d8sa68' % self.api_link)
+        response = self.client.post("%s?edit=dsa67d8sa68" % self.api_link)
         self.assertEqual(response.status_code, 404)
 
     @patch_category_acl({"can_edit_posts": 2})
     def test_nonexistant_edit_id(self):
         """api handles nonexistant edit in querystring"""
-        response = self.client.post('%s?edit=1321' % self.api_link)
+        response = self.client.post("%s?edit=1321" % self.api_link)
         self.assertEqual(response.status_code, 404)
 
     def test_anonymous(self):
         """only signed in users can rever ports"""
         self.logout_user()
 
-        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)
 
     @patch_category_acl({"can_edit_posts": 0})
     def test_no_permission(self):
         """api validates permission to revert post"""
-        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)
 
     @patch_category_acl({"can_edit_posts": 2})
     def test_revert_post(self):
         """api reverts post to version from before specified edit"""
-        response = self.client.post('%s?edit=%s' % (self.api_link, self.edits[0].id))
+        response = self.client.post("%s?edit=%s" % (self.api_link, self.edits[0].id))
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['edits'], 1)
-        self.assertEqual(response_json['content'], "<p>Original body</p>")
+        self.assertEqual(response_json["edits"], 1)
+        self.assertEqual(response_json["content"], "<p>Original body</p>")
 
         self.assertEqual(self.post.edits_record.count(), 4)
 

+ 54 - 47
misago/threads/tests/test_thread_postlikes_api.py

@@ -14,11 +14,8 @@ class ThreadPostLikesApiTestCase(ThreadsApiTestCase):
         self.post = testutils.reply_thread(self.thread, poster=self.user)
 
         self.api_link = reverse(
-            'misago:api:thread-post-likes',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.post.pk,
-            }
+            "misago:api:thread-post-likes",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.post.pk},
         )
 
     @patch_category_acl({"can_see_posts_likes": 0})
@@ -26,18 +23,18 @@ class ThreadPostLikesApiTestCase(ThreadsApiTestCase):
         """api errors if user has no permission to see likes"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEquals(response.json(), {
-            "detail": "You can't see who liked this post."
-        })
+        self.assertEquals(
+            response.json(), {"detail": "You can't see who liked this post."}
+        )
 
     @patch_category_acl({"can_see_posts_likes": 1})
     def test_no_permission_to_list(self):
         """api errors if user has no permission to see likes, but can see likes count"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEquals(response.json(), {
-            "detail": "You can't see who liked this post."
-        })
+        self.assertEquals(
+            response.json(), {"detail": "You can't see who liked this post."}
+        )
 
     @patch_category_acl({"can_see_posts_likes": 2})
     def test_no_likes(self):
@@ -55,24 +52,29 @@ class ThreadPostLikesApiTestCase(ThreadsApiTestCase):
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            response.json(), [
-                PostLikeSerializer({
-                    'id': other_like.id,
-                    'liked_on': other_like.liked_on,
-                    'liker_id': other_like.liker_id,
-                    'liker_name': other_like.liker_name,
-                    'liker_slug': other_like.liker_slug,
-                    'liker__avatars': self.user.avatars,
-                }).data,
-                PostLikeSerializer({
-                    'id': like.id,
-                    'liked_on': like.liked_on,
-                    'liker_id': like.liker_id,
-                    'liker_name': like.liker_name,
-                    'liker_slug': like.liker_slug,
-                    'liker__avatars': self.user.avatars,
-                }).data,
-            ]
+            response.json(),
+            [
+                PostLikeSerializer(
+                    {
+                        "id": other_like.id,
+                        "liked_on": other_like.liked_on,
+                        "liker_id": other_like.liker_id,
+                        "liker_name": other_like.liker_name,
+                        "liker_slug": other_like.liker_slug,
+                        "liker__avatars": self.user.avatars,
+                    }
+                ).data,
+                PostLikeSerializer(
+                    {
+                        "id": like.id,
+                        "liked_on": like.liked_on,
+                        "liker_id": like.liker_id,
+                        "liker_name": like.liker_name,
+                        "liker_slug": like.liker_slug,
+                        "liker__avatars": self.user.avatars,
+                    }
+                ).data,
+            ],
         )
 
         # api has no showstoppers for likes by deleted users
@@ -85,22 +87,27 @@ class ThreadPostLikesApiTestCase(ThreadsApiTestCase):
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            response.json(), [
-                PostLikeSerializer({
-                    'id': other_like.id,
-                    'liked_on': other_like.liked_on,
-                    'liker_id': other_like.liker_id,
-                    'liker_name': other_like.liker_name,
-                    'liker_slug': other_like.liker_slug,
-                    'liker__avatars': None,
-                }).data,
-                PostLikeSerializer({
-                    'id': like.id,
-                    'liked_on': like.liked_on,
-                    'liker_id': like.liker_id,
-                    'liker_name': like.liker_name,
-                    'liker_slug': like.liker_slug,
-                    'liker__avatars': None,
-                }).data,
-            ]
+            response.json(),
+            [
+                PostLikeSerializer(
+                    {
+                        "id": other_like.id,
+                        "liked_on": other_like.liked_on,
+                        "liker_id": other_like.liker_id,
+                        "liker_name": other_like.liker_name,
+                        "liker_slug": other_like.liker_slug,
+                        "liker__avatars": None,
+                    }
+                ).data,
+                PostLikeSerializer(
+                    {
+                        "id": like.id,
+                        "liked_on": like.liked_on,
+                        "liker_id": like.liker_id,
+                        "liker_name": like.liker_name,
+                        "liker_slug": like.liker_slug,
+                        "liker__avatars": None,
+                    }
+                ).data,
+            ],
         )

+ 242 - 220
misago/threads/tests/test_thread_postmerge_api.py

@@ -15,14 +15,12 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         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.post = testutils.reply_thread(self.thread, poster=self.user)
 
         self.api_link = reverse(
-            'misago:api:thread-post-merge', kwargs={
-                'thread_pk': self.thread.pk,
-            }
+            "misago:api:thread-post-merge", kwargs={"thread_pk": self.thread.pk}
         )
 
     def test_anonymous_user(self):
@@ -30,27 +28,23 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         self.logout_user()
 
         response = self.client.post(
-            self.api_link,
-            json.dumps({}),
-            content_type="application/json",
+            self.api_link, json.dumps({}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This action is not available to guests.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
 
     @patch_category_acl({"can_merge_posts": False})
     def test_no_permission(self):
         """api validates permission to merge"""
         response = self.client.post(
-            self.api_link,
-            json.dumps({}),
-            content_type="application/json",
+            self.api_link, json.dumps({}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't merge posts in this thread.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't merge posts in this thread."}
+        )
 
     @patch_category_acl({"can_merge_posts": True})
     def test_empty_data_json(self):
@@ -59,120 +53,126 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             self.api_link, json.dumps({}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to select at least two posts to merge.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You have to select at least two posts to merge."},
+        )
 
     @patch_category_acl({"can_merge_posts": True})
     def test_empty_data_form(self):
         """api handles empty form data"""
         response = self.client.post(self.api_link, {})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to select at least two posts to merge.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You have to select at least two posts to merge."},
+        )
 
     @patch_category_acl({"can_merge_posts": True})
     def test_invalid_data(self):
         """api handles post that is invalid type"""
-        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.json(), {
-            "detail": "Invalid data. Expected a dictionary, but got list.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Invalid data. Expected a dictionary, but got list."},
+        )
 
-        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.json(), {
-            "detail": "Invalid data. Expected a dictionary, but got int.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Invalid data. Expected a dictionary, but got int."},
+        )
 
-        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.json(), {
-            "detail": "Invalid data. Expected a dictionary, but got str.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Invalid data. Expected a dictionary, but got str."},
+        )
 
-        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.json(), {
-            "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)"},
+        )
 
     @patch_category_acl({"can_merge_posts": True})
     def test_no_posts_ids(self):
         """api rejects no posts ids"""
         response = self.client.post(
-            self.api_link,
-            json.dumps({
-                'posts': []
-            }),
-            content_type="application/json",
+            self.api_link, json.dumps({"posts": []}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to select at least two posts to merge.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You have to select at least two posts to merge."},
+        )
 
     @patch_category_acl({"can_merge_posts": True})
     def test_invalid_posts_data(self):
         """api handles invalid data"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': 'string'
-            }),
+            json.dumps({"posts": "string"}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "str".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "str".'}
+        )
 
     @patch_category_acl({"can_merge_posts": True})
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [1, 2, 'string']
-            }),
+            json.dumps({"posts": [1, 2, "string"]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "One or more post ids received were invalid.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "One or more post ids received were invalid."}
+        )
 
     @patch_category_acl({"can_merge_posts": True})
     def test_one_post_id(self):
         """api rejects one post id"""
         response = self.client.post(
-            self.api_link,
-            json.dumps({
-                'posts': [1]
-            }),
-            content_type="application/json",
+            self.api_link, json.dumps({"posts": [1]}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to select at least two posts to merge.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You have to select at least two posts to merge."},
+        )
 
     @patch_category_acl({"can_merge_posts": True})
     def test_merge_limit(self):
         """api rejects more posts than merge limit"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': list(range(POSTS_LIMIT + 1))
-            }),
+            json.dumps({"posts": list(range(POSTS_LIMIT + 1))}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "No more than %s posts can be merged at single time." % POSTS_LIMIT,
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "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):
@@ -181,30 +181,25 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [self.post.pk, event.pk]
-            }),
+            json.dumps({"posts": [self.post.pk, event.pk]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Events can't be merged.",
-        })
+        self.assertEqual(response.json(), {"detail": "Events can't be merged."})
 
     @patch_category_acl({"can_merge_posts": True})
     def test_merge_notfound_pk(self):
         """api recjects nonexistant pk's"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [self.post.pk, self.post.pk * 1000]
-            }),
+            json.dumps({"posts": [self.post.pk, self.post.pk * 1000]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "One or more posts to merge could not be found.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "One or more posts to merge could not be found."},
+        )
 
     @patch_category_acl({"can_merge_posts": True})
     def test_merge_cross_threads(self):
@@ -214,15 +209,14 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [self.post.pk, other_post.pk]
-            }),
+            json.dumps({"posts": [self.post.pk, other_post.pk]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "One or more posts to merge could not be found.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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):
@@ -231,15 +225,14 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [self.post.pk, other_post.pk]
-            }),
+            json.dumps({"posts": [self.post.pk, other_post.pk]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Posts made by different users can't be merged.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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):
@@ -248,69 +241,85 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [other_post.pk, self.post.pk]
-            }),
+            json.dumps({"posts": [other_post.pk, self.post.pk]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Posts made by different users can't be merged.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Posts made by different users can't be merged."},
+        )
 
     @patch_category_acl({"can_merge_posts": True})
     def test_merge_guest_posts_different_usernames(self):
         """api recjects attempt to merge posts made by different guests"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [
-                    testutils.reply_thread(self.thread, poster="Bob").pk,
-                    testutils.reply_thread(self.thread, poster="Miku").pk,
-                ]
-            }),
+            json.dumps(
+                {
+                    "posts": [
+                        testutils.reply_thread(self.thread, poster="Bob").pk,
+                        testutils.reply_thread(self.thread, poster="Miku").pk,
+                    ]
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Posts made by different users can't be merged.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Posts made by different users can't be merged."},
+        )
 
     @patch_category_acl({"can_merge_posts": True, "can_hide_posts": 1})
     def test_merge_different_visibility(self):
         """api recjects attempt to merge posts with different visibility"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [
-                    testutils.reply_thread(self.thread, poster=self.user, is_hidden=True).pk,
-                    testutils.reply_thread(self.thread, poster=self.user, is_hidden=False).pk,
-                ]
-            }),
+            json.dumps(
+                {
+                    "posts": [
+                        testutils.reply_thread(
+                            self.thread, poster=self.user, is_hidden=True
+                        ).pk,
+                        testutils.reply_thread(
+                            self.thread, poster=self.user, is_hidden=False
+                        ).pk,
+                    ]
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Posts with different visibility can't be merged.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Posts with different visibility can't be merged."},
+        )
 
     @patch_category_acl({"can_merge_posts": True, "can_approve_content": True})
     def test_merge_different_approval(self):
         """api recjects attempt to merge posts with different approval"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [
-                    testutils.reply_thread(self.thread, poster=self.user, is_unapproved=True).pk,
-                    testutils.reply_thread(self.thread, poster=self.user, is_unapproved=False).pk,
-                ]
-            }),
+            json.dumps(
+                {
+                    "posts": [
+                        testutils.reply_thread(
+                            self.thread, poster=self.user, is_unapproved=True
+                        ).pk,
+                        testutils.reply_thread(
+                            self.thread, poster=self.user, is_unapproved=False
+                        ).pk,
+                    ]
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Posts with different visibility can't be merged.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Posts with different visibility can't be merged."},
+        )
 
     @patch_category_acl({"can_merge_posts": True, "can_close_threads": False})
     def test_closed_thread_no_permission(self):
@@ -324,14 +333,13 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         ]
 
         response = self.client.post(
-            self.api_link,
-            json.dumps({'posts': posts}),
-            content_type="application/json",
+            self.api_link, json.dumps({"posts": posts}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "This thread is closed. You can't merge posts in it.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This thread is closed. You can't merge posts in it."},
+        )
 
     @patch_category_acl({"can_merge_posts": True, "can_close_threads": True})
     def test_closed_thread(self):
@@ -345,9 +353,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         ]
 
         response = self.client.post(
-            self.api_link,
-            json.dumps({'posts': posts}),
-            content_type="application/json",
+            self.api_link, json.dumps({"posts": posts}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 200)
 
@@ -363,14 +369,13 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         ]
 
         response = self.client.post(
-            self.api_link,
-            json.dumps({'posts': posts}),
-            content_type="application/json",
+            self.api_link, json.dumps({"posts": posts}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't merge posts in it.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This category is closed. You can't merge posts in it."},
+        )
 
     @patch_category_acl({"can_merge_posts": True, "can_close_threads": True})
     def test_closed_category(self):
@@ -384,9 +389,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         ]
 
         response = self.client.post(
-            self.api_link,
-            json.dumps({'posts': posts}),
-            content_type="application/json",
+            self.api_link, json.dumps({"posts": posts}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 200)
 
@@ -401,35 +404,33 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
 
         self.thread.set_best_answer(self.user, self.post)
         self.thread.save()
-         
+
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [
-                    self.thread.first_post.pk,
-                    self.post.pk,
-                ]
-            }),
+            json.dumps({"posts": [self.thread.first_post.pk, self.post.pk]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Post marked as best answer can't be merged with thread's first post.",
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "Post marked as best answer can't be merged with thread's first post."
+            },
+        )
 
     @patch_category_acl({"can_merge_posts": True})
     def test_merge_posts(self):
         """api merges two posts"""
-        post_a = testutils.reply_thread(self.thread, poster=self.user, message="Battęry")
+        post_a = testutils.reply_thread(
+            self.thread, poster=self.user, message="Battęry"
+        )
         post_b = testutils.reply_thread(self.thread, poster=self.user, message="Hórse")
 
         thread_replies = self.thread.replies
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [post_a.pk, post_b.pk]
-            }),
+            json.dumps({"posts": [post_a.pk, post_b.pk]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -441,67 +442,81 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             Post.objects.get(pk=post_b.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):
         """api recjects attempt to merge posts made by same guest"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [
-                    testutils.reply_thread(self.thread, poster="Bob").pk,
-                    testutils.reply_thread(self.thread, poster="Bob").pk,
-                ]
-            }),
+            json.dumps(
+                {
+                    "posts": [
+                        testutils.reply_thread(self.thread, poster="Bob").pk,
+                        testutils.reply_thread(self.thread, poster="Bob").pk,
+                    ]
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({"can_merge_posts": True, 'can_hide_posts': 1})
+    @patch_category_acl({"can_merge_posts": True, "can_hide_posts": 1})
     def test_merge_hidden_posts(self):
         """api merges two hidden posts"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [
-                    testutils.reply_thread(self.thread, poster=self.user, is_hidden=True).pk,
-                    testutils.reply_thread(self.thread, poster=self.user, is_hidden=True).pk,
-                ]
-            }),
+            json.dumps(
+                {
+                    "posts": [
+                        testutils.reply_thread(
+                            self.thread, poster=self.user, is_hidden=True
+                        ).pk,
+                        testutils.reply_thread(
+                            self.thread, poster=self.user, is_hidden=True
+                        ).pk,
+                    ]
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({"can_merge_posts": True, 'can_approve_content': True})
+    @patch_category_acl({"can_merge_posts": True, "can_approve_content": True})
     def test_merge_unapproved_posts(self):
         """api merges two unapproved posts"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [
-                    testutils.reply_thread(self.thread, poster=self.user, is_unapproved=True).pk,
-                    testutils.reply_thread(self.thread, poster=self.user, is_unapproved=True).pk,
-                ]
-            }),
+            json.dumps(
+                {
+                    "posts": [
+                        testutils.reply_thread(
+                            self.thread, poster=self.user, is_unapproved=True
+                        ).pk,
+                        testutils.reply_thread(
+                            self.thread, poster=self.user, is_unapproved=True
+                        ).pk,
+                    ]
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({"can_merge_posts": True, 'can_hide_threads': True})
+    @patch_category_acl({"can_merge_posts": True, "can_hide_threads": True})
     def test_merge_with_hidden_thread(self):
         """api excludes thread's first post from visibility checks"""
         self.thread.first_post.is_hidden = True
         self.thread.first_post.poster = self.user
         self.thread.first_post.save()
 
-        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
+        )
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [self.thread.first_post.pk, post_visible.pk]
-            }),
+            json.dumps({"posts": [self.thread.first_post.pk, post_visible.pk]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -511,17 +526,23 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         """api preserves protected status after merge"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [
-                    testutils.reply_thread(self.thread, poster="Bob", is_protected=True).pk,
-                    testutils.reply_thread(self.thread, poster="Bob", is_protected=False).pk,
-                ]
-            }),
+            json.dumps(
+                {
+                    "posts": [
+                        testutils.reply_thread(
+                            self.thread, poster="Bob", is_protected=True
+                        ).pk,
+                        testutils.reply_thread(
+                            self.thread, poster="Bob", is_protected=False
+                        ).pk,
+                    ]
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
-        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)
 
     @patch_category_acl({"can_merge_posts": True})
@@ -531,15 +552,17 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
 
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-         
+
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [
-                    best_answer.pk,
-                    testutils.reply_thread(self.thread, poster="Bob").pk,
-                ]
-            }),
+            json.dumps(
+                {
+                    "posts": [
+                        best_answer.pk,
+                        testutils.reply_thread(self.thread, poster="Bob").pk,
+                    ]
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -555,15 +578,10 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
 
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-         
+
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [
-                    best_answer.pk,
-                    other_post.pk,
-                ]
-            }),
+            json.dumps({"posts": [best_answer.pk, other_post.pk]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -575,18 +593,22 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
     def test_merge_best_answer_in_protected(self):
         """api merges best answer into protected post"""
         best_answer = testutils.reply_thread(self.thread, poster="Bob")
-        
+
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-         
+
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [
-                    best_answer.pk,
-                    testutils.reply_thread(self.thread, poster="Bob", is_protected=True).pk,
-                ]
-            }),
+            json.dumps(
+                {
+                    "posts": [
+                        best_answer.pk,
+                        testutils.reply_thread(
+                            self.thread, poster="Bob", is_protected=True
+                        ).pk,
+                    ]
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -601,7 +623,9 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
     @patch_category_acl({"can_merge_posts": True})
     def test_merge_remove_reads(self):
         """two posts merge removes read tracker from post"""
-        post_a = testutils.reply_thread(self.thread, poster=self.user, message="Battęry")
+        post_a = testutils.reply_thread(
+            self.thread, poster=self.user, message="Battęry"
+        )
         post_b = testutils.reply_thread(self.thread, poster=self.user, message="Hórse")
 
         poststracker.save_read(self.user, post_a)
@@ -609,9 +633,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [post_a.pk, post_b.pk]
-            }),
+            json.dumps({"posts": [post_a.pk, post_b.pk]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)

+ 210 - 184
misago/threads/tests/test_thread_postmove_api.py

@@ -15,103 +15,109 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         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.api_link = reverse(
-            'misago:api:thread-post-move', kwargs={
-                'thread_pk': self.thread.pk,
-            }
+            "misago:api:thread-post-move", kwargs={"thread_pk": self.thread.pk}
         )
 
-        Category(
-            name='Other category',
-            slug='other-category',
-        ).insert_at(
-            self.category,
-            position='last-child',
-            save=True,
+        Category(name="Other category", slug="other-category").insert_at(
+            self.category, position="last-child", save=True
         )
-        self.other_category = Category.objects.get(slug='other-category')
+        self.other_category = Category.objects.get(slug="other-category")
 
     def test_anonymous_user(self):
         """you need to authenticate to move posts"""
         self.logout_user()
 
-        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.json(), {
-            "detail": "This action is not available to guests.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_invalid_data(self):
         """api handles post that is invalid type"""
-        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.json(), {
-            "detail": "Invalid data. Expected a dictionary, but got list.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Invalid data. Expected a dictionary, but got list."},
+        )
 
-        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.json(), {
-            "detail": "Invalid data. Expected a dictionary, but got int.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Invalid data. Expected a dictionary, but got int."},
+        )
 
-        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.json(), {
-            "detail": "Invalid data. Expected a dictionary, but got str.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Invalid data. Expected a dictionary, but got str."},
+        )
 
-        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.json(), {
-            "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)"},
+        )
 
     @patch_category_acl({"can_move_posts": False})
     def test_no_permission(self):
         """api validates permission to move"""
-        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.json(), {
-            "detail": "You can't move posts in this thread.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't move posts in this thread."}
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_move_no_new_thread_url(self):
         """api validates if new thread url was given"""
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Enter link to new thread.",
-        })
+        self.assertEqual(response.json(), {"detail": "Enter link to new thread."})
 
     @patch_category_acl({"can_move_posts": True})
     def test_invalid_new_thread_url(self):
         """api validates new thread url"""
-        response = self.client.post(self.api_link, {
-            'new_thread': self.user.get_absolute_url(),
-        })
+        response = self.client.post(
+            self.api_link, {"new_thread": self.user.get_absolute_url()}
+        )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "This is not a valid thread link.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This is not a valid thread link."}
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_current_new_thread_url(self):
         """api validates if new thread url points to current thread"""
         response = self.client.post(
-            self.api_link, {
-                'new_thread': self.thread.get_absolute_url(),
-            }
+            self.api_link, {"new_thread": self.thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Thread to move posts to is same as current one.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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})
@@ -119,16 +125,19 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         """api validates if other thread exists"""
         other_thread = testutils.post_thread(self.other_category)
 
-        response = self.client.post(self.api_link, {
-            'new_thread': other_thread.get_absolute_url(),
-        })
+        response = self.client.post(
+            self.api_link, {"new_thread": other_thread.get_absolute_url()}
+        )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": (
-                "The thread you have entered link to doesn't exist "
-                "or you don't have permission to see it."
-            ),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": (
+                    "The thread you have entered link to doesn't exist "
+                    "or you don't have permission to see it."
+                )
+            },
+        )
 
     @patch_other_category_acl({"can_browse": False})
     @patch_category_acl({"can_move_posts": True})
@@ -137,17 +146,18 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(
-            self.api_link, {
-                'new_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"new_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": (
-                "The thread you have entered link to doesn't exist "
-                "or you don't have permission to see it."
-            ),
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": (
+                    "The thread you have entered link to doesn't exist "
+                    "or you don't have permission to see it."
+                )
+            },
+        )
 
     @patch_other_category_acl({"can_reply_threads": False})
     @patch_category_acl({"can_move_posts": True})
@@ -156,14 +166,13 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         other_thread = testutils.post_thread(self.other_category)
 
         response = self.client.post(
-            self.api_link, {
-                'new_thread': other_thread.get_absolute_url(),
-            }
+            self.api_link, {"new_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You can't move posts to threads you can't reply.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't move posts to threads you can't reply."},
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_empty_data(self):
@@ -172,9 +181,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Enter link to new thread.",
-        })
+        self.assertEqual(response.json(), {"detail": "Enter link to new thread."})
 
     @patch_category_acl({"can_move_posts": True})
     def test_empty_posts_data_json(self):
@@ -183,15 +190,14 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-            }),
+            json.dumps({"new_thread": other_thread.get_absolute_url()}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to specify at least one post to move.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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):
@@ -199,15 +205,13 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         other_thread = testutils.post_thread(self.category)
 
         response = self.client.post(
-            self.api_link,
-            {
-                'new_thread': other_thread.get_absolute_url(),
-            },
+            self.api_link, {"new_thread": other_thread.get_absolute_url()}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to specify at least one post to move.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You have to specify at least one post to move."},
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_no_posts_ids(self):
@@ -216,16 +220,14 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': [],
-            }),
+            json.dumps({"new_thread": other_thread.get_absolute_url(), "posts": []}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to specify at least one post to move.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You have to specify at least one post to move."},
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_invalid_posts_data(self):
@@ -234,16 +236,15 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': 'string',
-            }),
+            json.dumps(
+                {"new_thread": other_thread.get_absolute_url(), "posts": "string"}
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "str".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "str".'}
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_invalid_posts_ids(self):
@@ -252,16 +253,18 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': [1, 2, 'string'],
-            }),
+            json.dumps(
+                {
+                    "new_thread": other_thread.get_absolute_url(),
+                    "posts": [1, 2, "string"],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "One or more post ids received were invalid.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "One or more post ids received were invalid."}
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_move_limit(self):
@@ -270,16 +273,22 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': list(range(POSTS_LIMIT + 1)),
-            }),
+            json.dumps(
+                {
+                    "new_thread": other_thread.get_absolute_url(),
+                    "posts": list(range(POSTS_LIMIT + 1)),
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "No more than %s posts can be moved at single time." % POSTS_LIMIT,
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "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):
@@ -288,16 +297,20 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': [testutils.reply_thread(self.thread, is_unapproved=True).pk],
-            }),
+            json.dumps(
+                {
+                    "new_thread": other_thread.get_absolute_url(),
+                    "posts": [
+                        testutils.reply_thread(self.thread, is_unapproved=True).pk
+                    ],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "One or more posts to move could not be found.",
-        })
+        self.assertEqual(
+            response.json(), {"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):
@@ -306,16 +319,18 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': [testutils.reply_thread(other_thread, is_hidden=True).pk],
-            }),
+            json.dumps(
+                {
+                    "new_thread": other_thread.get_absolute_url(),
+                    "posts": [testutils.reply_thread(other_thread, is_hidden=True).pk],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "One or more posts to move could not be found.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "One or more posts to move could not be found."}
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_move_event(self):
@@ -324,16 +339,16 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': [testutils.reply_thread(self.thread, is_event=True).pk],
-            }),
+            json.dumps(
+                {
+                    "new_thread": other_thread.get_absolute_url(),
+                    "posts": [testutils.reply_thread(self.thread, is_event=True).pk],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Events can't be moved.",
-        })
+        self.assertEqual(response.json(), {"detail": "Events can't be moved."})
 
     @patch_category_acl({"can_move_posts": True})
     def test_move_first_post(self):
@@ -342,16 +357,18 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': [self.thread.first_post_id],
-            }),
+            json.dumps(
+                {
+                    "new_thread": other_thread.get_absolute_url(),
+                    "posts": [self.thread.first_post_id],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You can't move thread's first post.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't move thread's first post."}
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_move_hidden_posts(self):
@@ -360,16 +377,19 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': [testutils.reply_thread(self.thread, is_hidden=True).pk],
-            }),
+            json.dumps(
+                {
+                    "new_thread": other_thread.get_absolute_url(),
+                    "posts": [testutils.reply_thread(self.thread, is_hidden=True).pk],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You can't move posts the content you can't see.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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):
@@ -381,16 +401,19 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': [testutils.reply_thread(self.thread).pk],
-            }),
+            json.dumps(
+                {
+                    "new_thread": other_thread.get_absolute_url(),
+                    "posts": [testutils.reply_thread(self.thread).pk],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "This thread is closed. You can't move posts in it.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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})
@@ -403,16 +426,19 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': [testutils.reply_thread(self.thread).pk],
-            }),
+            json.dumps(
+                {
+                    "new_thread": other_thread.get_absolute_url(),
+                    "posts": [testutils.reply_thread(self.thread).pk],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't move posts in it.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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})
@@ -432,10 +458,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': posts,
-            }),
+            json.dumps({"new_thread": other_thread.get_absolute_url(), "posts": posts}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -465,10 +488,12 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': [best_answer.pk],
-            }),
+            json.dumps(
+                {
+                    "new_thread": other_thread.get_absolute_url(),
+                    "posts": [best_answer.pk],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -482,7 +507,6 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(other_thread.replies, 1)
         self.assertIsNone(other_thread.best_answer)
 
-
     @patch_other_category_acl({"can_reply_threads": True})
     @patch_category_acl({"can_move_posts": True})
     def test_move_posts_reads(self):
@@ -503,10 +527,12 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'new_thread': other_thread.get_absolute_url(),
-                'posts': [p.pk for p in posts],
-            }),
+            json.dumps(
+                {
+                    "new_thread": other_thread.get_absolute_url(),
+                    "posts": [p.pk for p in posts],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -514,10 +540,10 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         other_thread = Thread.objects.get(pk=other_thread.pk)
 
         # postreads were removed
-        postreads = self.user.postread_set.order_by('id')
+        postreads = self.user.postread_set.order_by("id")
 
-        postreads_threads = list(postreads.values_list('thread_id', flat=True))
+        postreads_threads = list(postreads.values_list("thread_id", flat=True))
         self.assertEqual(postreads_threads, [self.thread.pk])
 
-        postreads_categories = list(postreads.values_list('category_id', flat=True))
+        postreads_categories = list(postreads.values_list("category_id", flat=True))
         self.assertEqual(postreads_categories, [self.category.pk])

+ 253 - 550
misago/threads/tests/test_thread_postpatch_api.py

@@ -15,97 +15,76 @@ class ThreadPostPatchApiTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         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.post = testutils.reply_thread(self.thread, poster=self.user)
 
         self.api_link = reverse(
-            'misago:api:thread-post-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.post.pk,
-            }
+            "misago:api:thread-post-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.post.pk},
         )
 
     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"
+        )
 
 
 class PostAddAclApiTests(ThreadPostPatchApiTestCase):
     def test_add_acl_true(self):
         """api adds current event's acl to response"""
-        response = self.patch(self.api_link, [
-            {
-                'op': 'add',
-                'path': 'acl',
-                'value': True,
-            },
-        ])
+        response = self.patch(
+            self.api_link, [{"op": "add", "path": "acl", "value": True}]
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertTrue(response_json['acl'])
+        self.assertTrue(response_json["acl"])
 
     def test_add_acl_false(self):
         """if value is false, api won't add acl to the response, but will set empty key"""
-        response = self.patch(self.api_link, [
-            {
-                'op': 'add',
-                'path': 'acl',
-                'value': False,
-            },
-        ])
+        response = self.patch(
+            self.api_link, [{"op": "add", "path": "acl", "value": False}]
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertIsNone(response_json['acl'])
+        self.assertIsNone(response_json["acl"])
 
 
 class PostProtectApiTests(ThreadPostPatchApiTestCase):
-    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': True})
+    @patch_category_acl({"can_edit_posts": 2, "can_protect_posts": True})
     def test_protect_post(self):
         """api makes it possible to protect post"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-protected',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-protected", "value": True}]
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertTrue(reponse_json['is_protected'])
+        self.assertTrue(reponse_json["is_protected"])
 
         self.post.refresh_from_db()
         self.assertTrue(self.post.is_protected)
 
-    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': True})
+    @patch_category_acl({"can_edit_posts": 2, "can_protect_posts": True})
     def test_unprotect_post(self):
         """api makes it possible to unprotect protected post"""
         self.post.is_protected = True
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-protected',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-protected", "value": False}]
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertFalse(reponse_json['is_protected'])
+        self.assertFalse(reponse_json["is_protected"])
 
         self.post.refresh_from_db()
         self.assertFalse(self.post.is_protected)
 
-    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': True})
+    @patch_category_acl({"can_edit_posts": 2, "can_protect_posts": True})
     def test_protect_best_answer(self):
         """api makes it possible to protect post"""
         self.thread.set_best_answer(self.user, self.post)
@@ -114,18 +93,12 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         self.assertFalse(self.thread.best_answer_is_protected)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-protected',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-protected", "value": True}]
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertTrue(reponse_json['is_protected'])
+        self.assertTrue(reponse_json["is_protected"])
 
         self.post.refresh_from_db()
         self.assertTrue(self.post.is_protected)
@@ -133,7 +106,7 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         self.thread.refresh_from_db()
         self.assertTrue(self.thread.best_answer_is_protected)
 
-    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': True})
+    @patch_category_acl({"can_edit_posts": 2, "can_protect_posts": True})
     def test_unprotect_best_answer(self):
         """api makes it possible to unprotect protected post"""
         self.post.is_protected = True
@@ -145,18 +118,12 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         self.assertTrue(self.thread.best_answer_is_protected)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-protected',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-protected", "value": False}]
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertFalse(reponse_json['is_protected'])
+        self.assertFalse(reponse_json["is_protected"])
 
         self.post.refresh_from_db()
         self.assertFalse(self.post.is_protected)
@@ -164,161 +131,131 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         self.thread.refresh_from_db()
         self.assertFalse(self.thread.best_answer_is_protected)
 
-    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': False})
+    @patch_category_acl({"can_edit_posts": 2, "can_protect_posts": False})
     def test_protect_post_no_permission(self):
         """api validates permission to protect post"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-protected',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-protected", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         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.post.refresh_from_db()
         self.assertFalse(self.post.is_protected)
 
-    @patch_category_acl({'can_edit_posts': 2, 'can_protect_posts': False})
+    @patch_category_acl({"can_edit_posts": 2, "can_protect_posts": False})
     def test_unprotect_post_no_permission(self):
         """api validates permission to unprotect post"""
         self.post.is_protected = True
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-protected',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-protected", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         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.post.refresh_from_db()
         self.assertTrue(self.post.is_protected)
 
-    @patch_category_acl({'can_edit_posts': 0, 'can_protect_posts': True})
+    @patch_category_acl({"can_edit_posts": 0, "can_protect_posts": True})
     def test_protect_post_not_editable(self):
         """api validates if we can edit post we want to protect"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-protected',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-protected", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         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.post.refresh_from_db()
         self.assertFalse(self.post.is_protected)
 
-    @patch_category_acl({'can_edit_posts': 0, 'can_protect_posts': True})
+    @patch_category_acl({"can_edit_posts": 0, "can_protect_posts": True})
     def test_unprotect_post_not_editable(self):
         """api validates if we can edit post we want to protect"""
         self.post.is_protected = True
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-protected',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-protected", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         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.post.refresh_from_db()
         self.assertTrue(self.post.is_protected)
 
 
 class PostApproveApiTests(ThreadPostPatchApiTestCase):
-    @patch_category_acl({'can_approve_content': True})
+    @patch_category_acl({"can_approve_content": True})
     def test_approve_post(self):
         """api makes it possible to approve post"""
         self.post.is_unapproved = True
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-unapproved',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-unapproved", "value": False}]
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertFalse(reponse_json['is_unapproved'])
+        self.assertFalse(reponse_json["is_unapproved"])
 
         self.post.refresh_from_db()
         self.assertFalse(self.post.is_unapproved)
 
-    @patch_category_acl({'can_approve_content': True})
+    @patch_category_acl({"can_approve_content": True})
     def test_unapprove_post(self):
         """unapproving posts is not supported by api"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-unapproved',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-unapproved", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         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.post.refresh_from_db()
         self.assertFalse(self.post.is_unapproved)
 
-    @patch_category_acl({'can_approve_content': False})
+    @patch_category_acl({"can_approve_content": False})
     def test_approve_post_no_permission(self):
         """api validates approval permission"""
         self.post.is_unapproved = True
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-unapproved',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-unapproved", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         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.post.refresh_from_db()
         self.assertTrue(self.post.is_unapproved)
 
-    @patch_category_acl({'can_approve_content': True, 'can_close_threads': False})
+    @patch_category_acl({"can_approve_content": True, "can_close_threads": False})
     def test_approve_post_closed_thread_no_permission(self):
         """api validates approval permission in closed threads"""
         self.post.is_unapproved = True
@@ -328,26 +265,20 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-unapproved',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-unapproved", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0],
+            response_json["detail"][0],
             "This thread is closed. You can't approve posts in it.",
         )
 
         self.post.refresh_from_db()
         self.assertTrue(self.post.is_unapproved)
 
-    @patch_category_acl({'can_approve_content': True, 'can_close_threads': False})
+    @patch_category_acl({"can_approve_content": True, "can_close_threads": False})
     def test_approve_post_closed_category_no_permission(self):
         """api validates approval permission in closed categories"""
         self.post.is_unapproved = True
@@ -357,26 +288,20 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-unapproved',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-unapproved", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json['detail'][0],
+            response_json["detail"][0],
             "This category is closed. You can't approve posts in it.",
         )
 
         self.post.refresh_from_db()
         self.assertTrue(self.post.is_unapproved)
 
-    @patch_category_acl({'can_approve_content': True})
+    @patch_category_acl({"can_approve_content": True})
     def test_approve_first_post(self):
         """api approve first post fails"""
         self.post.is_unapproved = True
@@ -386,23 +311,19 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-unapproved',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-unapproved", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         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.post.refresh_from_db()
         self.assertTrue(self.post.is_unapproved)
 
-    @patch_category_acl({'can_approve_content': True})
+    @patch_category_acl({"can_approve_content": True})
     def test_approve_hidden_post(self):
         """api approve hidden post fails"""
         self.post.is_unapproved = True
@@ -410,19 +331,14 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-unapproved',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-unapproved", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.post.refresh_from_db()
@@ -430,233 +346,188 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
 
 
 class PostHideApiTests(ThreadPostPatchApiTestCase):
-    @patch_category_acl({'can_hide_posts': 1})
+    @patch_category_acl({"can_hide_posts": 1})
     def test_hide_post(self):
         """api makes it possible to hide post"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertTrue(reponse_json['is_hidden'])
+        self.assertTrue(reponse_json["is_hidden"])
 
         self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
-    @patch_category_acl({'can_hide_posts': 1})
+    @patch_category_acl({"can_hide_posts": 1})
     def test_hide_own_post(self):
         """api makes it possible to hide owned post"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertTrue(reponse_json['is_hidden'])
+        self.assertTrue(reponse_json["is_hidden"])
 
         self.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
-    @patch_category_acl({'can_hide_posts': 0})
+    @patch_category_acl({"can_hide_posts": 0})
     def test_hide_post_no_permission(self):
         """api hide post with no permission fails"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         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.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
-    @patch_category_acl({'can_hide_own_posts': 1, 'can_protect_posts': False})
+    @patch_category_acl({"can_hide_own_posts": 1, "can_protect_posts": False})
     def test_hide_own_protected_post(self):
         """api validates if we are trying to hide protected post"""
         self.post.is_protected = True
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         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.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
-    @patch_category_acl({'can_hide_own_posts': True})
+    @patch_category_acl({"can_hide_own_posts": True})
     def test_hide_other_user_post(self):
         """api validates post ownership when hiding"""
         self.post.poster = None
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
-    @patch_category_acl({'post_edit_time': 1, 'can_hide_own_posts': True})
+    @patch_category_acl({"post_edit_time": 1, "can_hide_own_posts": True})
     def test_hide_own_post_after_edit_time(self):
         """api validates if we are trying to hide post after edit time"""
         self.post.posted_on = timezone.now() - timedelta(minutes=10)
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
-    @patch_category_acl({'can_close_threads': False, 'can_hide_own_posts': True})
+    @patch_category_acl({"can_close_threads": False, "can_hide_own_posts": True})
     def test_hide_post_in_closed_thread(self):
         """api validates if we are trying to hide post in closed thread"""
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
-    @patch_category_acl({'can_close_threads': False, 'can_hide_own_posts': True})
+    @patch_category_acl({"can_close_threads": False, "can_hide_own_posts": True})
     def test_hide_post_in_closed_category(self):
         """api validates if we are trying to hide post in closed category"""
         self.category.is_closed = True
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
-    @patch_category_acl({'can_hide_posts': 1})
+    @patch_category_acl({"can_hide_posts": 1})
     def test_hide_first_post(self):
         """api hide first post fails"""
         self.thread.set_first_post(self.post)
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         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})
+    @patch_category_acl({"can_hide_posts": 1})
     def test_hide_best_answer(self):
         """api hide first post fails"""
         self.thread.set_best_answer(self.user, self.post)
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'id': self.post.id,
-            'detail': ["You can't hide this post because its marked as best answer."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "id": self.post.id,
+                "detail": [
+                    "You can't hide this post because its marked as best answer."
+                ],
+            },
+        )
 
 
 class PostUnhideApiTests(ThreadPostPatchApiTestCase):
-    @patch_category_acl({'can_hide_posts': 1})
+    @patch_category_acl({"can_hide_posts": 1})
     def test_show_post(self):
         """api makes it possible to unhide post"""
         self.post.is_hidden = True
@@ -666,23 +537,17 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         self.assertTrue(self.post.is_hidden)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertFalse(reponse_json['is_hidden'])
+        self.assertFalse(reponse_json["is_hidden"])
 
         self.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
-    @patch_category_acl({'can_hide_own_posts': 1})
+    @patch_category_acl({"can_hide_own_posts": 1})
     def test_show_own_post(self):
         """api makes it possible to unhide owned post"""
         self.post.is_hidden = True
@@ -692,23 +557,17 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         self.assertTrue(self.post.is_hidden)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 200)
 
         reponse_json = response.json()
-        self.assertFalse(reponse_json['is_hidden'])
+        self.assertFalse(reponse_json["is_hidden"])
 
         self.post.refresh_from_db()
         self.assertFalse(self.post.is_hidden)
 
-    @patch_category_acl({'can_hide_posts': 0})
+    @patch_category_acl({"can_hide_posts": 0})
     def test_show_post_no_permission(self):
         """api unhide post with no permission fails"""
         self.post.is_hidden = True
@@ -718,23 +577,19 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         self.assertTrue(self.post.is_hidden)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         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.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
-    @patch_category_acl({'can_protect_posts': 0, 'can_hide_own_posts': 1})
+    @patch_category_acl({"can_protect_posts": 0, "can_hide_own_posts": 1})
     def test_show_own_protected_post(self):
         """api validates if we are trying to reveal protected post"""
         self.post.is_hidden = True
@@ -744,25 +599,19 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
-    @patch_category_acl({'can_hide_own_posts': 1})
+    @patch_category_acl({"can_hide_own_posts": 1})
     def test_show_other_user_post(self):
         """api validates post ownership when revealing"""
         self.post.is_hidden = True
@@ -770,25 +619,20 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
-    @patch_category_acl({'post_edit_time': 1, 'can_hide_own_posts': 1})
+    @patch_category_acl({"post_edit_time": 1, "can_hide_own_posts": 1})
     def test_show_own_post_after_edit_time(self):
         """api validates if we are trying to reveal post after edit time"""
         self.post.is_hidden = True
@@ -796,25 +640,20 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
-    @patch_category_acl({'can_close_threads': False, 'can_hide_own_posts': 1})
+    @patch_category_acl({"can_close_threads": False, "can_hide_own_posts": 1})
     def test_show_post_in_closed_thread(self):
         """api validates if we are trying to reveal post in closed thread"""
         self.thread.is_closed = True
@@ -824,25 +663,20 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
-    @patch_category_acl({'can_close_threads': False, 'can_hide_own_posts': 1})
+    @patch_category_acl({"can_close_threads": False, "can_hide_own_posts": 1})
     def test_show_post_in_closed_category(self):
         """api validates if we are trying to reveal post in closed category"""
         self.category.is_closed = True
@@ -852,243 +686,172 @@ class PostUnhideApiTests(ThreadPostPatchApiTestCase):
         self.post.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.post.refresh_from_db()
         self.assertTrue(self.post.is_hidden)
 
-    @patch_category_acl({'can_hide_posts': 1})
+    @patch_category_acl({"can_hide_posts": 1})
     def test_show_first_post(self):
         """api unhide first post fails"""
         self.thread.set_first_post(self.post)
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
-        self.assertEqual(response_json['detail'][0], "You can't reveal thread's first post.")
+        self.assertEqual(
+            response_json["detail"][0], "You can't reveal thread's first post."
+        )
 
 
 class PostLikeApiTests(ThreadPostPatchApiTestCase):
-    @patch_category_acl({'can_see_posts_likes': 0})
+    @patch_category_acl({"can_see_posts_likes": 0})
     def test_like_no_see_permission(self):
         """api validates user's permission to see posts likes"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-liked',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-liked", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "id": self.post.id,
-            "detail": ["You can't like posts in this category."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.post.id, "detail": ["You can't like posts in this category."]},
+        )
 
-    @patch_category_acl({'can_like_posts': False})
+    @patch_category_acl({"can_like_posts": False})
     def test_like_no_like_permission(self):
         """api validates user's permission to see posts likes"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-liked',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-liked", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "id": self.post.id,
-            "detail": ["You can't like posts in this category."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"id": self.post.id, "detail": ["You can't like posts in this category."]},
+        )
 
     def test_like_post(self):
         """api adds user like to post"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-liked',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-liked", "value": True}]
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['likes'], 1)
-        self.assertEqual(response_json['is_liked'], True)
+        self.assertEqual(response_json["likes"], 1)
+        self.assertEqual(response_json["is_liked"], True)
         self.assertEqual(
-            response_json['last_likes'], [
-                {
-                    'id': self.user.id,
-                    'username': self.user.username,
-                },
-            ]
+            response_json["last_likes"],
+            [{"id": self.user.id, "username": self.user.username}],
         )
 
         post = Post.objects.get(pk=self.post.pk)
-        self.assertEqual(post.likes, response_json['likes'])
-        self.assertEqual(post.last_likes, response_json['last_likes'])
+        self.assertEqual(post.likes, response_json["likes"])
+        self.assertEqual(post.last_likes, response_json["last_likes"])
 
     def test_like_liked_post(self):
         """api adds user like to post"""
-        testutils.like_post(self.post, username='Myo')
-        testutils.like_post(self.post, username='Mugi')
-        testutils.like_post(self.post, username='Bob')
-        testutils.like_post(self.post, username='Miku')
+        testutils.like_post(self.post, username="Myo")
+        testutils.like_post(self.post, username="Mugi")
+        testutils.like_post(self.post, username="Bob")
+        testutils.like_post(self.post, username="Miku")
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-liked',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-liked", "value": True}]
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['likes'], 5)
-        self.assertEqual(response_json['is_liked'], True)
+        self.assertEqual(response_json["likes"], 5)
+        self.assertEqual(response_json["is_liked"], True)
         self.assertEqual(
-            response_json['last_likes'], [
-                {
-                    'id': self.user.id,
-                    'username': self.user.username
-                },
-                {
-                    'id': None,
-                    'username': 'Miku',
-                },
-                {
-                    'id': None,
-                    'username': 'Bob',
-                },
-                {
-                    'id': None,
-                    'username': 'Mugi',
-                },
-            ]
+            response_json["last_likes"],
+            [
+                {"id": self.user.id, "username": self.user.username},
+                {"id": None, "username": "Miku"},
+                {"id": None, "username": "Bob"},
+                {"id": None, "username": "Mugi"},
+            ],
         )
 
         post = Post.objects.get(pk=self.post.pk)
-        self.assertEqual(post.likes, response_json['likes'])
-        self.assertEqual(post.last_likes, response_json['last_likes'])
+        self.assertEqual(post.likes, response_json["likes"])
+        self.assertEqual(post.last_likes, response_json["last_likes"])
 
     def test_unlike_post(self):
         """api removes user like from post"""
         testutils.like_post(self.post, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-liked',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-liked", "value": False}]
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['likes'], 0)
-        self.assertEqual(response_json['is_liked'], False)
-        self.assertEqual(response_json['last_likes'], [])
+        self.assertEqual(response_json["likes"], 0)
+        self.assertEqual(response_json["is_liked"], False)
+        self.assertEqual(response_json["last_likes"], [])
 
         post = Post.objects.get(pk=self.post.pk)
-        self.assertEqual(post.likes, response_json['likes'])
-        self.assertEqual(post.last_likes, response_json['last_likes'])
+        self.assertEqual(post.likes, response_json["likes"])
+        self.assertEqual(post.last_likes, response_json["last_likes"])
 
     def test_like_post_no_change(self):
         """api does no state change if we are linking liked post"""
         testutils.like_post(self.post, self.user)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-liked',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-liked", "value": True}]
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['likes'], 1)
-        self.assertEqual(response_json['is_liked'], True)
+        self.assertEqual(response_json["likes"], 1)
+        self.assertEqual(response_json["is_liked"], True)
         self.assertEqual(
-            response_json['last_likes'], [
-                {
-                    'id': self.user.id,
-                    'username': self.user.username,
-                },
-            ]
+            response_json["last_likes"],
+            [{"id": self.user.id, "username": self.user.username}],
         )
 
         post = Post.objects.get(pk=self.post.pk)
-        self.assertEqual(post.likes, response_json['likes'])
-        self.assertEqual(post.last_likes, response_json['last_likes'])
+        self.assertEqual(post.likes, response_json["likes"])
+        self.assertEqual(post.last_likes, response_json["last_likes"])
 
     def test_unlike_post_no_change(self):
         """api does no state change if we are unlinking unliked post"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-liked',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-liked", "value": False}]
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['likes'], 0)
-        self.assertEqual(response_json['is_liked'], False)
-        self.assertEqual(response_json['last_likes'], [])
+        self.assertEqual(response_json["likes"], 0)
+        self.assertEqual(response_json["is_liked"], False)
+        self.assertEqual(response_json["last_likes"], [])
 
 
 class ThreadEventPatchApiTestCase(ThreadPostPatchApiTestCase):
     def setUp(self):
         super().setUp()
 
-        self.event = testutils.reply_thread(self.thread, poster=self.user, is_event=True)
+        self.event = testutils.reply_thread(
+            self.thread, poster=self.user, is_event=True
+        )
 
         self.api_link = reverse(
-            'misago:api:thread-post-detail',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.event.pk,
-            }
+            "misago:api:thread-post-detail",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.event.pk},
         )
 
     def refresh_event(self):
@@ -1100,74 +863,52 @@ class EventAnonPatchApiTests(ThreadEventPatchApiTestCase):
         """anonymous users can't change event state"""
         self.logout_user()
 
-        response = self.patch(self.api_link, [
-            {
-                'op': 'add',
-                'path': 'acl',
-                'value': True,
-            },
-        ])
+        response = self.patch(
+            self.api_link, [{"op": "add", "path": "acl", "value": True}]
+        )
         self.assertEqual(response.status_code, 403)
 
 
 class EventAddAclApiTests(ThreadEventPatchApiTestCase):
     def test_add_acl_true(self):
         """api adds current event's acl to response"""
-        response = self.patch(self.api_link, [
-            {
-                'op': 'add',
-                'path': 'acl',
-                'value': True,
-            },
-        ])
+        response = self.patch(
+            self.api_link, [{"op": "add", "path": "acl", "value": True}]
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertTrue(response_json['acl'])
+        self.assertTrue(response_json["acl"])
 
     def test_add_acl_false(self):
         """if value is false, api won't add acl to the response, but will set empty key"""
-        response = self.patch(self.api_link, [
-            {
-                'op': 'add',
-                'path': 'acl',
-                'value': False,
-            },
-        ])
+        response = self.patch(
+            self.api_link, [{"op": "add", "path": "acl", "value": False}]
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertIsNone(response_json['acl'])
+        self.assertIsNone(response_json["acl"])
 
-        response = self.patch(self.api_link, [
-            {
-                'op': 'add',
-                'path': 'acl',
-                'value': True,
-            },
-        ])
+        response = self.patch(
+            self.api_link, [{"op": "add", "path": "acl", "value": True}]
+        )
         self.assertEqual(response.status_code, 200)
 
 
 class EventHideApiTests(ThreadEventPatchApiTestCase):
-    @patch_category_acl({'can_hide_events': 1})
+    @patch_category_acl({"can_hide_events": 1})
     def test_hide_event(self):
         """api makes it possible to hide event"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 200)
 
         self.event.refresh_from_db()
         self.assertTrue(self.event.is_hidden)
 
-    @patch_category_acl({'can_hide_events': 1})
+    @patch_category_acl({"can_hide_events": 1})
     def test_show_event(self):
         """api makes it possible to unhide event"""
         self.event.is_hidden = True
@@ -1177,92 +918,70 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         self.assertTrue(self.event.is_hidden)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 200)
 
         self.event.refresh_from_db()
         self.assertFalse(self.event.is_hidden)
 
-    @patch_category_acl({'can_hide_events': 0})
+    @patch_category_acl({"can_hide_events": 0})
     def test_hide_event_no_permission(self):
         """api hide event with no permission fails"""
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.event.refresh_from_db()
         self.assertFalse(self.event.is_hidden)
 
-    @patch_category_acl({'can_close_threads': False, 'can_hide_events': 1})
+    @patch_category_acl({"can_close_threads": False, "can_hide_events": 1})
     def test_hide_event_closed_thread_no_permission(self):
         """api hide event in closed thread with no permission fails"""
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.event.refresh_from_db()
         self.assertFalse(self.event.is_hidden)
 
-    @patch_category_acl({'can_close_threads': False, 'can_hide_events': 1})
+    @patch_category_acl({"can_close_threads": False, "can_hide_events": 1})
     def test_hide_event_closed_category_no_permission(self):
         """api hide event in closed category with no permission fails"""
         self.category.is_closed = True
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': True,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": True}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.event.refresh_from_db()
         self.assertFalse(self.event.is_hidden)
 
-    @patch_category_acl({'can_hide_events': 0})
+    @patch_category_acl({"can_hide_events": 0})
     def test_show_event_no_permission(self):
         """api unhide event with no permission fails"""
         self.event.is_hidden = True
@@ -1272,17 +991,11 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         self.assertTrue(self.event.is_hidden)
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 404)
 
-    @patch_category_acl({'can_close_threads': False, 'can_hide_events': 1})
+    @patch_category_acl({"can_close_threads": False, "can_hide_events": 1})
     def test_show_event_closed_thread_no_permission(self):
         """api show event in closed thread with no permission fails"""
         self.event.is_hidden = True
@@ -1292,25 +1005,20 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         self.thread.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.event.refresh_from_db()
         self.assertTrue(self.event.is_hidden)
 
-    @patch_category_acl({'can_close_threads': False, 'can_hide_events': 1})
+    @patch_category_acl({"can_close_threads": False, "can_hide_events": 1})
     def test_show_event_closed_category_no_permission(self):
         """api show event in closed category with no permission fails"""
         self.event.is_hidden = True
@@ -1320,19 +1028,14 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         self.category.save()
 
         response = self.patch(
-            self.api_link, [
-                {
-                    'op': 'replace',
-                    'path': 'is-hidden',
-                    'value': False,
-                },
-            ]
+            self.api_link, [{"op": "replace", "path": "is-hidden", "value": False}]
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            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.event.refresh_from_db()

+ 16 - 22
misago/threads/tests/test_thread_postread_api.py

@@ -11,17 +11,12 @@ class PostReadApiTests(ThreadsApiTestCase):
         super().setUp()
 
         self.post = testutils.reply_thread(
-            self.thread,
-            poster=self.user,
-            posted_on=timezone.now(),
+            self.thread, poster=self.user, posted_on=timezone.now()
         )
 
         self.api_link = reverse(
-            'misago:api:thread-post-read',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.post.pk,
-            }
+            "misago:api:thread-post-read",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.post.pk},
         )
 
     def test_read_anonymous(self):
@@ -30,9 +25,9 @@ class PostReadApiTests(ThreadsApiTestCase):
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This action is not available to guests."
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
 
     def test_read_post(self):
         """api marks post as read"""
@@ -43,23 +38,22 @@ class PostReadApiTests(ThreadsApiTestCase):
         self.user.postread_set.get(post=self.post)
 
         # one post read, first post is still unread
-        self.assertFalse(response.json()['thread_is_read'])
+        self.assertFalse(response.json()["thread_is_read"])
 
         # read second post
-        response = self.client.post(reverse(
-            'misago:api:thread-post-read',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.thread.first_post.pk,
-            }
-        ))
+        response = self.client.post(
+            reverse(
+                "misago:api:thread-post-read",
+                kwargs={"thread_pk": self.thread.pk, "pk": self.thread.first_post.pk},
+            )
+        )
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(self.user.postread_set.count(), 2)
         self.user.postread_set.get(post=self.thread.first_post)
 
         # both posts are read
-        self.assertTrue(response.json()['thread_is_read'])
+        self.assertTrue(response.json()["thread_is_read"])
 
     def test_read_subscribed_thread_post(self):
         """api marks post as read and updates subscription"""
@@ -67,11 +61,11 @@ class PostReadApiTests(ThreadsApiTestCase):
             user=self.user,
             thread=self.thread,
             category=self.thread.category,
-            last_read_on=self.thread.post_set.order_by('id').first().posted_on,
+            last_read_on=self.thread.post_set.order_by("id").first().posted_on,
         )
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        subscription = self.thread.subscription_set.order_by('id').last()
+        subscription = self.thread.subscription_set.order_by("id").last()
         self.assertEqual(subscription.last_read_on, self.post.posted_on)

+ 311 - 259
misago/threads/tests/test_thread_postsplit_api.py

@@ -15,7 +15,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         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.posts = [
             testutils.reply_thread(self.thread).pk,
@@ -23,208 +23,214 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         ]
 
         self.api_link = reverse(
-            'misago:api:thread-post-split', kwargs={
-                'thread_pk': self.thread.pk,
-            }
+            "misago:api:thread-post-split", kwargs={"thread_pk": self.thread.pk}
         )
 
-        Category(
-            name='Other category',
-            slug='other-category',
-        ).insert_at(
-            self.category,
-            position='last-child',
-            save=True,
+        Category(name="Other category", slug="other-category").insert_at(
+            self.category, position="last-child", save=True
         )
-        self.other_category = Category.objects.get(slug='other-category')
+        self.other_category = Category.objects.get(slug="other-category")
 
     def test_anonymous_user(self):
         """you need to authenticate to split posts"""
         self.logout_user()
 
-        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.json(), {
-            "detail": "This action is not available to guests.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
 
     @patch_category_acl({"can_move_posts": False})
     def test_no_permission(self):
         """api validates permission to split"""
-        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.json(), {
-            "detail": "You can't split posts from this thread.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't split posts from this thread."}
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_empty_data(self):
         """api handles empty data"""
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to specify at least one post to split.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You have to specify at least one post to split."},
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_invalid_data(self):
         """api handles post that is invalid type"""
-        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.json(), {
-            "non_field_errors": ["Invalid data. Expected a dictionary, but got list."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "non_field_errors": [
+                    "Invalid data. Expected a dictionary, but got list."
+                ]
+            },
+        )
 
-        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.json(), {
-            "non_field_errors": ["Invalid data. Expected a dictionary, but got int."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"non_field_errors": ["Invalid data. Expected a dictionary, but got int."]},
+        )
 
-        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.json(), {
-            "non_field_errors": ["Invalid data. Expected a dictionary, but got str."],
-        })
+        self.assertEqual(
+            response.json(),
+            {"non_field_errors": ["Invalid data. Expected a dictionary, but got str."]},
+        )
 
-        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.json(), {
-            "detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "JSON parse error - Expecting value: line 1 column 1 (char 0)"},
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_no_posts_ids(self):
         """api rejects no posts ids"""
         response = self.client.post(
-            self.api_link,
-            json.dumps({}),
-            content_type="application/json",
+            self.api_link, json.dumps({}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to specify at least one post to split.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You have to specify at least one post to split."},
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_empty_posts_ids(self):
         """api rejects empty posts ids list"""
         response = self.client.post(
-            self.api_link,
-            json.dumps({
-                'posts': [],
-            }),
-            content_type="application/json",
+            self.api_link, json.dumps({"posts": []}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You have to specify at least one post to split.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You have to specify at least one post to split."},
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_invalid_posts_data(self):
         """api handles invalid data"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': 'string',
-            }),
+            json.dumps({"posts": "string"}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "str".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "str".'}
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [1, 2, 'string'],
-            }),
+            json.dumps({"posts": [1, 2, "string"]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "One or more post ids received were invalid.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "One or more post ids received were invalid."}
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_split_limit(self):
         """api rejects more posts than split limit"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': list(range(POSTS_LIMIT + 1)),
-            }),
+            json.dumps({"posts": list(range(POSTS_LIMIT + 1))}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "No more than %s posts can be split at single time." % POSTS_LIMIT,
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "No more than %s posts can be split at single time."
+                % POSTS_LIMIT
+            },
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_split_invisible(self):
         """api validates posts visibility"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [testutils.reply_thread(self.thread, is_unapproved=True).pk],
-            }),
+            json.dumps(
+                {"posts": [testutils.reply_thread(self.thread, is_unapproved=True).pk]}
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "One or more posts to split could not be found.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "One or more posts to split could not be found."},
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_split_event(self):
         """api rejects events split"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [testutils.reply_thread(self.thread, is_event=True).pk],
-            }),
+            json.dumps(
+                {"posts": [testutils.reply_thread(self.thread, is_event=True).pk]}
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Events can't be split.",
-        })
+        self.assertEqual(response.json(), {"detail": "Events can't be split."})
 
     @patch_category_acl({"can_move_posts": True})
     def test_split_first_post(self):
         """api rejects first post split"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [self.thread.first_post_id],
-            }),
+            json.dumps({"posts": [self.thread.first_post_id]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You can't split thread's first post.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't split thread's first post."}
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_split_hidden_posts(self):
         """api recjects attempt to split urneadable hidden post"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [testutils.reply_thread(self.thread, is_hidden=True).pk],
-            }),
+            json.dumps(
+                {"posts": [testutils.reply_thread(self.thread, is_hidden=True).pk]}
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "You can't split posts the content you can't see.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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):
@@ -234,15 +240,14 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [testutils.reply_thread(self.thread).pk],
-            }),
+            json.dumps({"posts": [testutils.reply_thread(self.thread).pk]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "This thread is closed. You can't split posts in it.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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):
@@ -252,15 +257,14 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [testutils.reply_thread(self.thread).pk],
-            }),
+            json.dumps({"posts": [testutils.reply_thread(self.thread).pk]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "This category is closed. You can't split posts in it.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"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):
@@ -269,34 +273,34 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [testutils.reply_thread(other_thread, is_hidden=True).pk],
-            }),
+            json.dumps(
+                {"posts": [testutils.reply_thread(other_thread, is_hidden=True).pk]}
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "One or more posts to split could not be found.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "One or more posts to split could not be found."},
+        )
 
     @patch_category_acl({"can_move_posts": True})
     def test_split_empty_new_thread_data(self):
         """api handles empty form data"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-            }),
+            json.dumps({"posts": self.posts}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'title': ['This field is required.'],
-                'category': ['This field is required.'],
-            }
+            response_json,
+            {
+                "title": ["This field is required."],
+                "category": ["This field is required."],
+            },
         )
 
     @patch_category_acl({"can_move_posts": True})
@@ -304,20 +308,21 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         """api rejects split because final thread title was invalid"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': '$$$',
-                'category': self.category.id,
-            }),
+            json.dumps(
+                {"posts": self.posts, "title": "$$$", "category": self.category.id}
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'title': ["Thread title should be at least 5 characters long (it has 3)."],
-            }
+            response_json,
+            {
+                "title": [
+                    "Thread title should be at least 5 characters long (it has 3)."
+                ]
+            },
         )
 
     @patch_other_category_acl({"can_see": False})
@@ -326,20 +331,20 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         """api rejects split because final category was invalid"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': 'Valid thread title',
-                'category': self.other_category.id,
-            }),
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "Valid thread title",
+                    "category": self.other_category.id,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'category': ["Requested category could not be found."],
-            }
+            response_json, {"category": ["Requested category could not be found."]}
         )
 
     @patch_category_acl({"can_move_posts": True, "can_start_threads": False})
@@ -347,20 +352,21 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         """api rejects split because category isn't allowing starting threads"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': 'Valid thread title',
-                'category': self.category.id,
-            }),
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "Valid thread title",
+                    "category": self.category.id,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'category': ["You can't create new threads in selected category."],
-            }
+            response_json,
+            {"category": ["You can't create new threads in selected category."]},
         )
 
     @patch_category_acl({"can_move_posts": True})
@@ -368,21 +374,21 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         """api rejects split because final weight was invalid"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': 'Valid thread title',
-                'category': self.category.id,
-                'weight': 4,
-            }),
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "Valid thread title",
+                    "category": self.category.id,
+                    "weight": 4,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'weight': ["Ensure this value is less than or equal to 2."],
-            }
+            response_json, {"weight": ["Ensure this value is less than or equal to 2."]}
         )
 
     @patch_category_acl({"can_move_posts": True})
@@ -390,21 +396,26 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         """api rejects split because global weight was unallowed"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': 'Valid thread title',
-                'category': self.category.id,
-                'weight': 2,
-            }),
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "Valid thread title",
+                    "category": self.category.id,
+                    "weight": 2,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'weight': ["You don't have permission to pin threads globally in this category."],
-            }
+            response_json,
+            {
+                "weight": [
+                    "You don't have permission to pin threads globally in this category."
+                ]
+            },
         )
 
     @patch_category_acl({"can_move_posts": True, "can_pin_threads": 0})
@@ -412,21 +423,22 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         """api rejects split because local weight was unallowed"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': 'Valid thread title',
-                'category': self.category.id,
-                'weight': 1,
-            }),
-            content_type="application/json"
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "Valid thread title",
+                    "category": self.category.id,
+                    "weight": 1,
+                }
+            ),
+            content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'weight': ["You don't have permission to pin threads in this category."],
-            }
+            response_json,
+            {"weight": ["You don't have permission to pin threads in this category."]},
         )
 
     @patch_category_acl({"can_move_posts": True, "can_pin_threads": 1})
@@ -434,21 +446,26 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         """api allows local weight"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': '$$$',
-                'category': self.category.id,
-                'weight': 1,
-            }),
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "$$$",
+                    "category": self.category.id,
+                    "weight": 1,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'title': ["Thread title should be at least 5 characters long (it has 3)."],
-            }
+            response_json,
+            {
+                "title": [
+                    "Thread title should be at least 5 characters long (it has 3)."
+                ]
+            },
         )
 
     @patch_category_acl({"can_move_posts": True, "can_pin_threads": 2})
@@ -456,21 +473,26 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         """api allows global weight"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': '$$$',
-                'category': self.category.id,
-                'weight': 2,
-            }),
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "$$$",
+                    "category": self.category.id,
+                    "weight": 2,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'title': ["Thread title should be at least 5 characters long (it has 3)."],
-            }
+            response_json,
+            {
+                "title": [
+                    "Thread title should be at least 5 characters long (it has 3)."
+                ]
+            },
         )
 
     @patch_category_acl({"can_move_posts": True, "can_close_threads": False})
@@ -478,21 +500,26 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         """api rejects split because closing thread was unallowed"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': 'Valid thread title',
-                'category': self.category.id,
-                'is_closed': True,
-            }),
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "Valid thread title",
+                    "category": self.category.id,
+                    "is_closed": True,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'is_closed': ["You don't have permission to close threads in this category."],
-            }
+            response_json,
+            {
+                "is_closed": [
+                    "You don't have permission to close threads in this category."
+                ]
+            },
         )
 
     @patch_category_acl({"can_move_posts": True, "can_close_threads": True})
@@ -500,22 +527,27 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         """api allows for closing thread"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': '$$$',
-                'category': self.category.id,
-                'weight': 0,
-                'is_closed': True,
-            }),
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "$$$",
+                    "category": self.category.id,
+                    "weight": 0,
+                    "is_closed": True,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'title': ["Thread title should be at least 5 characters long (it has 3)."],
-            }
+            response_json,
+            {
+                "title": [
+                    "Thread title should be at least 5 characters long (it has 3)."
+                ]
+            },
         )
 
     @patch_category_acl({"can_move_posts": True, "can_hide_threads": 0})
@@ -523,21 +555,26 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         """api rejects split because hidden thread was unallowed"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': 'Valid thread title',
-                'category': self.category.id,
-                'is_hidden': True,
-            }),
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "Valid thread title",
+                    "category": self.category.id,
+                    "is_hidden": True,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'is_hidden': ["You don't have permission to hide threads in this category."],
-            }
+            response_json,
+            {
+                "is_hidden": [
+                    "You don't have permission to hide threads in this category."
+                ]
+            },
         )
 
     @patch_category_acl({"can_move_posts": True, "can_hide_threads": 1})
@@ -545,22 +582,27 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         """api allows for hiding thread"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': '$$$',
-                'category': self.category.id,
-                'weight': 0,
-                'is_hidden': True,
-            }),
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "$$$",
+                    "category": self.category.id,
+                    "weight": 0,
+                    "is_hidden": True,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
         self.assertEqual(
-            response_json, {
-                'title': ["Thread title should be at least 5 characters long (it has 3)."],
-            }
+            response_json,
+            {
+                "title": [
+                    "Thread title should be at least 5 characters long (it has 3)."
+                ]
+            },
         )
 
     @patch_category_acl({"can_move_posts": True})
@@ -571,17 +613,19 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': 'Split thread.',
-                'category': self.category.id,
-            }),
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "Split thread.",
+                    "category": self.category.id,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
         # thread was created
-        split_thread = self.category.thread_set.get(slug='split-thread')
+        split_thread = self.category.thread_set.get(slug="split-thread")
         self.assertEqual(split_thread.replies, 1)
 
         # posts were removed from old thread
@@ -606,11 +650,13 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': [best_answer.pk],
-                'title': 'Split thread.',
-                'category': self.category.id,
-            }),
+            json.dumps(
+                {
+                    "posts": [best_answer.pk],
+                    "title": "Split thread.",
+                    "category": self.category.id,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -620,16 +666,18 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(self.thread.replies, 2)
         self.assertIsNone(self.thread.best_answer)
 
-        split_thread = self.category.thread_set.get(slug='split-thread')
+        split_thread = self.category.thread_set.get(slug="split-thread")
         self.assertEqual(split_thread.replies, 0)
         self.assertIsNone(split_thread.best_answer)
 
-    @patch_other_category_acl({
-        'can_start_threads': True,
-        'can_close_threads': True,
-        'can_hide_threads': True,
-        'can_pin_threads': 2,
-    })
+    @patch_other_category_acl(
+        {
+            "can_start_threads": True,
+            "can_close_threads": True,
+            "can_hide_threads": True,
+            "can_pin_threads": 2,
+        }
+    )
     @patch_category_acl({"can_move_posts": True})
     def test_split_kitchensink(self):
         """api splits posts with kitchensink"""
@@ -638,24 +686,28 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
 
         poststracker.save_read(self.user, self.thread.first_post)
         for post in self.posts:
-            poststracker.save_read(self.user, Post.objects.select_related().get(pk=post))
+            poststracker.save_read(
+                self.user, Post.objects.select_related().get(pk=post)
+            )
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'posts': self.posts,
-                'title': 'Split thread',
-                'category': self.other_category.id,
-                'weight': 2,
-                'is_closed': 1,
-                'is_hidden': 1,
-            }),
+            json.dumps(
+                {
+                    "posts": self.posts,
+                    "title": "Split thread",
+                    "category": self.other_category.id,
+                    "weight": 2,
+                    "is_closed": 1,
+                    "is_hidden": 1,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
         # thread was created
-        split_thread = self.other_category.thread_set.get(slug='split-thread')
+        split_thread = self.other_category.thread_set.get(slug="split-thread")
         self.assertEqual(split_thread.replies, 1)
         self.assertEqual(split_thread.weight, 2)
         self.assertTrue(split_thread.is_closed)
@@ -669,10 +721,10 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.assertEqual(split_thread.post_set.filter(pk__in=self.posts).count(), 2)
 
         # postreads were removed
-        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")
 
-        postreads_threads = list(postreads.values_list('thread_id', flat=True))
+        postreads_threads = list(postreads.values_list("thread_id", flat=True))
         self.assertEqual(postreads_threads, [self.thread.pk])
 
-        postreads_categories = list(postreads.values_list('category_id', flat=True))
+        postreads_categories = list(postreads.values_list("category_id", flat=True))
         self.assertEqual(postreads_categories, [self.category.pk])

+ 52 - 64
misago/threads/tests/test_thread_reply_api.py

@@ -12,13 +12,11 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
     def setUp(self):
         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.api_link = reverse(
-            'misago:api:thread-post-list', kwargs={
-                'thread_pk': self.thread.pk,
-            }
+            "misago:api:thread-post-list", kwargs={"thread_pk": self.thread.pk}
         )
 
     def test_cant_reply_thread_as_guest(self):
@@ -30,15 +28,15 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
 
     def test_thread_visibility(self):
         """thread's visibility is validated"""
-        with patch_category_acl({'can_see': 0}):
+        with patch_category_acl({"can_see": 0}):
             response = self.client.post(self.api_link)
             self.assertEqual(response.status_code, 404)
 
-        with patch_category_acl({'can_browse': 0}):
+        with patch_category_acl({"can_browse": 0}):
             response = self.client.post(self.api_link)
             self.assertEqual(response.status_code, 404)
 
-        with patch_category_acl({'can_see_all_threads': 0}):
+        with patch_category_acl({"can_see_all_threads": 0}):
             response = self.client.post(self.api_link)
             self.assertEqual(response.status_code, 404)
 
@@ -47,9 +45,9 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         """permission to reply thread is validated"""
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't reply to threads in this category.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't reply to threads in this category."}
+        )
 
     @patch_category_acl({"can_reply_threads": True, "can_close_threads": False})
     def test_closed_category_no_permission(self):
@@ -59,9 +57,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
 
         response = self.client.post(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.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This category is closed. You can't reply to threads in it."},
+        )
 
     @patch_category_acl({"can_reply_threads": True, "can_close_threads": True})
     def test_closed_category(self):
@@ -80,9 +79,10 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't reply to closed threads in this category.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't reply to closed threads in this category."},
+        )
 
     @patch_category_acl({"can_reply_threads": True, "can_close_threads": True})
     def test_closed_thread(self):
@@ -98,46 +98,44 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         """no data sent handling has no showstoppers"""
         response = self.client.post(self.api_link, data={})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "post": ["You have to enter a message."],
-        })
+        self.assertEqual(response.json(), {"post": ["You have to enter a message."]})
 
     @patch_category_acl({"can_reply_threads": True})
     def test_invalid_data(self):
         """api errors for invalid request data"""
         response = self.client.post(
-            self.api_link,
-            'false',
-            content_type="application/json",
+            self.api_link, "false", content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "non_field_errors": [
+                    "Invalid data. Expected a dictionary, but got bool."
+                ]
+            },
+        )
 
     @patch_category_acl({"can_reply_threads": True})
     def test_post_is_validated(self):
         """post is validated"""
-        response = self.client.post(
-            self.api_link, data={
-                'post': "a",
-            }
-        )
+        response = self.client.post(self.api_link, data={"post": "a"})
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'post': ["Posted message should be at least 5 characters long (it has 1)."],
-            }
+            response.json(),
+            {
+                "post": [
+                    "Posted message should be at least 5 characters long (it has 1)."
+                ]
+            },
         )
 
     @patch_category_acl({"can_reply_threads": True})
     def test_can_reply_thread(self):
         """endpoint creates new reply"""
         response = self.client.post(
-            self.api_link, data={
-                'post': "This is test response!",
-            }
+            self.api_link, data={"post": "This is test response!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -177,9 +175,7 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
     def test_post_unicode(self):
         """unicode characters can be posted"""
         response = self.client.post(
-            self.api_link, data={
-                'post': "Chrzążczyżewoszyce, powiat Łękółody.",
-            }
+            self.api_link, data={"post": "Chrzążczyżewoszyce, powiat Łękółody."}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -190,9 +186,7 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.category.save()
 
         response = self.client.post(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
+            self.api_link, data={"post": "Lorem ipsum dolor met!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -204,7 +198,7 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertTrue(post.is_unapproved)
 
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts)
 
@@ -216,9 +210,7 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.category.save()
 
         response = self.client.post(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
+            self.api_link, data={"post": "Lorem ipsum dolor met!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -230,7 +222,7 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertFalse(post.is_unapproved)
 
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts + 1)
 
@@ -238,9 +230,7 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
     def test_user_moderation_queue(self):
         """reply thread by user that requires approval"""
         response = self.client.post(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
+            self.api_link, data={"post": "Lorem ipsum dolor met!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -252,7 +242,7 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertTrue(post.is_unapproved)
 
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts)
 
@@ -261,9 +251,7 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
     def test_user_moderation_queue_bypass(self):
         """bypass moderation queue due to user's acl"""
         response = self.client.post(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
+            self.api_link, data={"post": "Lorem ipsum dolor met!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -275,15 +263,17 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertFalse(post.is_unapproved)
 
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts + 1)
 
-    @patch_category_acl({
-        "can_reply_threads": True,
-        "require_threads_approval": True,
-        "require_edits_approval": True,
-    })
+    @patch_category_acl(
+        {
+            "can_reply_threads": True,
+            "require_threads_approval": True,
+            "require_edits_approval": True,
+        }
+    )
     def test_omit_other_moderation_queues(self):
         """other queues are omitted"""
         self.category.require_threads_approval = True
@@ -291,9 +281,7 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         self.category.save()
 
         response = self.client.post(
-            self.api_link, data={
-                'post': "Lorem ipsum dolor met!",
-            }
+            self.api_link, data={"post": "Lorem ipsum dolor met!"}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -305,6 +293,6 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertFalse(post.is_unapproved)
 
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts + 1)

+ 174 - 153
misago/threads/tests/test_thread_start_api.py

@@ -10,8 +10,8 @@ class StartThreadTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.category = Category.objects.get(slug='first-category')
-        self.api_link = reverse('misago:api:thread-list')
+        self.category = Category.objects.get(slug="first-category")
+        self.api_link = reverse("misago:api:thread-list")
 
     def test_cant_start_thread_as_guest(self):
         """user has to be authenticated to be able to post thread"""
@@ -23,41 +23,46 @@ class StartThreadTests(AuthenticatedUserTestCase):
     @patch_category_acl({"can_see": False})
     def test_cant_see(self):
         """has no permission to see selected category"""
-        response = self.client.post(self.api_link, {
-            'category': self.category.pk,
-        })
+        response = self.client.post(self.api_link, {"category": self.category.pk})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'category': ["Selected category is invalid."],
-            'post': ['You have to enter a message.'],
-            'title': ['You have to enter thread title.'],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "category": ["Selected category is invalid."],
+                "post": ["You have to enter a message."],
+                "title": ["You have to enter thread title."],
+            },
+        )
 
     @patch_category_acl({"can_browse": False})
     def test_cant_browse(self):
         """has no permission to browse selected category"""
-        response = self.client.post(self.api_link, {
-            'category': self.category.pk,
-        })
+        response = self.client.post(self.api_link, {"category": self.category.pk})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'category': ['Selected category is invalid.'],
-            'post': ['You have to enter a message.'],
-            'title': ['You have to enter thread title.'],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "category": ["Selected category is invalid."],
+                "post": ["You have to enter a message."],
+                "title": ["You have to enter thread title."],
+            },
+        )
 
     @patch_category_acl({"can_start_threads": False})
     def test_cant_start_thread(self):
         """permission to start thread in category is validated"""
-        response = self.client.post(self.api_link, {
-            'category': self.category.pk,
-        })
+        response = self.client.post(self.api_link, {"category": self.category.pk})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'category': ["You don't have permission to start new threads in this category."],
-            'post': ['You have to enter a message.'],
-            'title': ['You have to enter thread title.'],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "category": [
+                    "You don't have permission to start new threads in this category."
+                ],
+                "post": ["You have to enter a message."],
+                "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):
@@ -65,28 +70,36 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.category.is_closed = True
         self.category.save()
 
-        response = self.client.post(self.api_link, {
-            'category': self.category.pk,
-        })
+        response = self.client.post(self.api_link, {"category": self.category.pk})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'category': ["This category is closed. You can't start new threads in it."],
-            'post': ['You have to enter a message.'],
-            'title': ['You have to enter thread title.'],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "category": [
+                    "This category is closed. You can't start new threads in it."
+                ],
+                "post": ["You have to enter a message."],
+                "title": ["You have to enter thread title."],
+            },
+        )
 
     def test_cant_start_thread_in_invalid_category(self):
         """can't post in invalid category"""
-        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.json(), {
-            'category': [
-                "Selected category doesn't exist or "
-                "you don't have permission to browse it."
-            ],
-            'post': ['You have to enter a message.'],
-            'title': ['You have to enter thread title.'],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "category": [
+                    "Selected category doesn't exist or "
+                    "you don't have permission to browse it."
+                ],
+                "post": ["You have to enter a message."],
+                "title": ["You have to enter thread title."],
+            },
+        )
 
     @patch_category_acl({"can_start_threads": True})
     def test_empty_data(self):
@@ -94,25 +107,29 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link, data={})
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'category': ["You have to select category to post thread in."],
-                'title': ["You have to enter thread title."],
-                'post': ["You have to enter a message."],
-            }
+            response.json(),
+            {
+                "category": ["You have to select category to post thread in."],
+                "title": ["You have to enter thread title."],
+                "post": ["You have to enter a message."],
+            },
         )
 
     @patch_category_acl({"can_start_threads": True})
     def test_invalid_data(self):
         """api errors for invalid request data"""
         response = self.client.post(
-            self.api_link,
-            'false',
-            content_type="application/json",
+            self.api_link, "false", content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "non_field_errors": [
+                    "Invalid data. Expected a dictionary, but got bool."
+                ]
+            },
+        )
 
     @patch_category_acl({"can_start_threads": True})
     def test_title_is_validated(self):
@@ -120,17 +137,16 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "------",
-                'post': "Lorem ipsum dolor met, sit amet elit!",
-            }
+                "category": self.category.pk,
+                "title": "------",
+                "post": "Lorem ipsum dolor met, sit amet elit!",
+            },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'title': ["Thread title should contain alpha-numeric characters."],
-            }
+            response.json(),
+            {"title": ["Thread title should contain alpha-numeric characters."]},
         )
 
     @patch_category_acl({"can_start_threads": True})
@@ -139,17 +155,20 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Lorem ipsum dolor met",
-                'post': "a",
-            }
+                "category": self.category.pk,
+                "title": "Lorem ipsum dolor met",
+                "post": "a",
+            },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'post': ["Posted message should be at least 5 characters long (it has 1)."],
-            }
+            response.json(),
+            {
+                "post": [
+                    "Posted message should be at least 5 characters long (it has 1)."
+                ]
+            },
         )
 
     @patch_category_acl({"can_start_threads": True})
@@ -158,17 +177,17 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
         thread = self.user.thread_set.all()[:1][0]
 
         response_json = response.json()
-        self.assertEqual(response_json['url'], thread.get_absolute_url())
+        self.assertEqual(response_json["url"], thread.get_absolute_url())
 
         response = self.client.get(thread.get_absolute_url())
         self.assertContains(response, self.category.name)
@@ -193,7 +212,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
 
         post = self.user.post_set.all()[:1][0]
         self.assertEqual(post.category_id, self.category.pk)
-        self.assertEqual(post.original, 'Lorem ipsum dolor met!')
+        self.assertEqual(post.original, "Lorem ipsum dolor met!")
         self.assertEqual(post.poster_id, self.user.id)
         self.assertEqual(post.poster_name, self.user.username)
 
@@ -214,11 +233,11 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-                'close': True,
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+                "close": True,
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -231,11 +250,11 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-                'close': True,
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+                "close": True,
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -248,11 +267,11 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-                'pin': 0,
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+                "pin": 0,
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -265,11 +284,11 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-                'pin': 1,
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+                "pin": 1,
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -282,11 +301,11 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-                'pin': 2,
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+                "pin": 2,
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -299,11 +318,11 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-                'pin': 2,
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+                "pin": 2,
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -316,11 +335,11 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-                'pin': 1,
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+                "pin": 1,
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -333,11 +352,11 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-                'hide': 1,
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+                "hide": 1,
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -353,11 +372,11 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-                'hide': 1,
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+                "hide": 1,
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -370,10 +389,10 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Brzęczyżczykiewicz",
-                'post': "Chrzążczyżewoszyce, powiat Łękółody.",
-            }
+                "category": self.category.pk,
+                "title": "Brzęczyżczykiewicz",
+                "post": "Chrzążczyżewoszyce, powiat Łękółody.",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -386,10 +405,10 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -400,7 +419,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertTrue(post.is_unapproved)
 
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts)
         self.assertFalse(category.last_thread_id == thread.id)
@@ -415,10 +434,10 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -429,7 +448,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertFalse(post.is_unapproved)
 
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertEqual(category.threads, self.category.threads + 1)
         self.assertEqual(category.posts, self.category.posts + 1)
         self.assertEqual(category.last_thread_id, thread.id)
@@ -440,10 +459,10 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -454,7 +473,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertTrue(post.is_unapproved)
 
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertEqual(category.threads, self.category.threads)
         self.assertEqual(category.posts, self.category.posts)
         self.assertFalse(category.last_thread_id == thread.id)
@@ -466,10 +485,10 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -480,16 +499,18 @@ class StartThreadTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertFalse(post.is_unapproved)
 
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertEqual(category.threads, self.category.threads + 1)
         self.assertEqual(category.posts, self.category.posts + 1)
         self.assertEqual(category.last_thread_id, thread.id)
 
-    @patch_category_acl({
-        "can_start_threads": True,
-        "require_replies_approval": True,
-        "require_edits_approval": True,
-    })
+    @patch_category_acl(
+        {
+            "can_start_threads": True,
+            "require_replies_approval": True,
+            "require_edits_approval": True,
+        }
+    )
     def test_omit_other_moderation_queues(self):
         """other queues are omitted"""
         self.category.require_replies_approval = True
@@ -499,10 +520,10 @@ class StartThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': "Hello, I am test thread!",
-                'post': "Lorem ipsum dolor met!",
-            }
+                "category": self.category.pk,
+                "title": "Hello, I am test thread!",
+                "post": "Lorem ipsum dolor met!",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -513,7 +534,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
         post = self.user.post_set.all()[:1][0]
         self.assertFalse(post.is_unapproved)
 
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertEqual(category.threads, self.category.threads + 1)
         self.assertEqual(category.posts, self.category.posts + 1)
         self.assertEqual(category.last_thread_id, thread.id)

+ 11 - 7
misago/threads/tests/test_threadparticipant_model.py

@@ -17,11 +17,11 @@ class ThreadParticipantTests(TestCase):
         self.thread = Thread(
             category=self.category,
             started_on=datetime,
-            starter_name='Tester',
-            starter_slug='tester',
+            starter_name="Tester",
+            starter_slug="tester",
             last_post_on=datetime,
-            last_poster_name='Tester',
-            last_poster_slug='tester',
+            last_poster_name="Tester",
+            last_poster_slug="tester",
         )
 
         self.thread.set_title("Test thread")
@@ -30,7 +30,7 @@ class ThreadParticipantTests(TestCase):
         post = Post.objects.create(
             category=self.category,
             thread=self.thread,
-            poster_name='Tester',
+            poster_name="Tester",
             original="Hello! I am test message!",
             parsed="<p>Hello! I am test message!</p>",
             checksum="nope",
@@ -45,7 +45,9 @@ class ThreadParticipantTests(TestCase):
     def test_set_owner(self):
         """set_owner makes user thread owner"""
         user = UserModel.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
-        other_user = UserModel.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123")
+        other_user = UserModel.objects.create_user(
+            "Bob2", "bob2@boberson.com", "Pass.123"
+        )
 
         ThreadParticipant.objects.set_owner(self.thread, user)
         self.assertEqual(self.thread.participants.count(), 1)
@@ -80,7 +82,9 @@ class ThreadParticipantTests(TestCase):
     def test_remove_participant(self):
         """remove_participant deletes participant from thread"""
         user = UserModel.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
-        other_user = UserModel.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123")
+        other_user = UserModel.objects.create_user(
+            "Bob2", "bob2@boberson.com", "Pass.123"
+        )
 
         ThreadParticipant.objects.add_participants(self.thread, [user])
         ThreadParticipant.objects.add_participants(self.thread, [other_user])

+ 44 - 52
misago/threads/tests/test_threads_api.py

@@ -19,7 +19,7 @@ class ThreadsApiTestCase(AuthenticatedUserTestCase):
         threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
 
         self.root = Category.objects.get(tree_id=threads_tree_id, level=0)
-        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.api_link = self.thread.get_api_url()
@@ -37,8 +37,8 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
 
         self.tested_links = [
             self.api_link,
-            '%sposts/' % self.api_link,
-            '%sposts/?page=1' % self.api_link,
+            "%sposts/" % self.api_link,
+            "%sposts/?page=1" % self.api_link,
         ]
 
     @patch_category_acl()
@@ -49,11 +49,11 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
             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.assertEqual(response_json["id"], self.thread.pk)
+            self.assertEqual(response_json["title"], self.thread.title)
 
-            if 'posts' in link:
-                self.assertIn('post_set', response_json)
+            if "posts" in link:
+                self.assertIn("post_set", response_json)
 
     @patch_category_acl({"can_see_all_threads": False})
     def test_api_shows_owned_thread(self):
@@ -86,14 +86,14 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
     def test_api_validates_posts_visibility(self):
         """api validates posts visiblity"""
         hidden_post = testutils.reply_thread(
-            self.thread,
-            is_hidden=True,
-            message="I'am hidden test message!",
+            self.thread, is_hidden=True, message="I'am hidden test message!"
         )
 
         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
+            self.assertNotContains(
+                response, hidden_post.parsed
+            )  # post's body is hidden
 
         # add permission to see hidden posts
         with patch_category_acl({"can_hide_posts": 1}):
@@ -103,10 +103,7 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
             )  # hidden post's body is visible with permission
 
         # unapproved posts shouldn't show at all
-        unapproved_post = testutils.reply_thread(
-            self.thread,
-            is_unapproved=True,
-        )
+        unapproved_post = testutils.reply_thread(self.thread, is_unapproved=True)
 
         with patch_category_acl({"can_approve_content": False}):
             response = self.client.get(self.tested_links[1])
@@ -128,9 +125,9 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
                 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'])
+                self.assertEqual(response_json["id"], self.thread.pk)
+                self.assertEqual(response_json["title"], self.thread.title)
+                self.assertFalse(response_json["has_unapproved_posts"])
 
         with patch_category_acl({"can_approve_content": True}):
             for link in self.tested_links:
@@ -138,9 +135,9 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
                 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'])
+                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):
@@ -156,30 +153,29 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
             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."
+                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."
+                response.json()["detail"], "You can't delete threads in this category."
             )
 
-    @patch_category_acl({'can_hide_threads': 1, 'can_hide_own_threads': 2})
+    @patch_category_acl({"can_hide_threads": 1, "can_hide_own_threads": 2})
     def test_delete_other_user_thread_no_permission(self):
         """api tests thread owner when deleting own thread"""
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json()['detail'], "You can't delete other users theads in this category."
+            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,
-    })
+    @patch_category_acl(
+        {"can_hide_threads": 2, "can_hide_own_threads": 2, "can_close_threads": False}
+    )
     def test_delete_thread_closed_category_no_permission(self):
         """api tests category's closed state"""
         self.category.is_closed = True
@@ -188,14 +184,13 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json()['detail'], "This category is closed. You can't delete threads in it."
+            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,
-    })
+    @patch_category_acl(
+        {"can_hide_threads": 2, "can_hide_own_threads": 2, "can_close_threads": False}
+    )
     def test_delete_thread_closed_no_permission(self):
         """api tests thread's closed state"""
         self.last_thread.is_closed = True
@@ -204,14 +199,12 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json()['detail'], "This thread is closed. You can't delete it."
+            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
-    })
+    @patch_category_acl(
+        {"can_hide_threads": 1, "can_hide_own_threads": 2, "thread_edit_time": 1}
+    )
     def test_delete_owned_thread_no_time(self):
         """api tests permission to delete owned thread within time limit"""
         self.last_thread.starter = self.user
@@ -221,13 +214,14 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            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})
+    @patch_category_acl({"can_hide_threads": 2})
     def test_delete_thread(self):
         """DELETE to API link with permission deletes thread"""
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertEqual(category.last_thread_id, self.last_thread.pk)
 
         response = self.client.delete(self.api_link)
@@ -237,7 +231,7 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
             Thread.objects.get(pk=self.last_thread.pk)
 
         # category was synchronised after deletion
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertEqual(category.last_thread_id, self.thread.pk)
 
         # test that last thread's deletion triggers category sync
@@ -247,14 +241,12 @@ class ThreadDeleteApiTests(ThreadsApiTestCase):
         with self.assertRaises(Thread.DoesNotExist):
             Thread.objects.get(pk=self.thread.pk)
 
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         self.assertIsNone(category.last_thread_id)
 
-    @patch_category_acl({
-        'can_hide_threads': 1,
-        'can_hide_own_threads': 2,
-        'thread_edit_time': 30
-    })
+    @patch_category_acl(
+        {"can_hide_threads": 1, "can_hide_own_threads": 2, "thread_edit_time": 30}
+    )
     def test_delete_owned_thread(self):
         """api lets owner to delete owned thread within time limit"""
         self.last_thread.starter = self.user

+ 76 - 79
misago/threads/tests/test_threads_bulkdelete_api.py

@@ -17,22 +17,18 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
     def setUp(self):
         super().setUp()
 
-        self.api_link = reverse('misago:api:thread-list')
+        self.api_link = reverse("misago:api:thread-list")
 
         self.threads = [
-            testutils.post_thread(
-                category=self.category,
-                poster=self.user,
-            ),
+            testutils.post_thread(category=self.category, poster=self.user),
             testutils.post_thread(category=self.category),
-            testutils.post_thread(
-                category=self.category,
-                poster=self.user,
-            ),
+            testutils.post_thread(category=self.category, poster=self.user),
         ]
 
     def delete(self, url, data=None):
-        return self.client.delete(url, json.dumps(data), content_type="application/json")
+        return self.client.delete(
+            url, json.dumps(data), content_type="application/json"
+        )
 
     def test_delete_anonymous(self):
         """anonymous users can't bulk delete threads"""
@@ -40,47 +36,52 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This action is not available to guests.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
 
     @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     def test_delete_no_ids(self):
         """api requires ids to delete"""
         response = self.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You have to specify at least one thread to delete.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You have to specify at least one thread to delete."},
+        )
 
     @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     def test_validate_ids(self):
         response = self.delete(self.api_link, True)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "bool".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "bool".'}
+        )
 
-        response = self.delete(self.api_link, 'abbss')
+        response = self.delete(self.api_link, "abbss")
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "str".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "str".'}
+        )
 
-        response = self.delete(self.api_link, [1, 2, 3, 'a', 'b', 'x'])
+        response = self.delete(self.api_link, [1, 2, 3, "a", "b", "x"])
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "One or more thread ids received were invalid.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "One or more thread ids received were invalid."}
+        )
 
     @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     def test_validate_ids_length(self):
         """api validates that ids are list of ints"""
         response = self.delete(self.api_link, list(range(THREADS_LIMIT + 1)))
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "No more than %s threads can be deleted at single time." % THREADS_LIMIT,
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "No more than %s threads can be deleted at single time."
+                % THREADS_LIMIT
+            },
+        )
 
     @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     def test_validate_thread_visibility(self):
@@ -94,9 +95,10 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link, threads_ids)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "One or more threads to delete could not be found.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "One or more threads to delete could not be found."},
+        )
 
         # no thread was deleted
         for thread in self.threads:
@@ -109,25 +111,23 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
 
         response = self.delete(self.api_link, [p.id for p in self.threads])
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), [
-            {
-                'thread': {
-                    'id': other_thread.pk,
-                    'title': other_thread.title
-                },
-                'error': "You can't delete other users theads in this category."
-            }
-        ])
+        self.assertEqual(
+            response.json(),
+            [
+                {
+                    "thread": {"id": other_thread.pk, "title": other_thread.title},
+                    "error": "You can't delete other users theads in this category.",
+                }
+            ],
+        )
 
         # no threads are removed on failed attempt
         for thread in self.threads:
             Thread.objects.get(pk=thread.pk)
 
-    @patch_category_acl({
-        "can_hide_threads": 2, 
-        "can_hide_own_threads": 2,
-        "can_close_threads": False,
-    })
+    @patch_category_acl(
+        {"can_hide_threads": 2, "can_hide_own_threads": 2, "can_close_threads": False}
+    )
     def test_delete_thread_closed_category_no_permission(self):
         """api tests category's closed state"""
         self.category.is_closed = True
@@ -136,21 +136,20 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         response = self.delete(self.api_link, [p.id for p in self.threads])
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), [
-            {
-                'thread': {
-                    'id': thread.pk,
-                    'title': thread.title
-                },
-                'error': "This category is closed. You can't delete threads in it."
-            } 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,
-    })
+        self.assertEqual(
+            response.json(),
+            [
+                {
+                    "thread": {"id": thread.pk, "title": thread.title},
+                    "error": "This category is closed. You can't delete threads in it.",
+                }
+                for thread in sorted(self.threads, key=lambda i: i.pk)
+            ],
+        )
+
+    @patch_category_acl(
+        {"can_hide_threads": 2, "can_hide_own_threads": 2, "can_close_threads": False}
+    )
     def test_delete_thread_closed_no_permission(self):
         """api tests thread's closed state"""
         closed_thread = self.threads[1]
@@ -160,15 +159,15 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         response = self.delete(self.api_link, [p.id for p in self.threads])
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), [
-            {
-                'thread': {
-                    'id': closed_thread.pk,
-                    'title': closed_thread.title
-                },
-                'error': "This thread is closed. You can't delete it."
-            }
-        ])
+        self.assertEqual(
+            response.json(),
+            [
+                {
+                    "thread": {"id": closed_thread.pk, "title": closed_thread.title},
+                    "error": "This thread is closed. You can't delete it.",
+                }
+            ],
+        )
 
     @patch_category_acl({"can_hide_threads": 2, "can_hide_own_threads": 2})
     def test_delete_private_thread(self):
@@ -176,21 +175,19 @@ class ThreadsBulkDeleteApiTests(ThreadsApiTestCase):
         private_thread = self.threads[0]
 
         private_thread.category = Category.objects.get(
-            tree_id=trees_map.get_tree_id_for_root(PRIVATE_THREADS_ROOT_NAME),
+            tree_id=trees_map.get_tree_id_for_root(PRIVATE_THREADS_ROOT_NAME)
         )
         private_thread.save()
 
-        private_thread.threadparticipant_set.create(
-            user=self.user,
-            is_owner=True,
-        )
+        private_thread.threadparticipant_set.create(user=self.user, is_owner=True)
 
         threads_ids = [p.id for p in self.threads]
 
         response = self.delete(self.api_link, threads_ids)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "One or more threads to delete could not be found.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "One or more threads to delete could not be found."},
+        )
 
         Thread.objects.get(pk=private_thread.pk)

+ 204 - 210
misago/threads/tests/test_threads_editor_api.py

@@ -12,8 +12,8 @@ from misago.threads.serializers import AttachmentSerializer
 from misago.threads.test import patch_category_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
-TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
-TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, 'document.pdf')
+TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testfiles")
+TEST_DOCUMENT_PATH = os.path.join(TESTFILES_DIR, "document.pdf")
 
 cache_versions = get_cache_versions()
 
@@ -22,14 +22,14 @@ class EditorApiTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.category = Category.objects.get(slug='first-category')
+        self.category = Category.objects.get(slug="first-category")
 
 
 class ThreadPostEditorApiTests(EditorApiTestCase):
     def setUp(self):
         super().setUp()
 
-        self.api_link = reverse('misago:api:thread-editor')
+        self.api_link = reverse("misago:api:thread-editor")
 
     def test_anonymous_user_request(self):
         """endpoint validates if user is authenticated"""
@@ -37,29 +37,35 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You need to be signed in to start threads.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You need to be signed in to start threads."}
+        )
 
-    @patch_category_acl({'can_browse': False})
+    @patch_category_acl({"can_browse": False})
     def test_category_visibility_validation(self):
         """endpoint omits non-browseable categories"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "No categories that allow new threads are available to you at the moment.",
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "No categories that allow new threads are available to you at the moment."
+            },
+        )
 
-    @patch_category_acl({'can_start_threads': False})
+    @patch_category_acl({"can_start_threads": False})
     def test_category_disallowing_new_threads(self):
         """endpoint omits category disallowing starting threads"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "No categories that allow new threads are available to you at the moment.",
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "No categories that allow new threads are available to you at the moment."
+            },
+        )
 
-    @patch_category_acl({'can_close_threads': False, 'can_start_threads': True})
+    @patch_category_acl({"can_close_threads": False, "can_start_threads": True})
     def test_category_closed_disallowing_new_threads(self):
         """endpoint omits closed category"""
         self.category.is_closed = True
@@ -67,11 +73,14 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "No categories that allow new threads are available to you at the moment.",
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "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})
+    @patch_category_acl({"can_close_threads": True, "can_start_threads": True})
     def test_category_closed_allowing_new_threads(self):
         """endpoint adds closed category that allows new threads"""
         self.category.is_closed = True
@@ -82,19 +91,16 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
 
         response_json = response.json()
         self.assertEqual(
-            response_json[0], {
-                'id': self.category.pk,
-                'name': self.category.name,
-                'level': 0,
-                'post': {
-                    'close': True,
-                    'hide': False,
-                    'pin': 0,
-                },
-            }
+            response_json[0],
+            {
+                "id": self.category.pk,
+                "name": self.category.name,
+                "level": 0,
+                "post": {"close": True, "hide": False, "pin": 0},
+            },
         )
 
-    @patch_category_acl({'can_start_threads': True})
+    @patch_category_acl({"can_start_threads": True})
     def test_category_allowing_new_threads(self):
         """endpoint adds category that allows new threads"""
         response = self.client.get(self.api_link)
@@ -102,19 +108,16 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
 
         response_json = response.json()
         self.assertEqual(
-            response_json[0], {
-                'id': self.category.pk,
-                'name': self.category.name,
-                'level': 0,
-                'post': {
-                    'close': False,
-                    'hide': False,
-                    'pin': 0,
-                },
-            }
+            response_json[0],
+            {
+                "id": self.category.pk,
+                "name": self.category.name,
+                "level": 0,
+                "post": {"close": False, "hide": False, "pin": 0},
+            },
         )
 
-    @patch_category_acl({'can_close_threads': True, 'can_start_threads': True})
+    @patch_category_acl({"can_close_threads": True, "can_start_threads": True})
     def test_category_allowing_closing_threads(self):
         """endpoint adds category that allows new closed threads"""
         response = self.client.get(self.api_link)
@@ -122,19 +125,16 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
 
         response_json = response.json()
         self.assertEqual(
-            response_json[0], {
-                'id': self.category.pk,
-                'name': self.category.name,
-                'level': 0,
-                'post': {
-                    'close': True,
-                    'hide': False,
-                    'pin': 0,
-                },
-            }
+            response_json[0],
+            {
+                "id": self.category.pk,
+                "name": self.category.name,
+                "level": 0,
+                "post": {"close": True, "hide": False, "pin": 0},
+            },
         )
 
-    @patch_category_acl({'can_start_threads': True, 'can_pin_threads': 1})
+    @patch_category_acl({"can_start_threads": True, "can_pin_threads": 1})
     def test_category_allowing_locally_pinned_threads(self):
         """endpoint adds category that allows locally pinned threads"""
         response = self.client.get(self.api_link)
@@ -142,19 +142,16 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
 
         response_json = response.json()
         self.assertEqual(
-            response_json[0], {
-                'id': self.category.pk,
-                'name': self.category.name,
-                'level': 0,
-                'post': {
-                    'close': False,
-                    'hide': False,
-                    'pin': 1,
-                },
-            }
+            response_json[0],
+            {
+                "id": self.category.pk,
+                "name": self.category.name,
+                "level": 0,
+                "post": {"close": False, "hide": False, "pin": 1},
+            },
         )
 
-    @patch_category_acl({'can_start_threads': True, 'can_pin_threads': 2})
+    @patch_category_acl({"can_start_threads": True, "can_pin_threads": 2})
     def test_category_allowing_globally_pinned_threads(self):
         """endpoint adds category that allows globally pinned threads"""
         response = self.client.get(self.api_link)
@@ -162,19 +159,16 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
 
         response_json = response.json()
         self.assertEqual(
-            response_json[0], {
-                'id': self.category.pk,
-                'name': self.category.name,
-                'level': 0,
-                'post': {
-                    'close': False,
-                    'hide': False,
-                    'pin': 2,
-                },
-            }
+            response_json[0],
+            {
+                "id": self.category.pk,
+                "name": self.category.name,
+                "level": 0,
+                "post": {"close": False, "hide": False, "pin": 2},
+            },
         )
 
-    @patch_category_acl({'can_start_threads': True, '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)
@@ -182,19 +176,16 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
 
         response_json = response.json()
         self.assertEqual(
-            response_json[0], {
-                'id': self.category.pk,
-                'name': self.category.name,
-                'level': 0,
-                'post': {
-                    'close': 0,
-                    'hide': 1,
-                    'pin': 0,
-                },
-            }
+            response_json[0],
+            {
+                "id": self.category.pk,
+                "name": self.category.name,
+                "level": 0,
+                "post": {"close": 0, "hide": 1, "pin": 0},
+            },
         )
 
-    @patch_category_acl({'can_start_threads': True, '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)
@@ -202,16 +193,13 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
 
         response_json = response.json()
         self.assertEqual(
-            response_json[0], {
-                'id': self.category.pk,
-                'name': self.category.name,
-                'level': 0,
-                'post': {
-                    'close': False,
-                    'hide': 1,
-                    'pin': 0,
-                },
-            }
+            response_json[0],
+            {
+                "id": self.category.pk,
+                "name": self.category.name,
+                "level": 0,
+                "post": {"close": False, "hide": 1, "pin": 0},
+            },
         )
 
 
@@ -221,9 +209,7 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
 
         self.thread = testutils.post_thread(category=self.category)
         self.api_link = reverse(
-            'misago:api:thread-post-editor', kwargs={
-                'thread_pk': self.thread.pk,
-            }
+            "misago:api:thread-post-editor", kwargs={"thread_pk": self.thread.pk}
         )
 
     def test_anonymous_user_request(self):
@@ -232,47 +218,52 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You have to sign in to reply threads.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You have to sign in to reply threads."}
+        )
 
     def test_thread_visibility(self):
         """thread's visibility is validated"""
-        with patch_category_acl({'can_see': False}):
+        with patch_category_acl({"can_see": False}):
             response = self.client.get(self.api_link)
             self.assertEqual(response.status_code, 404)
 
-        with patch_category_acl({'can_browse': False}):
+        with patch_category_acl({"can_browse": False}):
             response = self.client.get(self.api_link)
             self.assertEqual(response.status_code, 404)
 
-        with patch_category_acl({'can_see_all_threads': False}):
+        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})
+    @patch_category_acl({"can_reply_threads": False})
     def test_no_reply_permission(self):
         """permssion to reply is validated"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't reply to threads in this category.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't reply to threads in this category."}
+        )
 
     def test_closed_category(self):
         """permssion to reply in closed category is validated"""
         self.category.is_closed = True
         self.category.save()
 
-        with patch_category_acl({'can_reply_threads': True, 'can_close_threads': False}):
+        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.",
-            })
+            self.assertEqual(
+                response.json(),
+                {
+                    "detail": "This category is closed. You can't reply to threads in it."
+                },
+            )
 
         # allow to post in closed category
-        with patch_category_acl({'can_reply_threads': True, 'can_close_threads': True}):
+        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)
 
@@ -281,19 +272,22 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        with patch_category_acl({'can_reply_threads': True, 'can_close_threads': False}):
+        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.",
-            })
+            self.assertEqual(
+                response.json(),
+                {"detail": "You can't reply to closed threads in this category."},
+            )
 
         # allow to post in closed thread
-        with patch_category_acl({'can_reply_threads': True, 'can_close_threads': True}):
+        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})
+    @patch_category_acl({"can_reply_threads": True})
     def test_allow_reply_thread(self):
         """api returns 200 code if thread reply is allowed"""
         response = self.client.get(self.api_link)
@@ -305,53 +299,54 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         # unapproved reply can't be replied to
         unapproved_reply = testutils.reply_thread(self.thread, is_unapproved=True)
 
-        with patch_category_acl({'can_reply_threads': True}):
-            response = self.client.get('%s?reply=%s' % (self.api_link, unapproved_reply.pk))
+        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 = testutils.reply_thread(self.thread, is_hidden=True)
 
-        with patch_category_acl({'can_reply_threads': True}):
-            response = self.client.get('%s?reply=%s' % (self.api_link, hidden_reply.pk))
+        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.",
-            })
+            self.assertEqual(
+                response.json(), {"detail": "You can't reply to hidden posts."}
+            )
 
     def test_reply_to_other_thread_post(self):
         """api validates is replied post belongs to same thread"""
         other_thread = testutils.post_thread(category=self.category)
         reply_to = testutils.reply_thread(other_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))
         self.assertEqual(response.status_code, 404)
 
-    @patch_category_acl({'can_reply_threads': True})
+    @patch_category_acl({"can_reply_threads": True})
     def test_reply_to_event(self):
         """events can't be replied to"""
         reply_to = testutils.reply_thread(self.thread, is_event=True)
 
-        response = self.client.get('%s?reply=%s' % (self.api_link, reply_to.pk))
+        response = self.client.get("%s?reply=%s" % (self.api_link, reply_to.pk))
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't reply to events.",
-        })
+        self.assertEqual(response.json(), {"detail": "You can't reply to events."})
 
-    @patch_category_acl({'can_reply_threads': True})
+    @patch_category_acl({"can_reply_threads": True})
     def test_reply_to(self):
         """api includes replied to post details in response"""
         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))
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            response.json(), {
-                'id': reply_to.pk,
-                'post': reply_to.original,
-                'poster': reply_to.poster_name,
-            }
+            response.json(),
+            {
+                "id": reply_to.pk,
+                "post": reply_to.original,
+                "poster": reply_to.poster_name,
+            },
         )
 
 
@@ -363,11 +358,8 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.post = testutils.reply_thread(self.thread, poster=self.user)
 
         self.api_link = reverse(
-            'misago:api:thread-post-editor',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.post.pk,
-            }
+            "misago:api:thread-post-editor",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.post.pk},
         )
 
     def test_anonymous_user_request(self):
@@ -376,47 +368,48 @@ class EditReplyEditorApiTests(EditorApiTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You have to sign in to edit posts.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You have to sign in to edit posts."}
+        )
 
     def test_thread_visibility(self):
         """thread's visibility is validated"""
-        with patch_category_acl({'can_see': False}):
+        with patch_category_acl({"can_see": False}):
             response = self.client.get(self.api_link)
             self.assertEqual(response.status_code, 404)
 
-        with patch_category_acl({'can_browse': False}):
+        with patch_category_acl({"can_browse": False}):
             response = self.client.get(self.api_link)
             self.assertEqual(response.status_code, 404)
 
-        with patch_category_acl({'can_see_all_threads': False}):
+        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})
+    @patch_category_acl({"can_edit_posts": 0})
     def test_no_edit_permission(self):
         """permssion to edit is validated"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't edit posts in this category.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't edit posts in this category."}
+        )
 
     def test_closed_category(self):
         """permssion to edit in closed category is validated"""
         self.category.is_closed = True
         self.category.save()
 
-        with patch_category_acl({'can_edit_posts': 1, 'can_close_threads': False}):
+        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.",
-            })
+            self.assertEqual(
+                response.json(),
+                {"detail": "This category is closed. You can't edit posts in it."},
+            )
 
         # allow to edit in closed category
-        with patch_category_acl({'can_edit_posts': 1, 'can_close_threads': True}):
+        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)
 
@@ -425,15 +418,16 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        with patch_category_acl({'can_edit_posts': 1, 'can_close_threads': False}):
+        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.",
-            })
+            self.assertEqual(
+                response.json(),
+                {"detail": "This thread is closed. You can't edit posts in it."},
+            )
 
         # allow to edit in closed thread
-        with patch_category_acl({'can_edit_posts': 1, 'can_close_threads': True}):
+        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)
 
@@ -442,15 +436,16 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.post.is_protected = True
         self.post.save()
 
-        with patch_category_acl({'can_edit_posts': 1, 'can_protect_posts': False}):
+        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.",
-            })
+            self.assertEqual(
+                response.json(),
+                {"detail": "This post is protected. You can't edit it."},
+            )
 
         # allow to post in closed thread
-        with patch_category_acl({'can_edit_posts': 1, 'can_protect_posts': True}):
+        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)
 
@@ -459,15 +454,15 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.post.is_hidden = True
         self.post.save()
 
-        with patch_category_acl({'can_edit_posts': 1}):
+        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.",
-            })
+            self.assertEqual(
+                response.json(), {"detail": "This post is hidden, you can't edit it."}
+            )
 
         # allow hidden edition
-        with patch_category_acl({'can_edit_posts': 1, 'can_hide_posts': 1}):
+        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)
 
@@ -477,16 +472,16 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.post.poster = None
         self.post.save()
 
-        with patch_category_acl({'can_edit_posts': 2, 'can_approve_content': 0}):
+        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
-        with patch_category_acl({'can_edit_posts': 2, 'can_approve_content': 1}):
+        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})
+    @patch_category_acl({"can_edit_posts": 2})
     def test_post_is_event(self):
         """events can't be edited"""
         self.post.is_event = True
@@ -494,28 +489,27 @@ class EditReplyEditorApiTests(EditorApiTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "Events can't be edited.",
-        })
+        self.assertEqual(response.json(), {"detail": "Events can't be edited."})
 
     def test_other_user_post(self):
         """api validates if other user's post can be edited"""
         self.post.poster = None
         self.post.save()
 
-        with patch_category_acl({'can_edit_posts': 1}):
+        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.",
-            })
+            self.assertEqual(
+                response.json(),
+                {"detail": "You can't edit other users posts in this category."},
+            )
 
         # allow other users post edition
-        with patch_category_acl({'can_edit_posts': 2}):
+        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})
+    @patch_category_acl({"can_hide_threads": 1, "can_edit_posts": 2})
     def test_edit_first_post_hidden(self):
         """endpoint returns valid configuration for editor of hidden thread's first post"""
         self.thread.is_hidden = True
@@ -524,30 +518,25 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         self.thread.first_post.save()
 
         api_link = reverse(
-            'misago:api:thread-post-editor',
-            kwargs={
-                'thread_pk': self.thread.pk,
-                'pk': self.thread.first_post.pk,
-            }
+            "misago:api:thread-post-editor",
+            kwargs={"thread_pk": self.thread.pk, "pk": self.thread.first_post.pk},
         )
 
         response = self.client.get(api_link)
         self.assertEqual(response.status_code, 200)
 
-    @patch_category_acl({'can_edit_posts': 1})
+    @patch_category_acl({"can_edit_posts": 1})
     def test_edit(self):
         """endpoint returns valid configuration for editor"""
-        with patch_category_acl({'max_attachment_size': 1000}):
+        with patch_category_acl({"max_attachment_size": 1000}):
             for _ in range(3):
-                with open(TEST_DOCUMENT_PATH, 'rb') as upload:
+                with open(TEST_DOCUMENT_PATH, "rb") as upload:
                     response = self.client.post(
-                        reverse('misago:api:attachment-list'), data={
-                            'upload': upload,
-                        }
+                        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"))
 
         attachments[0].uploader = None
         attachments[0].save()
@@ -563,16 +552,21 @@ class EditReplyEditorApiTests(EditorApiTestCase):
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            response.json(), {
-                'id': self.post.pk,
-                'api': self.post.get_api_url(),
-                'post': self.post.original,
-                'can_protect': False,
-                'is_protected': self.post.is_protected,
-                'poster': self.post.poster_name,
-                'attachments': [
-                    AttachmentSerializer(attachments[1], context={'user': self.user}).data,
-                    AttachmentSerializer(attachments[0], context={'user': self.user}).data,
+            response.json(),
+            {
+                "id": self.post.pk,
+                "api": self.post.get_api_url(),
+                "post": self.post.original,
+                "can_protect": False,
+                "is_protected": self.post.is_protected,
+                "poster": self.post.poster_name,
+                "attachments": [
+                    AttachmentSerializer(
+                        attachments[1], context={"user": self.user}
+                    ).data,
+                    AttachmentSerializer(
+                        attachments[0], context={"user": self.user}
+                    ).data,
                 ],
-            }
+            },
         )

+ 424 - 348
misago/threads/tests/test_threads_merge_api.py

@@ -21,102 +21,79 @@ cache_versions = get_cache_versions()
 class ThreadsMergeApiTests(ThreadsApiTestCase):
     def setUp(self):
         super().setUp()
-        self.api_link = reverse('misago:api:thread-merge')
+        self.api_link = reverse("misago:api:thread-merge")
 
-        Category(
-            name='Other Category',
-            slug='other-category',
-        ).insert_at(
-            self.category,
-            position='last-child',
-            save=True,
+        Category(name="Other Category", slug="other-category").insert_at(
+            self.category, position="last-child", save=True
         )
-        self.other_category = Category.objects.get(slug='other-category')
+        self.other_category = Category.objects.get(slug="other-category")
 
     def test_merge_no_threads(self):
         """api validates if we are trying to merge no threads"""
         response = self.client.post(self.api_link, content_type="application/json")
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json(), {
-                'detail': "You have to select at least two threads to merge.",
-            }
+            response.json(),
+            {"detail": "You have to select at least two threads to merge."},
         )
 
     def test_merge_empty_threads(self):
         """api validates if we are trying to empty threads list"""
         response = self.client.post(
-            self.api_link,
-            json.dumps({
-                'threads': [],
-            }),
-            content_type="application/json",
+            self.api_link, json.dumps({"threads": []}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json(), {
-                'detail': "You have to select at least two threads to merge.",
-            }
+            response.json(),
+            {"detail": "You have to select at least two threads to merge."},
         )
 
     def test_merge_invalid_threads(self):
         """api validates if we are trying to merge invalid thread ids"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': 'abcd',
-            }),
+            json.dumps({"threads": "abcd"}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": 'Expected a list of items but got type "str".',
-        })
+        self.assertEqual(
+            response.json(), {"detail": 'Expected a list of items but got type "str".'}
+        )
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': ['a', '-', 'c'],
-            }),
+            json.dumps({"threads": ["a", "-", "c"]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json(), {
-                'detail': "One or more thread ids received were invalid.",
-            }
+            response.json(), {"detail": "One or more thread ids received were invalid."}
         )
 
     def test_merge_single_thread(self):
         """api validates if we are trying to merge single thread"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id],
-            }),
+            json.dumps({"threads": [self.thread.id]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json(), {
-                'detail': "You have to select at least two threads to merge.",
-            }
+            response.json(),
+            {"detail": "You have to select at least two threads to merge."},
         )
 
     def test_merge_with_nonexisting_thread(self):
         """api validates if we are trying to merge with invalid thread"""
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, self.thread.id + 1000],
-            }),
+            json.dumps({"threads": [self.thread.id, self.thread.id + 1000]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json(), {
-                'detail': "One or more threads to merge could not be found.",
-            }
+            response.json(),
+            {"detail": "One or more threads to merge could not be found."},
         )
 
     def test_merge_with_invisible_thread(self):
@@ -125,16 +102,13 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, unaccesible_thread.id],
-            }),
+            json.dumps({"threads": [self.thread.id, unaccesible_thread.id]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json(), {
-                'detail': "One or more threads to merge could not be found.",
-            }
+            response.json(),
+            {"detail": "One or more threads to merge could not be found."},
         )
 
     def test_merge_no_permission(self):
@@ -143,27 +117,30 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'category': self.category.id,
-                'title': 'Lorem ipsum dolor',
-                'threads': [self.thread.id, thread.id],
-            }),
+            json.dumps(
+                {
+                    "category": self.category.id,
+                    "title": "Lorem ipsum dolor",
+                    "threads": [self.thread.id, thread.id],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json(), [
+            response.json(),
+            [
                 {
-                    'id': thread.pk,
-                    'title': thread.title,
-                    'errors': ["You can't merge threads in this category."],
+                    "id": thread.pk,
+                    "title": thread.title,
+                    "errors": ["You can't merge threads in this category."],
                 },
                 {
-                    'id': self.thread.pk,
-                    'title': self.thread.title,
-                    'errors': ["You can't merge threads in this category."],
+                    "id": self.thread.pk,
+                    "title": self.thread.title,
+                    "errors": ["You can't merge threads in this category."],
                 },
-            ]
+            ],
         )
 
     @patch_other_category_acl()
@@ -177,26 +154,35 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'category': self.other_category.id,
-                'title': 'Lorem ipsum dolor',
-                'threads': [self.thread.id, other_thread.id],
-            }),
+            json.dumps(
+                {
+                    "category": self.other_category.id,
+                    "title": "Lorem ipsum dolor",
+                    "threads": [self.thread.id, other_thread.id],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), [
-            {
-                "id": other_thread.id,
-                "title": other_thread.title,
-                "errors": ["This category is closed. You can't merge it's threads."],
-            },
-            {
-                "id": self.thread.id,
-                "title": self.thread.title,
-                "errors": ["This category is closed. You can't merge it's threads."],
-            },
-        ])
+        self.assertEqual(
+            response.json(),
+            [
+                {
+                    "id": other_thread.id,
+                    "title": other_thread.title,
+                    "errors": [
+                        "This category is closed. You can't merge it's threads."
+                    ],
+                },
+                {
+                    "id": self.thread.id,
+                    "title": self.thread.title,
+                    "errors": [
+                        "This category is closed. You can't merge it's threads."
+                    ],
+                },
+            ],
+        )
 
     @patch_other_category_acl()
     @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
@@ -209,21 +195,28 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'category': self.other_category.id,
-                'title': 'Lorem ipsum dolor',
-                'threads': [self.thread.id, other_thread.id],
-            }),
+            json.dumps(
+                {
+                    "category": self.other_category.id,
+                    "title": "Lorem ipsum dolor",
+                    "threads": [self.thread.id, other_thread.id],
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), [
-            {
-                "id": other_thread.id,
-                "title": other_thread.title,
-                "errors": ["This thread is closed. You can't merge it with other threads."],
-            },
-        ])
+        self.assertEqual(
+            response.json(),
+            [
+                {
+                    "id": other_thread.id,
+                    "title": other_thread.title,
+                    "errors": [
+                        "This thread is closed. You can't merge it with other threads."
+                    ],
+                }
+            ],
+        )
 
     @patch_category_acl({"can_merge_threads": True})
     def test_merge_too_many_threads(self):
@@ -234,16 +227,16 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': threads,
-            }),
+            json.dumps({"threads": threads}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 403)
         self.assertEqual(
-            response.json(), {
-                'detail': "No more than %s threads can be merged at single time." % THREADS_LIMIT,
-            }
+            response.json(),
+            {
+                "detail": "No more than %s threads can be merged at single time."
+                % THREADS_LIMIT
+            },
         )
 
     @patch_category_acl({"can_merge_threads": True})
@@ -253,17 +246,16 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-            }),
+            json.dumps({"threads": [self.thread.id, thread.id]}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'title': ['This field is required.'],
-                'category': ['This field is required.'],
-            }
+            response.json(),
+            {
+                "title": ["This field is required."],
+                "category": ["This field is required."],
+            },
         )
 
     @patch_category_acl({"can_merge_threads": True})
@@ -273,18 +265,23 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': '$$$',
-                'category': self.category.id,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "$$$",
+                    "category": self.category.id,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'title': ["Thread title should be at least 5 characters long (it has 3)."],
-            }
+            response.json(),
+            {
+                "title": [
+                    "Thread title should be at least 5 characters long (it has 3)."
+                ]
+            },
         )
 
     @patch_category_acl({"can_merge_threads": True})
@@ -294,18 +291,18 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': 'Valid thread title',
-                'category': self.other_category.id,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "Valid thread title",
+                    "category": self.other_category.id,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'category': ["Requested category could not be found."],
-            }
+            response.json(), {"category": ["Requested category could not be found."]}
         )
 
     @patch_category_acl({"can_merge_threads": True, "can_start_threads": False})
@@ -315,18 +312,19 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': 'Valid thread title',
-                'category': self.category.id,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "Valid thread title",
+                    "category": self.category.id,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'category': ["You can't create new threads in selected category."],
-            }
+            response.json(),
+            {"category": ["You can't create new threads in selected category."]},
         )
 
     @patch_category_acl({"can_merge_threads": True})
@@ -336,19 +334,20 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': 'Valid thread title',
-                'category': self.category.id,
-                'weight': 4,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "Valid thread title",
+                    "category": self.category.id,
+                    "weight": 4,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'weight': ["Ensure this value is less than or equal to 2."],
-            }
+            response.json(),
+            {"weight": ["Ensure this value is less than or equal to 2."]},
         )
 
     @patch_category_acl({"can_merge_threads": True})
@@ -358,19 +357,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': 'Valid thread title',
-                'category': self.category.id,
-                'weight': 2,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "Valid thread title",
+                    "category": self.category.id,
+                    "weight": 2,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'weight': ["You don't have permission to pin threads globally in this category."],
-            }
+            response.json(),
+            {
+                "weight": [
+                    "You don't have permission to pin threads globally in this category."
+                ]
+            },
         )
 
     @patch_category_acl({"can_merge_threads": True})
@@ -380,19 +384,20 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': 'Valid thread title',
-                'category': self.category.id,
-                'weight': 1,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "Valid thread title",
+                    "category": self.category.id,
+                    "weight": 1,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'weight': ["You don't have permission to pin threads in this category."],
-            }
+            response.json(),
+            {"weight": ["You don't have permission to pin threads in this category."]},
         )
 
     @patch_category_acl({"can_merge_threads": True, "can_pin_threads": 1})
@@ -402,19 +407,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': '$$$',
-                'category': self.category.id,
-                'weight': 1,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "$$$",
+                    "category": self.category.id,
+                    "weight": 1,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'title': ["Thread title should be at least 5 characters long (it has 3)."],
-            }
+            response.json(),
+            {
+                "title": [
+                    "Thread title should be at least 5 characters long (it has 3)."
+                ]
+            },
         )
 
     @patch_category_acl({"can_merge_threads": True, "can_pin_threads": 2})
@@ -424,19 +434,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': '$$$',
-                'category': self.category.id,
-                'weight': 2,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "$$$",
+                    "category": self.category.id,
+                    "weight": 2,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'title': ["Thread title should be at least 5 characters long (it has 3)."],
-            }
+            response.json(),
+            {
+                "title": [
+                    "Thread title should be at least 5 characters long (it has 3)."
+                ]
+            },
         )
 
     @patch_category_acl({"can_merge_threads": True, "can_close_threads": False})
@@ -446,19 +461,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': 'Valid thread title',
-                'category': self.category.id,
-                'is_closed': True,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "Valid thread title",
+                    "category": self.category.id,
+                    "is_closed": True,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'is_closed': ["You don't have permission to close threads in this category."],
-            }
+            response.json(),
+            {
+                "is_closed": [
+                    "You don't have permission to close threads in this category."
+                ]
+            },
         )
 
     @patch_category_acl({"can_merge_threads": True, "can_close_threads": True})
@@ -468,20 +488,25 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': '$$$',
-                'category': self.category.id,
-                'weight': 0,
-                'is_closed': True,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "$$$",
+                    "category": self.category.id,
+                    "weight": 0,
+                    "is_closed": True,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'title': ["Thread title should be at least 5 characters long (it has 3)."],
-            }
+            response.json(),
+            {
+                "title": [
+                    "Thread title should be at least 5 characters long (it has 3)."
+                ]
+            },
         )
 
     @patch_category_acl({"can_merge_threads": True, "can_hide_threads": 0})
@@ -491,19 +516,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': 'Valid thread title',
-                'category': self.category.id,
-                'is_hidden': True,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "Valid thread title",
+                    "category": self.category.id,
+                    "is_hidden": True,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'is_hidden': ["You don't have permission to hide threads in this category."],
-            }
+            response.json(),
+            {
+                "is_hidden": [
+                    "You don't have permission to hide threads in this category."
+                ]
+            },
         )
 
     @patch_category_acl({"can_merge_threads": True, "can_hide_threads": 1})
@@ -513,20 +543,25 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': '$$$',
-                'category': self.category.id,
-                'weight': 0,
-                'is_hidden': True,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "$$$",
+                    "category": self.category.id,
+                    "weight": 0,
+                    "is_hidden": True,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'title': ["Thread title should be at least 5 characters long (it has 3)."],
-            }
+            response.json(),
+            {
+                "title": [
+                    "Thread title should be at least 5 characters long (it has 3)."
+                ]
+            },
         )
 
     @patch_category_acl({"can_merge_threads": True})
@@ -537,11 +572,13 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -549,7 +586,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         # is response json with new thread?
         response_json = response.json()
 
-        new_thread = Thread.objects.get(pk=response_json['id'])
+        new_thread = Thread.objects.get(pk=response_json["id"])
         new_thread.is_read = False
         new_thread.subscription = None
 
@@ -566,12 +603,14 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         # are old threads gone?
         self.assertEqual([t.pk for t in Thread.objects.all()], [new_thread.pk])
 
-    @patch_category_acl({
-        "can_merge_threads": True,
-        "can_close_threads": True,
-        "can_hide_threads": 1,
-        "can_pin_threads": 2,
-    })
+    @patch_category_acl(
+        {
+            "can_merge_threads": True,
+            "can_close_threads": True,
+            "can_hide_threads": 1,
+            "can_pin_threads": 2,
+        }
+    )
     def test_merge_kitchensink(self):
         """api performs merge"""
         posts_ids = [p.id for p in Post.objects.all()]
@@ -595,14 +634,16 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-                'is_closed': 1,
-                'is_hidden': 1,
-                'weight': 2,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                    "is_closed": 1,
+                    "is_hidden": 1,
+                    "weight": 2,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -610,7 +651,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         # is response json with new thread?
         response_json = response.json()
 
-        new_thread = Thread.objects.get(pk=response_json['id'])
+        new_thread = Thread.objects.get(pk=response_json["id"])
         new_thread.is_read = False
         new_thread.subscription = None
 
@@ -632,10 +673,10 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual([t.pk for t in Thread.objects.all()], [new_thread.pk])
 
         # posts reads are kept
-        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(
-            list(postreads.values_list('post_id', flat=True)),
+            list(postreads.values_list("post_id", flat=True)),
             [self.thread.first_post_id, thread.first_post_id],
         )
         self.assertEqual(postreads.filter(thread=new_thread).count(), 2)
@@ -657,17 +698,19 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
-        
+
         # best answer is set on new thread
-        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)
 
     @patch_category_acl({"can_merge_threads": True})
@@ -676,36 +719,44 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-        
+
         other_thread = testutils.post_thread(self.category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
         other_thread.save()
 
         response = self.client.post(
-            self.api_link, 
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-            }),
+            self.api_link,
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'best_answers': [
-                ['0', "Unmark all best answers"],
-                [str(self.thread.id), self.thread.title],
-                [str(other_thread.id), other_thread.title],
-            ]
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "best_answers": [
+                    ["0", "Unmark all best answers"],
+                    [str(self.thread.id), self.thread.title],
+                    [str(other_thread.id), other_thread.title],
+                ]
+            },
+        )
 
         # best answers were untouched
         self.assertEqual(self.thread.post_set.count(), 2)
         self.assertEqual(other_thread.post_set.count(), 2)
-        self.assertEqual(Thread.objects.get(pk=self.thread.pk).best_answer_id, best_answer.id)
         self.assertEqual(
-            Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
+            Thread.objects.get(pk=self.thread.pk).best_answer_id, best_answer.id
+        )
+        self.assertEqual(
+            Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id
+        )
 
     @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_best_answer_invalid_resolution(self):
@@ -713,7 +764,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-        
+
         other_thread = testutils.post_thread(self.category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
@@ -721,23 +772,28 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-                'best_answer': other_thread.id + 10,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                    "best_answer": other_thread.id + 10,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {'best_answer': ["Invalid choice."]})
+        self.assertEqual(response.json(), {"best_answer": ["Invalid choice."]})
 
         # best answers were untouched
         self.assertEqual(self.thread.post_set.count(), 2)
         self.assertEqual(other_thread.post_set.count(), 2)
-        self.assertEqual(Thread.objects.get(pk=self.thread.pk).best_answer_id, best_answer.id)
         self.assertEqual(
-            Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id)
+            Thread.objects.get(pk=self.thread.pk).best_answer_id, best_answer.id
+        )
+        self.assertEqual(
+            Thread.objects.get(pk=other_thread.pk).best_answer_id, other_best_answer.id
+        )
 
     @patch_category_acl({"can_merge_threads": True})
     def test_threads_merge_conflict_unmark_all_best_answers(self):
@@ -745,7 +801,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-        
+
         other_thread = testutils.post_thread(self.category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
@@ -753,18 +809,20 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-                'best_answer': 0,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                    "best_answer": 0,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
         # best answer is not set on new thread
-        new_thread = Thread.objects.get(pk=response.json()['id'])
+        new_thread = Thread.objects.get(pk=response.json()["id"])
         self.assertFalse(new_thread.has_best_answer)
         self.assertIsNone(new_thread.best_answer_id)
 
@@ -774,7 +832,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-        
+
         other_thread = testutils.post_thread(self.category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
@@ -782,18 +840,20 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-                'best_answer': self.thread.pk,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                    "best_answer": self.thread.pk,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
         # selected best answer is set on new thread
-        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)
 
     @patch_category_acl({"can_merge_threads": True})
@@ -802,7 +862,7 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         best_answer = testutils.reply_thread(self.thread)
         self.thread.set_best_answer(self.user, best_answer)
         self.thread.save()
-        
+
         other_thread = testutils.post_thread(self.category)
         other_best_answer = testutils.reply_thread(other_thread)
         other_thread.set_best_answer(self.user, other_best_answer)
@@ -810,18 +870,20 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-                'best_answer': other_thread.pk,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                    "best_answer": other_thread.pk,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
         # selected best answer is set on new thread
-        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)
 
     @patch_category_acl({"can_merge_threads": True})
@@ -832,20 +894,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
-        new_thread = Thread.objects.get(pk=response.json()['id'])
+        new_thread = Thread.objects.get(pk=response.json()["id"])
 
         # poll and its votes were kept
         self.assertEqual(Poll.objects.filter(pk=poll.pk, thread=new_thread).count(), 1)
-        self.assertEqual(PollVote.objects.filter(poll=poll, thread=new_thread).count(), 4)
+        self.assertEqual(
+            PollVote.objects.filter(poll=poll, thread=new_thread).count(), 4
+        )
 
         self.assertEqual(Poll.objects.count(), 1)
         self.assertEqual(PollVote.objects.count(), 4)
@@ -858,20 +924,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
-        new_thread = Thread.objects.get(pk=response.json()['id'])
+        new_thread = Thread.objects.get(pk=response.json()["id"])
 
         # poll and its votes were kept
         self.assertEqual(Poll.objects.filter(pk=poll.pk, thread=new_thread).count(), 1)
-        self.assertEqual(PollVote.objects.filter(poll=poll, thread=new_thread).count(), 4)
+        self.assertEqual(
+            PollVote.objects.filter(poll=poll, thread=new_thread).count(), 4
+        )
 
         self.assertEqual(Poll.objects.count(), 1)
         self.assertEqual(PollVote.objects.count(), 4)
@@ -885,29 +955,29 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                }
+            ),
             content_type="application/json",
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'polls': [
-                    ['0', "Delete all polls"],
+            response.json(),
+            {
+                "polls": [
+                    ["0", "Delete all polls"],
                     [
                         str(other_poll.pk),
-                        '%s (%s)' % (other_poll.question, other_poll.thread.title),
-                    ],
-                    [
-                        str(poll.pk),
-                        '%s (%s)' % (poll.question, poll.thread.title),
+                        "%s (%s)" % (other_poll.question, other_poll.thread.title),
                     ],
-                ],
-            }
+                    [str(poll.pk), "%s (%s)" % (poll.question, poll.thread.title)],
+                ]
+            },
         )
 
         # polls and votes were untouched
@@ -924,19 +994,19 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-                'poll': other_thread.poll.id + 10,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                    "poll": other_thread.poll.id + 10,
+                }
+            ),
             content_type="application/json",
         )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'poll': ["Invalid choice."],
-        })
+        self.assertEqual(response.json(), {"poll": ["Invalid choice."]})
 
         # polls and votes were untouched
         self.assertEqual(Poll.objects.count(), 2)
@@ -952,12 +1022,14 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-                'poll': 0,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                    "poll": 0,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -975,12 +1047,14 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-                'poll': poll.pk,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                    "poll": poll.pk,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -1002,12 +1076,14 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         response = self.client.post(
             self.api_link,
-            json.dumps({
-                'threads': [self.thread.id, other_thread.id],
-                'title': 'Merged thread!',
-                'category': self.category.id,
-                'poll': other_poll.pk,
-            }),
+            json.dumps(
+                {
+                    "threads": [self.thread.id, other_thread.id],
+                    "title": "Merged thread!",
+                    "category": self.category.id,
+                    "poll": other_poll.pk,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)

+ 22 - 23
misago/threads/tests/test_threads_moderation.py

@@ -7,7 +7,7 @@ from misago.users.testutils import AuthenticatedUserTestCase
 class MockRequest(object):
     def __init__(self, user):
         self.user = user
-        self.user_ip = '123.14.15.222'
+        self.user_ip = "123.14.15.222"
 
 
 class ThreadsModerationTests(AuthenticatedUserTestCase):
@@ -27,17 +27,19 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
     def test_change_thread_title(self):
         """change_thread_title changes thread's title and slug"""
         self.assertTrue(
-            moderation.change_thread_title(self.request, self.thread, "New title is here!")
+            moderation.change_thread_title(
+                self.request, self.thread, "New title is here!"
+            )
         )
 
         self.reload_thread()
         self.assertEqual(self.thread.title, "New title is here!")
-        self.assertEqual(self.thread.slug, 'new-title-is-here')
+        self.assertEqual(self.thread.slug, "new-title-is-here")
 
         event = self.thread.last_post
 
         self.assertTrue(event.is_event)
-        self.assertEqual(event.event_type, 'changed_title')
+        self.assertEqual(event.event_type, "changed_title")
 
     def test_pin_globally_thread(self):
         """pin_thread_globally makes thread pinned globally"""
@@ -50,7 +52,7 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         event = self.thread.last_post
 
         self.assertTrue(event.is_event)
-        self.assertEqual(event.event_type, 'pinned_globally')
+        self.assertEqual(event.event_type, "pinned_globally")
 
     def test_pin_globally_invalid_thread(self):
         """
@@ -72,7 +74,7 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         event = self.thread.last_post
 
         self.assertTrue(event.is_event)
-        self.assertEqual(event.event_type, 'pinned_locally')
+        self.assertEqual(event.event_type, "pinned_locally")
 
     def test_pin_invalid_thread(self):
         """pin_thread_locally returns false for already locally pinned thread"""
@@ -94,7 +96,7 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         event = self.thread.last_post
 
         self.assertTrue(event.is_event)
-        self.assertEqual(event.event_type, 'unpinned')
+        self.assertEqual(event.event_type, "unpinned")
 
     def test_unpin_locally_pinned_thread(self):
         """unpin_thread unpins locally pinned thread"""
@@ -109,7 +111,7 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         event = self.thread.last_post
 
         self.assertTrue(event.is_event)
-        self.assertEqual(event.event_type, 'unpinned')
+        self.assertEqual(event.event_type, "unpinned")
 
     def test_unpin_weightless_thread(self):
         """unpin_thread returns false for already weightless thread"""
@@ -131,20 +133,15 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         event = self.thread.last_post
 
         self.assertTrue(event.is_event)
-        self.assertEqual(event.event_type, 'approved')
+        self.assertEqual(event.event_type, "approved")
 
     def test_move_thread(self):
         """moves_thread moves unapproved thread to other category"""
         root_category = Category.objects.root_category()
-        Category(
-            name='New Category',
-            slug='new-category',
-        ).insert_at(
-            root_category,
-            position='last-child',
-            save=True,
+        Category(name="New Category", slug="new-category").insert_at(
+            root_category, position="last-child", save=True
         )
-        new_category = Category.objects.get(slug='new-category')
+        new_category = Category.objects.get(slug="new-category")
 
         self.assertEqual(self.thread.category, self.category)
         self.assertTrue(moderation.move_thread(self.request, self.thread, new_category))
@@ -155,12 +152,14 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         event = self.thread.last_post
 
         self.assertTrue(event.is_event)
-        self.assertEqual(event.event_type, 'moved')
+        self.assertEqual(event.event_type, "moved")
 
     def test_move_thread_to_same_category(self):
         """moves_thread does not move thread to same category it is in"""
         self.assertEqual(self.thread.category, self.category)
-        self.assertFalse(moderation.move_thread(self.request, self.thread, self.category))
+        self.assertFalse(
+            moderation.move_thread(self.request, self.thread, self.category)
+        )
 
         self.reload_thread()
         self.assertEqual(self.thread.category, self.category)
@@ -176,7 +175,7 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         event = self.thread.last_post
 
         self.assertTrue(event.is_event)
-        self.assertEqual(event.event_type, 'closed')
+        self.assertEqual(event.event_type, "closed")
 
     def test_close_invalid_thread(self):
         """close_thread fails gracefully for opened thread"""
@@ -200,7 +199,7 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         event = self.thread.last_post
 
         self.assertTrue(event.is_event)
-        self.assertEqual(event.event_type, 'opened')
+        self.assertEqual(event.event_type, "opened")
 
     def test_open_invalid_thread(self):
         """open_thread fails gracefully for opened thread"""
@@ -218,7 +217,7 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         event = self.thread.last_post
 
         self.assertTrue(event.is_event)
-        self.assertEqual(event.event_type, 'hid')
+        self.assertEqual(event.event_type, "hid")
 
     def test_hide_hidden_thread(self):
         """hide_thread fails gracefully for hidden thread"""
@@ -239,7 +238,7 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         event = self.thread.last_post
 
         self.assertTrue(event.is_event)
-        self.assertEqual(event.event_type, 'unhid')
+        self.assertEqual(event.event_type, "unhid")
 
     def test_unhide_visible_thread(self):
         """unhide_thread fails gracefully for visible thread"""

+ 382 - 455
misago/threads/tests/test_threadslists.py

@@ -12,44 +12,48 @@ from misago.threads import testutils
 from misago.users.models import AnonymousUser
 from misago.users.testutils import AuthenticatedUserTestCase
 
-LISTS_URLS = ('', 'my/', 'new/', 'unread/', 'subscribed/', )
+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': [],
-        })
+        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
+            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,
-            })
+            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)
+                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)
 
@@ -71,90 +75,54 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
         """
         super().setUp()
 
-        self.api_link = reverse('misago:api:thread-list')
+        self.api_link = reverse("misago:api:thread-list")
 
         self.root = Category.objects.root_category()
-        self.first_category = Category.objects.get(slug='first-category')
+        self.first_category = Category.objects.get(slug="first-category")
 
         Category(
-            name='Category A',
-            slug='category-a',
-            css_class='showing-category-a',
-        ).insert_at(
-            self.root,
-            position='last-child',
-            save=True,
-        )
+            name="Category A", slug="category-a", css_class="showing-category-a"
+        ).insert_at(self.root, position="last-child", save=True)
         Category(
-            name='Category E',
-            slug='category-e',
-            css_class='showing-category-e',
-        ).insert_at(
-            self.root,
-            position='last-child',
-            save=True,
-        )
+            name="Category E", slug="category-e", css_class="showing-category-e"
+        ).insert_at(self.root, position="last-child", save=True)
 
         self.root = Category.objects.root_category()
 
-        self.category_a = Category.objects.get(slug='category-a')
+        self.category_a = Category.objects.get(slug="category-a")
 
         Category(
-            name='Category B',
-            slug='category-b',
-            css_class='showing-category-b',
-        ).insert_at(
-            self.category_a,
-            position='last-child',
-            save=True,
-        )
+            name="Category B", slug="category-b", css_class="showing-category-b"
+        ).insert_at(self.category_a, position="last-child", save=True)
 
-        self.category_b = Category.objects.get(slug='category-b')
+        self.category_b = Category.objects.get(slug="category-b")
 
         Category(
-            name='Category C',
-            slug='category-c',
-            css_class='showing-category-c',
-        ).insert_at(
-            self.category_b,
-            position='last-child',
-            save=True,
-        )
+            name="Category C", slug="category-c", css_class="showing-category-c"
+        ).insert_at(self.category_b, position="last-child", save=True)
         Category(
-            name='Category D',
-            slug='category-d',
-            css_class='showing-category-d',
-        ).insert_at(
-            self.category_b,
-            position='last-child',
-            save=True,
-        )
+            name="Category D", slug="category-d", css_class="showing-category-d"
+        ).insert_at(self.category_b, position="last-child", save=True)
 
-        self.category_c = Category.objects.get(slug='category-c')
-        self.category_d = Category.objects.get(slug='category-d')
+        self.category_c = Category.objects.get(slug="category-c")
+        self.category_d = Category.objects.get(slug="category-d")
 
-        self.category_e = Category.objects.get(slug='category-e')
+        self.category_e = Category.objects.get(slug="category-e")
         Category(
-            name='Category F',
-            slug='category-f',
-            css_class='showing-category-f',
-        ).insert_at(
-            self.category_e,
-            position='last-child',
-            save=True,
-        )
+            name="Category F", slug="category-f", css_class="showing-category-f"
+        ).insert_at(self.category_e, position="last-child", save=True)
 
-        self.category_f = Category.objects.get(slug='category-f')
+        self.category_f = Category.objects.get(slug="category-f")
 
         Category.objects.partial_rebuild(self.root.tree_id)
 
         self.root = Category.objects.root_category()
-        self.category_a = Category.objects.get(slug='category-a')
-        self.category_b = Category.objects.get(slug='category-b')
-        self.category_c = Category.objects.get(slug='category-c')
-        self.category_d = Category.objects.get(slug='category-d')
-        self.category_e = Category.objects.get(slug='category-e')
-        self.category_f = Category.objects.get(slug='category-f')
+        self.category_a = Category.objects.get(slug="category-a")
+        self.category_b = Category.objects.get(slug="category-b")
+        self.category_c = Category.objects.get(slug="category-c")
+        self.category_d = Category.objects.get(slug="category-d")
+        self.category_e = Category.objects.get(slug="category-e")
+        self.category_f = Category.objects.get(slug="category-f")
 
     def assertContainsThread(self, response, thread):
         self.assertContains(response, ' href="%s"' % thread.get_absolute_url())
@@ -166,17 +134,21 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
 class ApiTests(ThreadsListTestCase):
     def test_root_category(self):
         """its possible to access threads endpoint with category=ROOT_ID"""
-        response = self.client.get('%s?category=%s' % (self.api_link, self.root.pk))
+        response = self.client.get("%s?category=%s" % (self.api_link, self.root.pk))
         self.assertEqual(response.status_code, 200)
 
     def test_explicit_first_page(self):
         """its possible to access threads endpoint with explicit first page"""
-        response = self.client.get('%s?category=%s&page=1' % (self.api_link, self.root.pk))
+        response = self.client.get(
+            "%s?category=%s&page=1" % (self.api_link, self.root.pk)
+        )
         self.assertEqual(response.status_code, 200)
 
     def test_invalid_list_type(self):
         """api returns 404 for invalid list type"""
-        response = self.client.get('%s?category=%s&list=nope' % (self.api_link, self.root.pk))
+        response = self.client.get(
+            "%s?category=%s&list=nope" % (self.api_link, self.root.pk)
+        )
         self.assertEqual(response.status_code, 404)
 
 
@@ -185,7 +157,7 @@ class AllThreadsListTests(ThreadsListTestCase):
     def test_list_renders_empty(self):
         """empty threads list renders"""
         for url in LISTS_URLS:
-            response = self.client.get('/' + url)
+            response = self.client.get("/" + url)
             self.assertEqual(response.status_code, 200)
             self.assertContains(response, "empty-message")
             if url:
@@ -202,17 +174,19 @@ class AllThreadsListTests(ThreadsListTestCase):
             else:
                 self.assertContains(response, "There are no threads in this category")
 
-            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)
 
             response_json = response.json()
-            self.assertEqual(len(response_json['results']), 0)
+            self.assertEqual(len(response_json["results"]), 0)
 
         # empty lists render for anonymous user?
         self.logout_user()
         self.user = self.get_anonymous_user()
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
         self.assertContains(response, "There are no threads on this forum")
@@ -223,17 +197,17 @@ class AllThreadsListTests(ThreadsListTestCase):
         self.assertContains(response, "empty-message")
         self.assertContains(response, "There are no threads in this category")
 
-        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)
 
         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):
         """authenticated only views return 403 for guests"""
         for url in LISTS_URLS:
-            response = self.client.get('/' + url)
+            response = self.client.get("/" + url)
             self.assertEqual(response.status_code, 200)
 
             response = self.client.get(self.category_b.get_absolute_url() + url)
@@ -241,141 +215,130 @@ class AllThreadsListTests(ThreadsListTestCase):
             self.assertContains(response, self.category_b.name)
 
             response = self.client.get(
-                '%s?category=%s&list=%s' %
-                (self.api_link, self.category_b.pk, url.strip('/') or 'all', )
+                "%s?category=%s&list=%s"
+                % (self.api_link, self.category_b.pk, url.strip("/") or "all")
             )
             self.assertEqual(response.status_code, 200)
 
         self.logout_user()
         self.user = self.get_anonymous_user()
         for url in LISTS_URLS[1:]:
-            response = self.client.get('/' + url)
+            response = self.client.get("/" + url)
             self.assertEqual(response.status_code, 403)
 
             response = self.client.get(self.category_b.get_absolute_url() + url)
             self.assertEqual(response.status_code, 403)
 
             response = self.client.get(
-                '%s?category=%s&list=%s' %
-                (self.api_link, self.category_b.pk, url.strip('/') or 'all', )
+                "%s?category=%s&list=%s"
+                % (self.api_link, self.category_b.pk, url.strip("/") or "all")
             )
             self.assertEqual(response.status_code, 403)
 
     @patch_categories_acl()
     def test_list_renders_categories_picker(self):
         """categories picker renders valid categories"""
-        Category(
-            name='Hidden Category',
-            slug='hidden-category',
-        ).insert_at(
-            self.root,
-            position='last-child',
-            save=True,
+        Category(name="Hidden Category", slug="hidden-category").insert_at(
+            self.root, position="last-child", save=True
         )
-        test_category = Category.objects.get(slug='hidden-category')
+        test_category = Category.objects.get(slug="hidden-category")
 
-        testutils.post_thread(
-            category=self.category_b,
-        )
+        testutils.post_thread(category=self.category_b)
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
 
-        self.assertContains(response, 'subcategory-%s' % self.category_a.css_class)
+        self.assertContains(response, "subcategory-%s" % self.category_a.css_class)
 
         # readable categories, but non-accessible directly
-        self.assertNotContains(response, 'subcategory-%s' % self.category_b.css_class)
-        self.assertNotContains(response, 'subcategory-%s' % self.category_c.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_b.css_class)
+        self.assertNotContains(response, "subcategory-%s" % self.category_c.css_class)
+        self.assertNotContains(response, "subcategory-%s" % self.category_d.css_class)
+        self.assertNotContains(response, "subcategory-%s" % self.category_f.css_class)
 
         # hidden category
-        self.assertNotContains(response, 'subcategory-%s' % test_category.css_class)
+        self.assertNotContains(response, "subcategory-%s" % test_category.css_class)
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertIn(self.category_a.pk, response_json['subcategories'])
-        self.assertNotIn(self.category_b.pk, response_json['subcategories'])
+        self.assertIn(self.category_a.pk, response_json["subcategories"])
+        self.assertNotIn(self.category_b.pk, response_json["subcategories"])
 
         # test category view
         response = self.client.get(self.category_a.get_absolute_url())
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'subcategory-%s' % self.category_b.css_class)
+        self.assertContains(response, "subcategory-%s" % self.category_b.css_class)
 
         # readable categories, but non-accessible directly
-        self.assertNotContains(response, 'subcategory-%s' % self.category_c.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_c.css_class)
+        self.assertNotContains(response, "subcategory-%s" % self.category_d.css_class)
+        self.assertNotContains(response, "subcategory-%s" % self.category_f.css_class)
 
-        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)
 
         response_json = response.json()
-        self.assertEqual(response_json['subcategories'][0], self.category_b.pk)
+        self.assertEqual(response_json["subcategories"][0], self.category_b.pk)
 
     def test_display_pinned_threads(self):
         """
         threads list displays globally pinned threads first
         and locally ones inbetween other
         """
-        globally = testutils.post_thread(
-            category=self.first_category,
-            is_global=True,
-        )
+        globally = testutils.post_thread(category=self.first_category, is_global=True)
 
-        locally = testutils.post_thread(
-            category=self.first_category,
-            is_pinned=True,
-        )
+        locally = testutils.post_thread(category=self.first_category, is_pinned=True)
 
         standard = testutils.post_thread(category=self.first_category)
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
 
         content = smart_str(response.content)
         positions = {
-            'g': content.find(globally.get_absolute_url()),
-            'l': content.find(locally.get_absolute_url()),
-            's': content.find(standard.get_absolute_url()),
+            "g": content.find(globally.get_absolute_url()),
+            "l": content.find(locally.get_absolute_url()),
+            "s": content.find(standard.get_absolute_url()),
         }
 
         # global announcement before others
-        self.assertTrue(positions['g'] < positions['l'])
-        self.assertTrue(positions['g'] < positions['s'])
+        self.assertTrue(positions["g"] < positions["l"])
+        self.assertTrue(positions["g"] < positions["s"])
 
         # standard in the middle
-        self.assertTrue(positions['s'] < positions['l'])
-        self.assertTrue(positions['s'] > positions['g'])
+        self.assertTrue(positions["s"] < positions["l"])
+        self.assertTrue(positions["s"] > positions["g"])
 
         # pinned last
-        self.assertTrue(positions['l'] > positions['g'])
-        self.assertTrue(positions['l'] > positions['s'])
+        self.assertTrue(positions["l"] > positions["g"])
+        self.assertTrue(positions["l"] > positions["s"])
 
         # API behaviour is identic
-        response = self.client.get('/api/threads/')
+        response = self.client.get("/api/threads/")
         self.assertEqual(response.status_code, 200)
 
         content = smart_str(response.content)
         positions = {
-            'g': content.find(globally.get_absolute_url()),
-            'l': content.find(locally.get_absolute_url()),
-            's': content.find(standard.get_absolute_url()),
+            "g": content.find(globally.get_absolute_url()),
+            "l": content.find(locally.get_absolute_url()),
+            "s": content.find(standard.get_absolute_url()),
         }
 
         # global announcement before others
-        self.assertTrue(positions['g'] < positions['l'])
-        self.assertTrue(positions['g'] < positions['s'])
+        self.assertTrue(positions["g"] < positions["l"])
+        self.assertTrue(positions["g"] < positions["s"])
 
         # standard in the middle
-        self.assertTrue(positions['s'] < positions['l'])
-        self.assertTrue(positions['s'] > positions['g'])
+        self.assertTrue(positions["s"] < positions["l"])
+        self.assertTrue(positions["s"] > positions["g"])
 
         # pinned last
-        self.assertTrue(positions['l'] > positions['g'])
-        self.assertTrue(positions['l'] > positions['s'])
+        self.assertTrue(positions["l"] > positions["g"])
+        self.assertTrue(positions["l"] > positions["s"])
 
     def test_noscript_pagination(self):
         """threads list is paginated for users with js disabled"""
@@ -386,21 +349,21 @@ class AllThreadsListTests(ThreadsListTestCase):
             threads.append(testutils.post_thread(category=self.first_category))
 
         # secondary page renders
-        response = self.client.get('/?page=2')
+        response = self.client.get("/?page=2")
         self.assertEqual(response.status_code, 200)
 
         for thread in threads[:threads_per_page]:
             self.assertNotContainsThread(response, thread)
-        for thread in threads[threads_per_page:threads_per_page * 2]:
+        for thread in threads[threads_per_page : threads_per_page * 2]:
             self.assertContainsThread(response, thread)
-        for thread in threads[threads_per_page * 2:]:
+        for thread in threads[threads_per_page * 2 :]:
             self.assertNotContainsThread(response, thread)
 
-        self.assertNotContains(response, '/?page=1')
-        self.assertContains(response, '/?page=3')
+        self.assertNotContains(response, "/?page=1")
+        self.assertContains(response, "/?page=3")
 
         # third page renders
-        response = self.client.get('/?page=3')
+        response = self.client.get("/?page=3")
         self.assertEqual(response.status_code, 200)
 
         for thread in threads[threads_per_page:]:
@@ -408,62 +371,52 @@ class AllThreadsListTests(ThreadsListTestCase):
         for thread in threads[:threads_per_page]:
             self.assertContainsThread(response, thread)
 
-        self.assertContains(response, '/?page=2')
-        self.assertNotContains(response, '/?page=4')
+        self.assertContains(response, "/?page=2")
+        self.assertNotContains(response, "/?page=4")
 
         # excessive page gives 404
-        response = self.client.get('/?page=4')
+        response = self.client.get("/?page=4")
         self.assertEqual(response.status_code, 404)
 
 
 class CategoryThreadsListTests(ThreadsListTestCase):
     def test_access_hidden_category(self):
         """hidden category returns 404"""
-        Category(
-            name='Hidden Category',
-            slug='hidden-category',
-        ).insert_at(
-            self.root,
-            position='last-child',
-            save=True,
+        Category(name="Hidden Category", slug="hidden-category").insert_at(
+            self.root, position="last-child", save=True
         )
-        test_category = Category.objects.get(slug='hidden-category')
+        test_category = Category.objects.get(slug="hidden-category")
 
         for url in LISTS_URLS:
             response = self.client.get(test_category.get_absolute_url() + url)
             self.assertEqual(response.status_code, 404)
 
-            response = self.client.get('%s?category=%s' % (self.api_link, test_category.id))
+            response = self.client.get(
+                "%s?category=%s" % (self.api_link, test_category.id)
+            )
             self.assertEqual(response.status_code, 404)
 
     def test_access_protected_category(self):
         """protected category returns 403"""
-        Category(
-            name='Hidden Category',
-            slug='hidden-category',
-        ).insert_at(
-            self.root,
-            position='last-child',
-            save=True,
+        Category(name="Hidden Category", slug="hidden-category").insert_at(
+            self.root, position="last-child", save=True
         )
-        test_category = Category.objects.get(slug='hidden-category')
+        test_category = Category.objects.get(slug="hidden-category")
 
         for url in LISTS_URLS:
-            with patch_user_acl({
-                'visible_categories': [test_category.id],
-                'browseable_categories': [],
-                'categories': {
-                    test_category.id: {
-                        '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(
-                    '%s?category=%s&list=%s' % (self.api_link, test_category.id, url.strip('/'))
+                    "%s?category=%s&list=%s"
+                    % (self.api_link, test_category.id, url.strip("/"))
                 )
                 self.assertEqual(response.status_code, 403)
 
@@ -472,15 +425,9 @@ class CategoryThreadsListTests(ThreadsListTestCase):
         category threads list displays globally pinned threads first
         then locally ones and unpinned last
         """
-        globally = testutils.post_thread(
-            category=self.first_category,
-            is_global=True,
-        )
+        globally = testutils.post_thread(category=self.first_category, is_global=True)
 
-        locally = testutils.post_thread(
-            category=self.first_category,
-            is_pinned=True,
-        )
+        locally = testutils.post_thread(category=self.first_category, is_pinned=True)
 
         standard = testutils.post_thread(category=self.first_category)
 
@@ -489,74 +436,76 @@ class CategoryThreadsListTests(ThreadsListTestCase):
 
         content = smart_str(response.content)
         positions = {
-            'g': content.find(globally.get_absolute_url()),
-            'l': content.find(locally.get_absolute_url()),
-            's': content.find(standard.get_absolute_url()),
+            "g": content.find(globally.get_absolute_url()),
+            "l": content.find(locally.get_absolute_url()),
+            "s": content.find(standard.get_absolute_url()),
         }
 
         # global announcement before others
-        self.assertTrue(positions['g'] < positions['l'])
-        self.assertTrue(positions['g'] < positions['s'])
+        self.assertTrue(positions["g"] < positions["l"])
+        self.assertTrue(positions["g"] < positions["s"])
 
         # pinned in the middle
-        self.assertTrue(positions['l'] < positions['s'])
-        self.assertTrue(positions['l'] > positions['g'])
+        self.assertTrue(positions["l"] < positions["s"])
+        self.assertTrue(positions["l"] > positions["g"])
 
         # standard last
-        self.assertTrue(positions['s'] > positions['g'])
-        self.assertTrue(positions['s'] > positions['g'])
+        self.assertTrue(positions["s"] > positions["g"])
+        self.assertTrue(positions["s"] > positions["g"])
 
         # API behaviour is identic
-        response = self.client.get('/api/threads/?category=%s' % self.first_category.id)
+        response = self.client.get("/api/threads/?category=%s" % self.first_category.id)
         self.assertEqual(response.status_code, 200)
 
         content = smart_str(response.content)
         positions = {
-            'g': content.find(globally.get_absolute_url()),
-            'l': content.find(locally.get_absolute_url()),
-            's': content.find(standard.get_absolute_url()),
+            "g": content.find(globally.get_absolute_url()),
+            "l": content.find(locally.get_absolute_url()),
+            "s": content.find(standard.get_absolute_url()),
         }
 
         # global announcement before others
-        self.assertTrue(positions['g'] < positions['l'])
-        self.assertTrue(positions['g'] < positions['s'])
+        self.assertTrue(positions["g"] < positions["l"])
+        self.assertTrue(positions["g"] < positions["s"])
 
         # pinned in the middle
-        self.assertTrue(positions['l'] < positions['s'])
-        self.assertTrue(positions['l'] > positions['g'])
+        self.assertTrue(positions["l"] < positions["s"])
+        self.assertTrue(positions["l"] > positions["g"])
 
         # standard last
-        self.assertTrue(positions['s'] > positions['g'])
-        self.assertTrue(positions['s'] > positions['g'])
+        self.assertTrue(positions["s"] > positions["g"])
+        self.assertTrue(positions["s"] > positions["g"])
 
 
 class ThreadsVisibilityTests(ThreadsListTestCase):
     @patch_categories_acl()
     def test_list_renders_test_thread(self):
         """list renders test thread with valid top category"""
-        test_thread = testutils.post_thread(
-            category=self.category_c,
-        )
+        test_thread = testutils.post_thread(category=self.category_c)
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
 
         self.assertContainsThread(response, test_thread)
 
-        self.assertContains(response, 'subcategory-%s' % self.category_a.css_class)
-        self.assertContains(response, 'subcategory-%s' % self.category_e.css_class)
+        self.assertContains(response, "subcategory-%s" % self.category_a.css_class)
+        self.assertContains(response, "subcategory-%s" % self.category_e.css_class)
 
-        self.assertNotContains(response, 'thread-detail-category-%s' % self.category_a.css_class)
-        self.assertContains(response, 'thread-detail-category-%s' % self.category_c.css_class)
+        self.assertNotContains(
+            response, "thread-detail-category-%s" % self.category_a.css_class
+        )
+        self.assertContains(
+            response, "thread-detail-category-%s" % self.category_c.css_class
+        )
 
         # api displays same data
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
-        self.assertEqual(len(response_json['subcategories']), 3)
-        self.assertIn(self.category_a.pk, response_json['subcategories'])
+        self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
+        self.assertEqual(len(response_json["subcategories"]), 3)
+        self.assertIn(self.category_a.pk, response_json["subcategories"])
 
         # test category view
         response = self.client.get(self.category_b.get_absolute_url())
@@ -565,70 +514,62 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         # thread displays
         self.assertContainsThread(response, test_thread)
 
-        self.assertNotContains(response, 'thread-detail-category-%s' % self.category_b.css_class)
-        self.assertContains(response, 'thread-detail-category-%s' % self.category_c.css_class)
+        self.assertNotContains(
+            response, "thread-detail-category-%s" % self.category_b.css_class
+        )
+        self.assertContains(
+            response, "thread-detail-category-%s" % self.category_c.css_class
+        )
 
         # api displays same data
-        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)
 
         response_json = response.json()
-        self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
-        self.assertEqual(len(response_json['subcategories']), 2)
-        self.assertEqual(response_json['subcategories'][0], self.category_c.pk)
+        self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
+        self.assertEqual(len(response_json["subcategories"]), 2)
+        self.assertEqual(response_json["subcategories"][0], self.category_c.pk)
 
     def test_list_hides_hidden_thread(self):
         """list renders empty due to no permission to see thread"""
-        Category(
-            name='Hidden Category',
-            slug='hidden-category',
-        ).insert_at(
-            self.root,
-            position='last-child',
-            save=True,
+        Category(name="Hidden Category", slug="hidden-category").insert_at(
+            self.root, position="last-child", save=True
         )
 
-        test_category = Category.objects.get(slug='hidden-category')
+        test_category = Category.objects.get(slug="hidden-category")
         test_thread = testutils.post_thread(category=test_category)
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
         self.assertNotContainsThread(response, test_thread)
 
     def test_api_hides_hidden_thread(self):
         """api returns empty due to no permission to see thread"""
-        Category(
-            name='Hidden Category',
-            slug='hidden-category',
-        ).insert_at(
-            self.root,
-            position='last-child',
-            save=True,
+        Category(name="Hidden Category", slug="hidden-category").insert_at(
+            self.root, position="last-child", save=True
         )
 
-        test_category = Category.objects.get(slug='hidden-category')
+        test_category = Category.objects.get(slug="hidden-category")
 
-        testutils.post_thread(
-            category=test_category,
-        )
+        testutils.post_thread(category=test_category)
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         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):
         """list renders unapproved thread that belongs to viewer"""
         test_thread = testutils.post_thread(
-            category=self.category_a,
-            poster=self.user,
-            is_unapproved=True,
+            category=self.category_a, poster=self.user, is_unapproved=True
         )
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
@@ -637,17 +578,16 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertEqual(response.status_code, 200)
 
         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):
         """list hides unapproved thread that belongs to other user"""
         test_thread = testutils.post_thread(
-            category=self.category_a,
-            is_unapproved=True,
+            category=self.category_a, is_unapproved=True
         )
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
@@ -656,17 +596,14 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertEqual(response.status_code, 200)
 
         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):
         """list hides hidden thread that belongs to other user"""
-        test_thread = testutils.post_thread(
-            category=self.category_a,
-            is_hidden=True,
-        )
+        test_thread = testutils.post_thread(category=self.category_a, is_hidden=True)
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
@@ -675,18 +612,16 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertEqual(response.status_code, 200)
 
         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):
         """list hides hidden thread that belongs to viewer"""
         test_thread = testutils.post_thread(
-            category=self.category_a,
-            poster=self.user,
-            is_hidden=True,
+            category=self.category_a, poster=self.user, is_hidden=True
         )
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
@@ -695,18 +630,16 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertEqual(response.status_code, 200)
 
         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})
+    @patch_categories_acl({"can_hide_threads": 1})
     def test_list_user_can_see_own_hidden_thread(self):
         """list shows hidden thread that belongs to viewer due to permission"""
         test_thread = testutils.post_thread(
-            category=self.category_a,
-            poster=self.user,
-            is_hidden=True,
+            category=self.category_a, poster=self.user, is_hidden=True
         )
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
@@ -715,17 +648,14 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertEqual(response.status_code, 200)
 
         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})
+    @patch_categories_acl({"can_hide_threads": 1})
     def test_list_user_can_see_hidden_thread(self):
         """list shows hidden thread that belongs to other user due to permission"""
-        test_thread = testutils.post_thread(
-            category=self.category_a,
-            is_hidden=True,
-        )
+        test_thread = testutils.post_thread(category=self.category_a, is_hidden=True)
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
@@ -734,17 +664,16 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertEqual(response.status_code, 200)
 
         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})
+    @patch_categories_acl({"can_approve_content": 1})
     def test_list_user_can_see_unapproved_thread(self):
         """list shows hidden thread that belongs to other user due to permission"""
         test_thread = testutils.post_thread(
-            category=self.category_a,
-            is_unapproved=True,
+            category=self.category_a, is_unapproved=True
         )
 
-        response = self.client.get('/')
+        response = self.client.get("/")
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
@@ -753,120 +682,125 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
+        self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
 
 
 class MyThreadsListTests(ThreadsListTestCase):
     @patch_categories_acl()
     def test_list_renders_empty(self):
         """list renders empty"""
-        response = self.client.get('/my/')
+        response = self.client.get("/my/")
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
-        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.assertContains(response, "empty-message")
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
-        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()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
     @patch_categories_acl()
     def test_list_renders_test_thread(self):
         """list renders only threads posted by user"""
-        test_thread = testutils.post_thread(
-            category=self.category_a,
-            poster=self.user,
-        )
+        test_thread = testutils.post_thread(category=self.category_a, poster=self.user)
 
         other_thread = testutils.post_thread(category=self.category_a)
 
-        response = self.client.get('/my/')
+        response = self.client.get("/my/")
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
         self.assertNotContainsThread(response, other_thread)
 
-        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.assertContainsThread(response, test_thread)
         self.assertNotContainsThread(response, other_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 1)
-        self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
+        self.assertEqual(len(response_json["results"]), 1)
+        self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
 
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 1)
-        self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
+        self.assertEqual(len(response_json["results"]), 1)
+        self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
 
 
 class NewThreadsListTests(ThreadsListTestCase):
     @patch_categories_acl()
     def test_list_renders_empty(self):
         """list renders empty"""
-        response = self.client.get('/new/')
+        response = self.client.get("/new/")
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
-        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.assertContains(response, "empty-message")
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
-        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()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
     @patch_categories_acl()
     def test_list_renders_new_thread(self):
         """list renders new thread"""
         test_thread = testutils.post_thread(category=self.category_a)
 
-        response = self.client.get('/new/')
+        response = self.client.get("/new/")
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
-        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.assertContainsThread(response, test_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 1)
-        self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
+        self.assertEqual(len(response_json["results"]), 1)
+        self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
 
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 1)
-        self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
+        self.assertEqual(len(response_json["results"]), 1)
+        self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
 
     @patch_categories_acl()
     def test_list_renders_thread_bumped_after_user_cutoff(self):
@@ -875,37 +809,37 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.user.save()
 
         test_thread = testutils.post_thread(
-            category=self.category_a,
-            started_on=self.user.joined_on - timedelta(days=2),
+            category=self.category_a, started_on=self.user.joined_on - timedelta(days=2)
         )
 
         testutils.reply_thread(
-            test_thread,
-            posted_on=self.user.joined_on + timedelta(days=4),
+            test_thread, posted_on=self.user.joined_on + timedelta(days=4)
         )
 
-        response = self.client.get('/new/')
+        response = self.client.get("/new/")
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
-        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.assertContainsThread(response, test_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 1)
-        self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
+        self.assertEqual(len(response_json["results"]), 1)
+        self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
 
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 1)
-        self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
+        self.assertEqual(len(response_json["results"]), 1)
+        self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
 
     @patch_categories_acl()
     def test_list_hides_global_cutoff_thread(self):
@@ -915,29 +849,32 @@ class NewThreadsListTests(ThreadsListTestCase):
 
         test_thread = testutils.post_thread(
             category=self.category_a,
-            started_on=timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF + 1),
+            started_on=timezone.now()
+            - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF + 1),
         )
 
-        response = self.client.get('/new/')
+        response = self.client.get("/new/")
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        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.assertNotContainsThread(response, test_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
-        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)
 
         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):
@@ -950,26 +887,28 @@ class NewThreadsListTests(ThreadsListTestCase):
             started_on=self.user.joined_on - timedelta(minutes=1),
         )
 
-        response = self.client.get('/new/')
+        response = self.client.get("/new/")
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        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.assertNotContainsThread(response, test_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
-        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)
 
         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):
@@ -980,54 +919,56 @@ class NewThreadsListTests(ThreadsListTestCase):
         test_thread = testutils.post_thread(category=self.category_a)
         poststracker.save_read(self.user, test_thread.first_post)
 
-        response = self.client.get('/new/')
+        response = self.client.get("/new/")
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        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.assertNotContainsThread(response, test_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
 
 class UnreadThreadsListTests(ThreadsListTestCase):
     @patch_categories_acl()
     def test_list_renders_empty(self):
         """list renders empty"""
-        response = self.client.get('/unread/')
+        response = self.client.get("/unread/")
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
 
-        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.assertContains(response, "empty-message")
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
         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)
         )
         self.assertEqual(response.status_code, 200)
 
         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):
@@ -1039,30 +980,30 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         poststracker.save_read(self.user, test_thread.first_post)
         testutils.reply_thread(test_thread)
 
-        response = self.client.get('/unread/')
+        response = self.client.get("/unread/")
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
-        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.assertContainsThread(response, test_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 1)
-        self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
+        self.assertEqual(len(response_json["results"]), 1)
+        self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
 
         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)
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 1)
-        self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
+        self.assertEqual(len(response_json["results"]), 1)
+        self.assertEqual(response_json["results"][0]["id"], test_thread.pk)
 
     @patch_categories_acl()
     def test_list_hides_never_read_thread(self):
@@ -1072,28 +1013,28 @@ class UnreadThreadsListTests(ThreadsListTestCase):
 
         test_thread = testutils.post_thread(category=self.category_a)
 
-        response = self.client.get('/unread/')
+        response = self.client.get("/unread/")
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        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.assertNotContainsThread(response, test_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
         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)
         )
         self.assertEqual(response.status_code, 200)
 
         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):
@@ -1104,28 +1045,28 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         test_thread = testutils.post_thread(category=self.category_a)
         poststracker.save_read(self.user, test_thread.first_post)
 
-        response = self.client.get('/unread/')
+        response = self.client.get("/unread/")
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        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.assertNotContainsThread(response, test_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
         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)
         )
         self.assertEqual(response.status_code, 200)
 
         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):
@@ -1135,34 +1076,37 @@ class UnreadThreadsListTests(ThreadsListTestCase):
 
         test_thread = testutils.post_thread(
             category=self.category_a,
-            started_on=timezone.now() - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF + 5),
+            started_on=timezone.now()
+            - timedelta(days=settings.MISAGO_READTRACKER_CUTOFF + 5),
         )
 
         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)
+        )
 
-        response = self.client.get('/unread/')
+        response = self.client.get("/unread/")
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        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.assertNotContainsThread(response, test_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
         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)
         )
         self.assertEqual(response.status_code, 200)
 
         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):
@@ -1171,39 +1115,37 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         self.user.save()
 
         test_thread = testutils.post_thread(
-            category=self.category_a,
-            started_on=self.user.joined_on - timedelta(days=2),
+            category=self.category_a, started_on=self.user.joined_on - timedelta(days=2)
         )
 
         poststracker.save_read(self.user, test_thread.first_post)
 
         testutils.reply_thread(
-            test_thread,
-            posted_on=test_thread.started_on + timedelta(days=1),
+            test_thread, posted_on=test_thread.started_on + timedelta(days=1)
         )
 
-        response = self.client.get('/unread/')
+        response = self.client.get("/unread/")
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        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.assertNotContainsThread(response, test_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
         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)
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
 
 
 class SubscribedThreadsListTests(ThreadsListTestCase):
@@ -1217,29 +1159,29 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
             last_read_on=test_thread.last_post_on,
         )
 
-        response = self.client.get('/subscribed/')
+        response = self.client.get("/subscribed/")
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, test_thread)
 
-        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.assertContainsThread(response, test_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 1)
+        self.assertEqual(len(response_json["results"]), 1)
         self.assertContains(response, test_thread.get_absolute_url())
 
         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)
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 1)
+        self.assertEqual(len(response_json["results"]), 1)
         self.assertContains(response, test_thread.get_absolute_url())
 
     @patch_categories_acl()
@@ -1247,29 +1189,29 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
         """list shows subscribed thread"""
         test_thread = testutils.post_thread(category=self.category_a)
 
-        response = self.client.get('/subscribed/')
+        response = self.client.get("/subscribed/")
         self.assertEqual(response.status_code, 200)
         self.assertNotContainsThread(response, test_thread)
 
-        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.assertNotContainsThread(response, test_thread)
 
         # test api
-        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)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
         self.assertNotContainsThread(response, test_thread)
 
         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)
         )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(len(response_json['results']), 0)
+        self.assertEqual(len(response_json["results"]), 0)
         self.assertNotContainsThread(response, test_thread)
 
 
@@ -1277,8 +1219,9 @@ class UnapprovedListTests(ThreadsListTestCase):
     def test_list_errors_without_permission(self):
         """list errors if user has no permission to access it"""
         TEST_URLS = (
-            '/unapproved/', self.category_a.get_absolute_url() + 'unapproved/',
-            '%s?list=unapproved' % self.api_link,
+            "/unapproved/",
+            self.category_a.get_absolute_url() + "unapproved/",
+            "%s?list=unapproved" % self.api_link,
         )
 
         with patch_categories_acl():
@@ -1287,77 +1230,69 @@ class UnapprovedListTests(ThreadsListTestCase):
                 self.assertEqual(response.status_code, 403)
 
         # approval perm has no influence on visibility
-        with patch_categories_acl({'can_approve_content': True}):
+        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
-        with patch_categories_acl(base_acl={
-            'can_see_unapproved_content_lists': True,
-        }):
+        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},
+        {"can_approve_content": True}, {"can_see_unapproved_content_lists": True}
     )
     def test_list_shows_all_threads_for_approving_user(self):
         """list shows all threads with unapproved posts when user has perm"""
         visible_thread = testutils.post_thread(
-            category=self.category_b,
-            is_unapproved=True,
+            category=self.category_b, is_unapproved=True
         )
 
         hidden_thread = testutils.post_thread(
-            category=self.category_b,
-            is_unapproved=False,
+            category=self.category_b, is_unapproved=False
         )
 
-        response = self.client.get('/unapproved/')
+        response = self.client.get("/unapproved/")
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
-        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.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
         # test api
-        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.assertContains(response, visible_thread.get_absolute_url())
         self.assertNotContains(response, hidden_thread.get_absolute_url())
 
-    @patch_categories_acl(base_acl={'can_see_unapproved_content_lists': True})
+    @patch_categories_acl(base_acl={"can_see_unapproved_content_lists": True})
     def test_list_shows_owned_threads_for_unapproving_user(self):
         """list shows owned threads with unapproved posts for user without perm"""
         visible_thread = testutils.post_thread(
-            poster=self.user,
-            category=self.category_b,
-            is_unapproved=True,
+            poster=self.user, category=self.category_b, is_unapproved=True
         )
 
         hidden_thread = testutils.post_thread(
-            category=self.category_b,
-            is_unapproved=True,
+            category=self.category_b, is_unapproved=True
         )
 
-        response = self.client.get('/unapproved/')
+        response = self.client.get("/unapproved/")
         self.assertEqual(response.status_code, 200)
         self.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
-        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.assertContainsThread(response, visible_thread)
         self.assertNotContainsThread(response, hidden_thread)
 
         # test api
-        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.assertContains(response, visible_thread.get_absolute_url())
         self.assertNotContains(response, hidden_thread.get_absolute_url())
@@ -1365,10 +1300,10 @@ class UnapprovedListTests(ThreadsListTestCase):
 
 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
+        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)
 
@@ -1377,19 +1312,16 @@ class OwnerOnlyThreadsVisibilityTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.category = Category.objects.get(slug='first-category')
+        self.category = Category.objects.get(slug="first-category")
 
     def test_owned_threads_visibility(self):
         """only user-posted threads are visible in category"""
         visible_thread = testutils.post_thread(
-            poster=self.user,
-            category=self.category,
-            is_unapproved=True,
+            poster=self.user, category=self.category, is_unapproved=True
         )
 
         hidden_thread = testutils.post_thread(
-            category=self.category,
-            is_unapproved=True,
+            category=self.category, is_unapproved=True
         )
 
         with patch_category_see_all_threads_acl():
@@ -1403,15 +1335,10 @@ class OwnerOnlyThreadsVisibilityTests(AuthenticatedUserTestCase):
         self.logout_user()
 
         user_thread = testutils.post_thread(
-            poster=self.user,
-            category=self.category,
-            is_unapproved=True,
+            poster=self.user, category=self.category, is_unapproved=True
         )
 
-        guest_thread = testutils.post_thread(
-            category=self.category,
-            is_unapproved=True,
-        )
+        guest_thread = testutils.post_thread(category=self.category, is_unapproved=True)
 
         with patch_category_see_all_threads_acl():
             response = self.client.get(self.category.get_absolute_url())

+ 129 - 113
misago/threads/tests/test_threadview.py

@@ -17,24 +17,26 @@ cache_versions = get_cache_versions()
 
 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]
+        category = Category.objects.get(slug="first-category")
+        category_acl = user_acl["categories"][category.id]
 
         # reset category ACL to single predictable state
-        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_close_threads': 0,
-            'post_edit_time': 0,
-            'can_hide_events': 0,
-        })
+        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_close_threads": 0,
+                "post_edit_time": 0,
+                "can_hide_events": 0,
+            }
+        )
 
         if new_acl:
             category_acl.update(new_acl)
@@ -46,7 +48,7 @@ class ThreadViewTestCase(AuthenticatedUserTestCase):
     def setUp(self):
         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)
 
 
@@ -58,7 +60,7 @@ class ThreadVisibilityTests(ThreadViewTestCase):
 
     def test_view_shows_owner_thread(self):
         """view handles "owned threads" only"""
-        with patch_category_acl({'can_see_all_threads': 0}):
+        with patch_category_acl({"can_see_all_threads": 0}):
             response = self.client.get(self.thread.get_absolute_url())
             self.assertEqual(response.status_code, 404)
 
@@ -70,17 +72,17 @@ class ThreadVisibilityTests(ThreadViewTestCase):
 
     def test_view_validates_category_permissions(self):
         """view validates category visiblity"""
-        with patch_category_acl({'can_see': 0}):
+        with patch_category_acl({"can_see": 0}):
             response = self.client.get(self.thread.get_absolute_url())
             self.assertEqual(response.status_code, 404)
 
-        with patch_category_acl({'can_browse': 0}):
+        with patch_category_acl({"can_browse": 0}):
             response = self.client.get(self.thread.get_absolute_url())
             self.assertEqual(response.status_code, 404)
 
     def test_view_shows_unapproved_thread(self):
         """view handles unapproved thread"""
-        with patch_category_acl({'can_approve_content': 0}):
+        with patch_category_acl({"can_approve_content": 0}):
             self.thread.is_unapproved = True
             self.thread.save()
 
@@ -88,7 +90,7 @@ class ThreadVisibilityTests(ThreadViewTestCase):
             self.assertEqual(response.status_code, 404)
 
         # grant permission to see unapproved content
-        with patch_category_acl({'can_approve_content': 1}):
+        with patch_category_acl({"can_approve_content": 1}):
             response = self.client.get(self.thread.get_absolute_url())
             self.assertContains(response, self.thread.title)
 
@@ -97,13 +99,13 @@ class ThreadVisibilityTests(ThreadViewTestCase):
             self.thread.starter = self.user
             self.thread.save()
 
-        with patch_category_acl({'can_approve_content': 0}):
+        with patch_category_acl({"can_approve_content": 0}):
             response = self.client.get(self.thread.get_absolute_url())
             self.assertContains(response, self.thread.title)
 
     def test_view_shows_hidden_thread(self):
         """view handles hidden thread"""
-        with patch_category_acl({'can_hide_threads': 0}):
+        with patch_category_acl({"can_hide_threads": 0}):
             self.thread.is_hidden = True
             self.thread.save()
 
@@ -118,7 +120,7 @@ class ThreadVisibilityTests(ThreadViewTestCase):
         self.assertEqual(response.status_code, 404)
 
         # grant permission to see hidden content
-        with patch_category_acl({'can_hide_threads': 1}):
+        with patch_category_acl({"can_hide_threads": 1}):
             response = self.client.get(self.thread.get_absolute_url())
             self.assertContains(response, self.thread.title)
 
@@ -135,7 +137,7 @@ class ThreadPostsVisibilityTests(ThreadViewTestCase):
         """invalid post renders"""
         post = testutils.reply_thread(self.thread, poster=self.user)
 
-        post.parsed = 'fiddled post content'
+        post.parsed = "fiddled post content"
         post.save()
 
         response = self.client.get(self.thread.get_absolute_url())
@@ -150,7 +152,9 @@ class ThreadPostsVisibilityTests(ThreadViewTestCase):
 
         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.assertContains(
+            response, "This post is hidden. You cannot not see its contents."
+        )
         self.assertNotContains(response, post.parsed)
 
         # posts authors are not extempt from seeing hidden posts content
@@ -159,24 +163,31 @@ class ThreadPostsVisibilityTests(ThreadViewTestCase):
 
         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.assertContains(
+            response, "This post is hidden. You cannot not see its contents."
+        )
         self.assertNotContains(response, post.parsed)
 
         # permission to hide own posts isn't enought to see post content
-        with patch_category_acl({'can_hide_own_posts': 1}):
+        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.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
-        with patch_category_acl({'can_hide_posts': 1}):
+        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."
+                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.assertNotContains(response, "This post is hidden. You cannot not see its contents.")
             self.assertContains(response, post.parsed)
 
     def test_unapproved_post_visibility(self):
@@ -188,14 +199,14 @@ class ThreadPostsVisibilityTests(ThreadViewTestCase):
         self.assertNotContains(response, post.get_absolute_url())
 
         # post displays because we have permission to approve unapproved content
-        with patch_category_acl({'can_approve_content': 1}):
+        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
-        with patch_category_acl({'can_approve_content': 0}):
+        with patch_category_acl({"can_approve_content": 0}):
             post.poster = self.user
             post.save()
 
@@ -209,7 +220,10 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
     def test_thread_events_render(self):
         """different thread events render"""
         TEST_ACTIONS = [
-            (threads_moderation.pin_thread_globally, "Thread has been pinned globally."),
+            (
+                threads_moderation.pin_thread_globally,
+                "Thread has been pinned globally.",
+            ),
             (threads_moderation.pin_thread_locally, "Thread has been pinned locally."),
             (threads_moderation.unpin_thread, "Thread has been unpinned."),
             (threads_moderation.approve_thread, "Thread has been approved."),
@@ -225,7 +239,7 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         for action, message in TEST_ACTIONS:
             self.thread.post_set.filter(is_event=True).delete()
 
-            with patch_category_acl({'can_approve_content': 1, 'can_hide_threads': 1}):
+            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)
@@ -238,18 +252,16 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
                 self.assertContains(response, message)
 
             # hidden events don't render without permission
-            with patch_category_acl({'can_approve_content': 1, 'can_hide_threads': 1}):
+            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
-            with patch_category_acl({
-                'can_approve_content': 1,
-                'can_hide_threads': 1,
-                'can_hide_events': 1,
-            }):
+            with patch_category_acl(
+                {"can_approve_content": 1, "can_hide_threads": 1, "can_hide_events": 1}
+            ):
                 hide_post(self.user, event)
                 response = self.client.get(self.thread.get_absolute_url())
                 self.assertContains(response, event.get_absolute_url())
@@ -257,11 +269,9 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
                 self.assertContains(response, "Hidden by")
 
             # Event is only loaded if thread has events flag
-            with patch_category_acl({
-                'can_approve_content': 1,
-                'can_hide_threads': 1,
-                'can_hide_events': 1,
-            }):
+            with patch_category_acl(
+                {"can_approve_content": 1, "can_hide_threads": 1, "can_hide_events": 1}
+            ):
                 self.thread.has_events = False
                 self.thread.save()
 
@@ -275,7 +285,7 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
 
         for _ in range(events_limit + 5):
             request = Mock(user=self.user, user_ip="127.0.0.1")
-            event = record_event(request, self.thread, 'closed')
+            event = record_event(request, self.thread, "closed")
             events.append(event)
 
         # test that only events within limits were rendered
@@ -294,7 +304,7 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
 
         for _ in range(events_limit + 5):
             request = Mock(user=self.user, user_ip="127.0.0.1")
-            event = record_event(request, self.thread, 'closed')
+            event = record_event(request, self.thread, "closed")
             events.append(event)
 
         posts = []
@@ -315,7 +325,7 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
             post = testutils.reply_thread(self.thread)
         for _ in range(events_limit):
             request = Mock(user=self.user, user_ip="127.0.0.1")
-            event = record_event(request, self.thread, 'closed')
+            event = record_event(request, self.thread, "closed")
             events.append(event)
 
         # see first page
@@ -323,14 +333,14 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
 
         for event in events[5:events_limit]:
             self.assertContains(response, event.get_absolute_url())
-        for post in posts[:posts_limit - 1]:
+        for post in posts[: posts_limit - 1]:
             self.assertContains(response, post.get_absolute_url())
 
         # see second page
-        response = self.client.get('%s2/' % self.thread.get_absolute_url())
-        for event in events[5 + events_limit:]:
+        response = self.client.get("%s2/" % self.thread.get_absolute_url())
+        for event in events[5 + events_limit :]:
             self.assertContains(response, event.get_absolute_url())
-        for post in posts[posts_limit - 1:]:
+        for post in posts[posts_limit - 1 :]:
             self.assertContains(response, post.get_absolute_url())
 
     def test_changed_thread_title_event_renders(self):
@@ -341,7 +351,7 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         )
 
         event = self.thread.post_set.filter(is_event=True)[0]
-        self.assertEqual(event.event_type, 'changed_title')
+        self.assertEqual(event.event_type, "changed_title")
 
         # event renders
         response = self.client.get(self.thread.get_absolute_url())
@@ -358,7 +368,7 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         threads_moderation.move_thread(request, self.thread, self.category)
 
         event = self.thread.post_set.filter(is_event=True)[0]
-        self.assertEqual(event.event_type, 'moved')
+        self.assertEqual(event.event_type, "moved")
 
         # event renders
         response = self.client.get(self.thread.get_absolute_url())
@@ -372,7 +382,7 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         threads_moderation.merge_thread(request, self.thread, other_thread)
 
         event = self.thread.post_set.filter(is_event=True)[0]
-        self.assertEqual(event.event_type, 'merged')
+        self.assertEqual(event.event_type, "merged")
 
         # event renders
         response = self.client.get(self.thread.get_absolute_url())
@@ -383,13 +393,13 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
 class ThreadAttachmentsViewTests(ThreadViewTestCase):
     def mock_attachment_cache(self, data):
         json = {
-            'url': {},
-            'size': 16914,
-            'filename': 'Archiwum.zip',
-            'filetype': 'ZIP',
-            'is_image': False,
-            'uploaded_on': '2016-10-22T21:17:40.408710Z',
-            'uploader_name': 'BobBoberson',
+            "url": {},
+            "size": 16914,
+            "filename": "Archiwum.zip",
+            "filetype": "ZIP",
+            "is_image": False,
+            "uploaded_on": "2016-10-22T21:17:40.408710Z",
+            "uploader_name": "BobBoberson",
         }
 
         json.update(data)
@@ -400,31 +410,37 @@ class ThreadAttachmentsViewTests(ThreadViewTestCase):
         post = self.thread.first_post
 
         post.attachments_cache = [
-            self.mock_attachment_cache({
-                'url': {
-                    'index': '/attachment/loremipsum-123/',
-                    'thumb': None,
-                    'uploader': '/user/bobboberson-123/',
-                },
-                'filename': 'Archiwum-1.zip',
-            }),
-            self.mock_attachment_cache({
-                'url': {
-                    'index': '/attachment/loremipsum-223/',
-                    'thumb': '/attachment/thumb/loremipsum-223/',
-                    'uploader': '/user/bobboberson-223/',
-                },
-                'is_image': True,
-                'filename': 'Archiwum-2.zip',
-            }),
-            self.mock_attachment_cache({
-                'url': {
-                    'index': '/attachment/loremipsum-323/',
-                    'thumb': None,
-                    'uploader': '/user/bobboberson-323/',
-                },
-                'filename': 'Archiwum-3.zip',
-            }),
+            self.mock_attachment_cache(
+                {
+                    "url": {
+                        "index": "/attachment/loremipsum-123/",
+                        "thumb": None,
+                        "uploader": "/user/bobboberson-123/",
+                    },
+                    "filename": "Archiwum-1.zip",
+                }
+            ),
+            self.mock_attachment_cache(
+                {
+                    "url": {
+                        "index": "/attachment/loremipsum-223/",
+                        "thumb": "/attachment/thumb/loremipsum-223/",
+                        "uploader": "/user/bobboberson-223/",
+                    },
+                    "is_image": True,
+                    "filename": "Archiwum-2.zip",
+                }
+            ),
+            self.mock_attachment_cache(
+                {
+                    "url": {
+                        "index": "/attachment/loremipsum-323/",
+                        "thumb": None,
+                        "uploader": "/user/bobboberson-323/",
+                    },
+                    "filename": "Archiwum-3.zip",
+                }
+            ),
         ]
         post.save()
 
@@ -432,13 +448,13 @@ class ThreadAttachmentsViewTests(ThreadViewTestCase):
         response = self.client.get(self.thread.get_absolute_url())
 
         for attachment in post.attachments_cache:
-            self.assertContains(response, attachment['filename'])
-            self.assertContains(response, attachment['uploader_name'])
-            self.assertContains(response, attachment['url']['index'])
-            self.assertContains(response, attachment['url']['uploader'])
+            self.assertContains(response, attachment["filename"])
+            self.assertContains(response, attachment["uploader_name"])
+            self.assertContains(response, attachment["url"]["index"])
+            self.assertContains(response, attachment["url"]["uploader"])
 
-            if attachment['url']['thumb']:
-                self.assertContains(response, attachment['url']['thumb'])
+            if attachment["url"]["thumb"]:
+                self.assertContains(response, attachment["url"]["thumb"])
 
 
 class ThreadPollViewTests(ThreadViewTestCase):
@@ -448,8 +464,8 @@ class ThreadPollViewTests(ThreadViewTestCase):
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, poll.question)
-        self.assertContains(response, '4 votes')
-        self.assertNotContains(response, 'Save your vote')
+        self.assertContains(response, "4 votes")
+        self.assertNotContains(response, "Save your vote")
 
     def test_poll_unvoted_display(self):
         """view has no showstoppers when displaying poll vote form"""
@@ -458,7 +474,7 @@ class ThreadPollViewTests(ThreadViewTestCase):
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, poll.question)
-        self.assertContains(response, 'Save your vote')
+        self.assertContains(response, "Save your vote")
 
     def test_poll_anonymous_view(self):
         """view has no showstoppers when displaying poll to anon user"""
@@ -468,8 +484,8 @@ class ThreadPollViewTests(ThreadViewTestCase):
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, poll.question)
-        self.assertContains(response, '4 votes')
-        self.assertNotContains(response, 'Save your vote')
+        self.assertContains(response, "4 votes")
+        self.assertNotContains(response, "Save your vote")
 
 
 class ThreadLikedPostsViewTests(ThreadViewTestCase):
@@ -486,7 +502,7 @@ class ThreadLikedPostsViewTests(ThreadViewTestCase):
         """
         testutils.like_post(self.thread.first_post, self.user)
 
-        with patch_category_acl({'can_see_posts_likes': 0}):
+        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')
@@ -497,11 +513,11 @@ class ThreadAnonViewTests(ThreadViewTestCase):
     def test_anonymous_user_view_no_showstoppers_display(self):
         """kitchensink thread view has no showstoppers for anons"""
         request = Mock(user=self.user, user_ip="127.0.0.1")
-        
+
         poll = testutils.post_poll(self.thread, self.user)
-        event = record_event(request, self.thread, 'closed')
+        event = record_event(request, self.thread, "closed")
 
-        hidden_event = record_event(request, self.thread, 'opened')
+        hidden_event = record_event(request, self.thread, "opened")
         hide_post(self.user, hidden_event)
 
         unapproved_post = testutils.reply_thread(self.thread, is_unapproved=True)
@@ -520,7 +536,7 @@ class ThreadAnonViewTests(ThreadViewTestCase):
 class ThreadUnicodeSupportTests(ThreadViewTestCase):
     def test_category_name(self):
         """unicode in category name causes no showstopper"""
-        self.category.name = 'Łódź'
+        self.category.name = "Łódź"
         self.category.save()
 
         with patch_category_acl():
@@ -529,8 +545,8 @@ class ThreadUnicodeSupportTests(ThreadViewTestCase):
 
     def test_thread_title(self):
         """unicode in thread title causes no showstopper"""
-        self.thread.title = 'Łódź'
-        self.thread.slug = 'Lodz'
+        self.thread.title = "Łódź"
+        self.thread.slug = "Lodz"
         self.thread.save()
 
         with patch_category_acl():
@@ -539,8 +555,8 @@ class ThreadUnicodeSupportTests(ThreadViewTestCase):
 
     def test_post_content(self):
         """unicode in thread title causes no showstopper"""
-        self.thread.first_post.original = 'Łódź'
-        self.thread.first_post.parsed = '<p>Łódź</p>'
+        self.thread.first_post.original = "Łódź"
+        self.thread.first_post.parsed = "<p>Łódź</p>"
 
         update_post_checksum(self.thread.first_post)
         self.thread.first_post.save()
@@ -551,9 +567,9 @@ class ThreadUnicodeSupportTests(ThreadViewTestCase):
 
     def test_user_rank(self):
         """unicode in user rank causes no showstopper"""
-        self.user.title = 'Łódź'
-        self.user.rank.name = 'Łódź'
-        self.user.rank.title = 'Łódź'
+        self.user.title = "Łódź"
+        self.user.rank.name = "Łódź"
+        self.user.rank.title = "Łódź"
         self.user.rank.save()
         self.user.save()
 

+ 37 - 22
misago/threads/tests/test_treesmap.py

@@ -4,7 +4,7 @@ from misago.categories.models import Category
 from misago.threads.threadtypes.treesmap import TreesMap
 
 
-THREAD_TYPE = 'misago.threads.threadtypes.thread.Thread'
+THREAD_TYPE = "misago.threads.threadtypes.thread.Thread"
 
 
 class TreesMapTests(TestCase):
@@ -14,7 +14,7 @@ class TreesMapTests(TestCase):
         types = trees_map.load_types([THREAD_TYPE])
 
         self.assertEqual(len(types), 1, "expected to load only one thread type")
-        self.assertIn('root_category', types, "invalid thread type was loaded")
+        self.assertIn("root_category", types, "invalid thread type was loaded")
 
     def test_load_trees(self):
         """TreesMap().load_trees() loads trees ids"""
@@ -22,10 +22,12 @@ class TreesMapTests(TestCase):
         types = trees_map.load_types([THREAD_TYPE])
         trees = trees_map.load_trees(types)
 
-        tree_id = Category.objects.get(special_role='root_category').tree_id
+        tree_id = Category.objects.get(special_role="root_category").tree_id
 
         self.assertEqual(len(trees), 1, "expected to load only one tree")
-        self.assertEqual(trees[tree_id].root_name, 'root_category', "invalid tree was loaded")
+        self.assertEqual(
+            trees[tree_id].root_name, "root_category", "invalid tree was loaded"
+        )
 
     def test_get_roots(self):
         """TreesMap().get_roots() returns roots to trees dict"""
@@ -34,45 +36,54 @@ class TreesMapTests(TestCase):
         trees = trees_map.load_trees(types)
         roots = trees_map.get_roots(trees)
 
-        tree_id = Category.objects.get(special_role='root_category').tree_id
+        tree_id = Category.objects.get(special_role="root_category").tree_id
 
         self.assertEqual(len(roots), 1, "expected to load only one root")
-        self.assertIn('root_category', roots, "invalid root was loaded")
-        self.assertEqual(roots['root_category'], tree_id, "invalid tree_id was loaded")
+        self.assertIn("root_category", roots, "invalid root was loaded")
+        self.assertEqual(roots["root_category"], tree_id, "invalid tree_id was loaded")
 
     def test_load(self):
         """TreesMap().load() populates trees map"""
         trees_map = TreesMap([THREAD_TYPE])
 
-        self.assertFalse(trees_map.is_loaded, "trees map should be not loaded by default")
+        self.assertFalse(
+            trees_map.is_loaded, "trees map should be not loaded by default"
+        )
 
         trees_map.load()
 
-        self.assertTrue(trees_map.is_loaded, "trees map should be loaded after call to load()")
+        self.assertTrue(
+            trees_map.is_loaded, "trees map should be loaded after call to load()"
+        )
 
         self.assertEqual(len(trees_map.types), 1, "expected to load one type")
         self.assertEqual(len(trees_map.trees), 1, "expected to load one tree")
         self.assertEqual(len(trees_map.roots), 1, "expected to load one root")
 
-        tree_id = Category.objects.get(special_role='root_category').tree_id
+        tree_id = Category.objects.get(special_role="root_category").tree_id
 
-        self.assertIn('root_category', trees_map.types, "invalid thread type was loaded")
+        self.assertIn(
+            "root_category", trees_map.types, "invalid thread type was loaded"
+        )
         self.assertEqual(
-            trees_map.trees[tree_id].root_name, 'root_category', "invalid tree was loaded"
+            trees_map.trees[tree_id].root_name,
+            "root_category",
+            "invalid tree was loaded",
         )
-        self.assertIn('root_category', trees_map.roots, "invalid root was loaded")
+        self.assertIn("root_category", trees_map.roots, "invalid root was loaded")
 
     def test_get_type_for_tree_id(self):
         """TreesMap().get_type_for_tree_id() returns type for valid id"""
         trees_map = TreesMap([THREAD_TYPE])
         trees_map.load()
 
-        tree_id = Category.objects.get(special_role='root_category').tree_id
+        tree_id = Category.objects.get(special_role="root_category").tree_id
         thread_type = trees_map.get_type_for_tree_id(tree_id)
 
         self.assertEqual(
-            thread_type.root_name, 'root_category',
-            "returned invalid thread type for given tree id"
+            thread_type.root_name,
+            "root_category",
+            "returned invalid thread type for given tree id",
         )
 
         try:
@@ -81,7 +92,8 @@ class TreesMapTests(TestCase):
         except KeyError as e:
             self.assertIn(
                 "tree id has no type defined",
-                str(e), "invalid exception message as given"
+                str(e),
+                "invalid exception message as given",
             )
 
     def test_get_tree_id_for_root(self):
@@ -89,16 +101,19 @@ class TreesMapTests(TestCase):
         trees_map = TreesMap([THREAD_TYPE])
         trees_map.load()
 
-        in_db_tree_id = Category.objects.get(special_role='root_category').tree_id
-        tree_id = trees_map.get_tree_id_for_root('root_category')
+        in_db_tree_id = Category.objects.get(special_role="root_category").tree_id
+        tree_id = trees_map.get_tree_id_for_root("root_category")
 
-        self.assertEqual(tree_id, in_db_tree_id, "root name didn't match one in database")
+        self.assertEqual(
+            tree_id, in_db_tree_id, "root name didn't match one in database"
+        )
 
         try:
-            trees_map.get_tree_id_for_root('hurr_durr')
+            trees_map.get_tree_id_for_root("hurr_durr")
             self.fail("invalid root name should cause KeyError being raised")
         except KeyError as e:
             self.assertIn(
                 '"hurr_durr" root has no tree defined',
-                str(e), "invalid exception message as given"
+                str(e),
+                "invalid exception message as given",
             )

+ 1 - 1
misago/threads/tests/test_updatepostschecksums.py

@@ -29,7 +29,7 @@ class UpdatePostsChecksumsTests(TestCase):
             [testutils.reply_thread(thread) for _ in range(3)]
             thread.save()
 
-        Post.objects.update(parsed='Hello world!')
+        Post.objects.update(parsed="Hello world!")
         for post in Post.objects.all():
             self.assertFalse(post.is_valid)
 

+ 88 - 121
misago/threads/tests/test_utils.py

@@ -25,80 +25,44 @@ class AddCategoriesToItemsTests(TestCase):
         self.root = Category.objects.root_category()
 
         Category(
-            name='Category A',
-            slug='category-a',
-            css_class='showing-category-a',
-        ).insert_at(
-            self.root,
-            position='last-child',
-            save=True,
-        )
+            name="Category A", slug="category-a", css_class="showing-category-a"
+        ).insert_at(self.root, position="last-child", save=True)
         Category(
-            name='Category E',
-            slug='category-e',
-            css_class='showing-category-e',
-        ).insert_at(
-            self.root,
-            position='last-child',
-            save=True,
-        )
+            name="Category E", slug="category-e", css_class="showing-category-e"
+        ).insert_at(self.root, position="last-child", save=True)
 
         self.root = Category.objects.root_category()
 
-        self.category_a = Category.objects.get(slug='category-a')
+        self.category_a = Category.objects.get(slug="category-a")
         Category(
-            name='Category B',
-            slug='category-b',
-            css_class='showing-category-b',
-        ).insert_at(
-            self.category_a,
-            position='last-child',
-            save=True,
-        )
-
-        self.category_b = Category.objects.get(slug='category-b')
+            name="Category B", slug="category-b", css_class="showing-category-b"
+        ).insert_at(self.category_a, position="last-child", save=True)
+
+        self.category_b = Category.objects.get(slug="category-b")
         Category(
-            name='Category C',
-            slug='category-c',
-            css_class='showing-category-c',
-        ).insert_at(
-            self.category_b,
-            position='last-child',
-            save=True,
-        )
+            name="Category C", slug="category-c", css_class="showing-category-c"
+        ).insert_at(self.category_b, position="last-child", save=True)
         Category(
-            name='Category D',
-            slug='category-d',
-            css_class='showing-category-d',
-        ).insert_at(
-            self.category_b,
-            position='last-child',
-            save=True,
-        )
-
-        self.category_c = Category.objects.get(slug='category-c')
-        self.category_d = Category.objects.get(slug='category-d')
-
-        self.category_e = Category.objects.get(slug='category-e')
+            name="Category D", slug="category-d", css_class="showing-category-d"
+        ).insert_at(self.category_b, position="last-child", save=True)
+
+        self.category_c = Category.objects.get(slug="category-c")
+        self.category_d = Category.objects.get(slug="category-d")
+
+        self.category_e = Category.objects.get(slug="category-e")
         Category(
-            name='Category F',
-            slug='category-f',
-            css_class='showing-category-f',
-        ).insert_at(
-            self.category_e,
-            position='last-child',
-            save=True,
-        )
+            name="Category F", slug="category-f", css_class="showing-category-f"
+        ).insert_at(self.category_e, position="last-child", save=True)
 
         Category.objects.partial_rebuild(self.root.tree_id)
 
         self.root = Category.objects.root_category()
-        self.category_a = Category.objects.get(slug='category-a')
-        self.category_b = Category.objects.get(slug='category-b')
-        self.category_c = Category.objects.get(slug='category-c')
-        self.category_d = Category.objects.get(slug='category-d')
-        self.category_e = Category.objects.get(slug='category-e')
-        self.category_f = Category.objects.get(slug='category-f')
+        self.category_a = Category.objects.get(slug="category-a")
+        self.category_b = Category.objects.get(slug="category-b")
+        self.category_c = Category.objects.get(slug="category-c")
+        self.category_d = Category.objects.get(slug="category-d")
+        self.category_e = Category.objects.get(slug="category-e")
+        self.category_f = Category.objects.get(slug="category-f")
 
         self.categories = list(Category.objects.all_categories(include_root=True))
 
@@ -160,18 +124,18 @@ class AddCategoriesToItemsTests(TestCase):
 
 
 class MockRequest(object):
-    def __init__(self, scheme, host, wsgialias=''):
+    def __init__(self, scheme, host, wsgialias=""):
         self.scheme = scheme
         self.host = host
 
-        self.path_info = '/api/threads/123/merge/'
-        self.path = '%s%s' % (wsgialias.rstrip('/'), self.path_info)
+        self.path_info = "/api/threads/123/merge/"
+        self.path = "%s%s" % (wsgialias.rstrip("/"), self.path_info)
 
     def get_host(self):
         return self.host
 
     def is_secure(self):
-        return self.scheme == 'https'
+        return self.scheme == "https"
 
 
 class GetThreadIdFromUrlTests(TestCase):
@@ -180,123 +144,126 @@ class GetThreadIdFromUrlTests(TestCase):
         TEST_CASES = [
             {
                 # perfect match
-                'request': MockRequest('https', 'testforum.com', '/discuss/'),
-                'url': 'https://testforum.com/discuss/t/test-thread/123/',
-                'pk': 123,
+                "request": MockRequest("https", "testforum.com", "/discuss/"),
+                "url": "https://testforum.com/discuss/t/test-thread/123/",
+                "pk": 123,
             },
             {
                 # we don't validate scheme in case site recently moved to https
                 # but user still has old url's saved somewhere
-                'request': MockRequest('http', 'testforum.com', '/discuss/'),
-                'url': 'http://testforum.com/discuss/t/test-thread/432/post/12321/',
-                'pk': 432,
+                "request": MockRequest("http", "testforum.com", "/discuss/"),
+                "url": "http://testforum.com/discuss/t/test-thread/432/post/12321/",
+                "pk": 432,
             },
             {
                 # extract thread id from other thread urls
-                'request': MockRequest('https', 'testforum.com', '/discuss/'),
-                'url': 'http://testforum.com/discuss/t/test-thread/432/post/12321/',
-                'pk': 432,
+                "request": MockRequest("https", "testforum.com", "/discuss/"),
+                "url": "http://testforum.com/discuss/t/test-thread/432/post/12321/",
+                "pk": 432,
             },
             {
                 # extract thread id from thread page url
-                'request': MockRequest('http', 'testforum.com', '/discuss/'),
-                'url': 'http://testforum.com/discuss/t/test-thread/432/123/',
-                'pk': 432,
+                "request": MockRequest("http", "testforum.com", "/discuss/"),
+                "url": "http://testforum.com/discuss/t/test-thread/432/123/",
+                "pk": 432,
             },
             {
                 # extract thread id from thread last post url with relative schema
-                'request': MockRequest('http', 'testforum.com', '/discuss/'),
-                'url': '//testforum.com/discuss/t/test-thread/18/last/',
-                'pk': 18,
+                "request": MockRequest("http", "testforum.com", "/discuss/"),
+                "url": "//testforum.com/discuss/t/test-thread/18/last/",
+                "pk": 18,
             },
             {
                 # extract thread id from url that lacks scheme
-                'request': MockRequest('http', 'testforum.com', ''),
-                'url': 'testforum.com/t/test-thread/12/last/',
-                'pk': 12,
+                "request": MockRequest("http", "testforum.com", ""),
+                "url": "testforum.com/t/test-thread/12/last/",
+                "pk": 12,
             },
             {
                 # extract thread id from schemaless thread last post url
-                'request': MockRequest('http', 'testforum.com', '/discuss/'),
-                'url': 'testforum.com/discuss/t/test-thread/18/last/',
-                'pk': 18,
+                "request": MockRequest("http", "testforum.com", "/discuss/"),
+                "url": "testforum.com/discuss/t/test-thread/18/last/",
+                "pk": 18,
             },
             {
                 # extract thread id from url that lacks scheme and hostname
-                'request': MockRequest('http', 'testforum.com', ''),
-                'url': '/t/test-thread/13/',
-                'pk': 13,
+                "request": MockRequest("http", "testforum.com", ""),
+                "url": "/t/test-thread/13/",
+                "pk": 13,
             },
             {
                 # extract thread id from url that has port name
-                'request': MockRequest('http', '127.0.0.1:8000', ''),
-                'url': 'https://127.0.0.1:8000/t/test-thread/13/',
-                'pk': 13,
+                "request": MockRequest("http", "127.0.0.1:8000", ""),
+                "url": "https://127.0.0.1:8000/t/test-thread/13/",
+                "pk": 13,
             },
             {
                 # extract thread id from url that isn't trimmed
-                'request': MockRequest('http', '127.0.0.1:8000', ''),
-                'url': '   /t/test-thread/13/   ',
-                'pk': 13,
-            }
+                "request": MockRequest("http", "127.0.0.1:8000", ""),
+                "url": "   /t/test-thread/13/   ",
+                "pk": 13,
+            },
         ]
 
         for case in TEST_CASES:
-            pk = get_thread_id_from_url(case['request'], case['url'])
+            pk = get_thread_id_from_url(case["request"], case["url"])
             self.assertEqual(
-                pk, case['pk'],
-                'get_thread_id_from_url for %(url)s should return %(pk)s' % case
+                pk,
+                case["pk"],
+                "get_thread_id_from_url for %(url)s should return %(pk)s" % case,
             )
 
     def test_get_thread_id_from_invalid_urls(self):
         TEST_CASES = [
             {
                 # lacking wsgi alias
-                'request': MockRequest('https', 'testforum.com'),
-                'url': 'http://testforum.com/discuss/t/test-thread-123/',
+                "request": MockRequest("https", "testforum.com"),
+                "url": "http://testforum.com/discuss/t/test-thread-123/",
             },
             {
                 # invalid wsgi alias
-                'request': MockRequest('https', 'testforum.com', '/discuss/'),
-                'url': 'http://testforum.com/forum/t/test-thread-123/',
+                "request": MockRequest("https", "testforum.com", "/discuss/"),
+                "url": "http://testforum.com/forum/t/test-thread-123/",
             },
             {
                 # invalid hostname
-                'request': MockRequest('http', 'misago-project.org', '/discuss/'),
-                'url': 'https://testforum.com/discuss/t/test-thread-432/post/12321/',
+                "request": MockRequest("http", "misago-project.org", "/discuss/"),
+                "url": "https://testforum.com/discuss/t/test-thread-432/post/12321/",
             },
             {
                 # old thread url
-                'request': MockRequest('http', 'testforum.com'),
-                'url': 'https://testforum.com/thread/bobboberson-123/',
+                "request": MockRequest("http", "testforum.com"),
+                "url": "https://testforum.com/thread/bobboberson-123/",
             },
             {
                 # dashed thread url
-                'request': MockRequest('http', 'testforum.com'),
-                'url': 'https://testforum.com/t/bobboberson-123/',
+                "request": MockRequest("http", "testforum.com"),
+                "url": "https://testforum.com/t/bobboberson-123/",
             },
             {
                 # non-thread url
-                'request': MockRequest('http', 'testforum.com'),
-                'url': 'https://testforum.com/user/bobboberson-123/',
+                "request": MockRequest("http", "testforum.com"),
+                "url": "https://testforum.com/user/bobboberson-123/",
             },
             {
                 # rubbish url
-                'request': MockRequest('http', 'testforum.com'),
-                'url': 'asdsadsasadsaSA&das8as*S(A*sa'
+                "request": MockRequest("http", "testforum.com"),
+                "url": "asdsadsasadsaSA&das8as*S(A*sa",
             },
             {
                 # blank url
-                'request': MockRequest('http', 'testforum.com'),
-                'url': '/'
+                "request": MockRequest("http", "testforum.com"),
+                "url": "/",
             },
             {
                 # empty url
-                'request': MockRequest('http', 'testforum.com'),
-                'url': ''
-            }
+                "request": MockRequest("http", "testforum.com"),
+                "url": "",
+            },
         ]
 
         for case in TEST_CASES:
-            pk = get_thread_id_from_url(case['request'], case['url'])
-            self.assertIsNone(pk, 'get_thread_id_from_url for %s should fail' % case['url'])
+            pk = get_thread_id_from_url(case["request"], case["url"])
+            self.assertIsNone(
+                pk, "get_thread_id_from_url for %s should fail" % case["url"]
+            )

+ 40 - 42
misago/threads/tests/test_validate_post.py

@@ -8,32 +8,32 @@ class ValidatePostTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.category = Category.objects.get(slug='first-category')
-        self.api_link = reverse('misago:api:thread-list')
+        self.category = Category.objects.get(slug="first-category")
+        self.api_link = reverse("misago:api:thread-list")
 
     def test_title_validation(self):
         """validate_post tests title"""
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': 'Check our l33t CaSiNo!',
-                'post': 'Lorem ipsum dolor met!',
-            }
+                "category": self.category.pk,
+                "title": "Check our l33t CaSiNo!",
+                "post": "Lorem ipsum dolor met!",
+            },
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'non_field_errors': ["Don't discuss gambling!"],
-        })
+        self.assertEqual(
+            response.json(), {"non_field_errors": ["Don't discuss gambling!"]}
+        )
 
         # clean title passes validation
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': 'Check our l33t place!',
-                'post': 'Lorem ipsum dolor met!',
-            }
+                "category": self.category.pk,
+                "title": "Check our l33t place!",
+                "post": "Lorem ipsum dolor met!",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -42,49 +42,47 @@ class ValidatePostTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': 'Lorem ipsum dolor met!',
-                'post': 'Check our l33t CaSiNo!',
-            }
+                "category": self.category.pk,
+                "title": "Lorem ipsum dolor met!",
+                "post": "Check our l33t CaSiNo!",
+            },
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'non_field_errors': ["Don't discuss gambling!"],
-        })
+        self.assertEqual(
+            response.json(), {"non_field_errors": ["Don't discuss gambling!"]}
+        )
 
         # clean post passes validation
         response = self.client.post(
             self.api_link,
             data={
-                'category': self.category.pk,
-                'title': 'Lorem ipsum dolor met!',
-                'post': 'Check our l33t place!',
-            }
+                "category": self.category.pk,
+                "title": "Lorem ipsum dolor met!",
+                "post": "Check our l33t place!",
+            },
         )
         self.assertEqual(response.status_code, 200)
 
     def test_empty_input(self):
         """validate_post handles empty input"""
-        response = self.client.post(
-            self.api_link, data={
-                'category': self.category.pk,
-            }
-        )
+        response = self.client.post(self.api_link, data={"category": self.category.pk})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'title': ['You have to enter thread title.'],
-            'post': ['You have to enter a message.'],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "title": ["You have to enter thread title."],
+                "post": ["You have to enter a message."],
+            },
+        )
 
         response = self.client.post(
-            self.api_link, data={
-                'category': self.category.pk,
-                'title': '',
-                'post': '',
-            }
+            self.api_link, data={"category": self.category.pk, "title": "", "post": ""}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'title': ['This field may not be blank.'],
-            'post': ['This field may not be blank.'],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "title": ["This field may not be blank."],
+                "post": ["This field may not be blank."],
+            },
+        )

+ 2 - 6
misago/threads/tests/test_validators.py

@@ -28,7 +28,7 @@ class ValidatePostLengthTests(TestCase):
         """too long post is rejected"""
         settings = Mock(post_length_min=1, post_length_max=2)
         with self.assertRaises(ValidationError):
-            post = 'a' * settings.post_length_max
+            post = "a" * settings.post_length_max
             validate_post_length(settings, "abc")
 
 
@@ -37,11 +37,7 @@ class ValidateThreadTitleTests(TestCase):
         """validate_thread_title is ok with valid titles"""
         settings = Mock(thread_title_length_min=1, thread_title_length_max=50)
 
-        VALID_TITLES = [
-            'Lorem ipsum dolor met',
-            '123 456 789 112'
-            'Ugabugagagagagaga',
-        ]
+        VALID_TITLES = ["Lorem ipsum dolor met", "123 456 789 112" "Ugabugagagagagaga"]
 
         for title in VALID_TITLES:
             validate_thread_title(settings, title)

+ 79 - 98
misago/threads/testutils.py

@@ -13,50 +13,54 @@ UserModel = get_user_model()
 
 
 def post_thread(
-        category,
-        title='Test thread',
-        poster='Tester',
-        is_global=False,
-        is_pinned=False,
-        is_unapproved=False,
-        is_hidden=False,
-        is_closed=False,
-        started_on=None
+    category,
+    title="Test thread",
+    poster="Tester",
+    is_global=False,
+    is_pinned=False,
+    is_unapproved=False,
+    is_hidden=False,
+    is_closed=False,
+    started_on=None,
 ):
     started_on = started_on or timezone.now()
 
     kwargs = {
-        'category': category,
-        'title': title,
-        'slug': slugify(title),
-        'started_on': started_on,
-        'last_post_on': started_on,
-        'is_unapproved': is_unapproved,
-        'is_hidden': is_hidden,
-        'is_closed': is_closed,
+        "category": category,
+        "title": title,
+        "slug": slugify(title),
+        "started_on": started_on,
+        "last_post_on": started_on,
+        "is_unapproved": is_unapproved,
+        "is_hidden": is_hidden,
+        "is_closed": is_closed,
     }
 
     if is_global:
-        kwargs['weight'] = 2
+        kwargs["weight"] = 2
     elif is_pinned:
-        kwargs['weight'] = 1
+        kwargs["weight"] = 1
 
     try:
-        kwargs.update({
-            'starter': poster,
-            'starter_name': poster.username,
-            'starter_slug': poster.slug,
-            'last_poster': poster,
-            'last_poster_name': poster.username,
-            'last_poster_slug': poster.slug,
-        })
+        kwargs.update(
+            {
+                "starter": poster,
+                "starter_name": poster.username,
+                "starter_slug": poster.slug,
+                "last_poster": poster,
+                "last_poster_name": poster.username,
+                "last_poster_slug": poster.slug,
+            }
+        )
     except AttributeError:
-        kwargs.update({
-            'starter_name': poster,
-            'starter_slug': slugify(poster),
-            'last_poster_name': poster,
-            'last_poster_slug': slugify(poster),
-        })
+        kwargs.update(
+            {
+                "starter_name": poster,
+                "starter_slug": slugify(poster),
+                "last_poster_name": poster,
+                "last_poster_slug": slugify(poster),
+            }
+        )
 
     thread = Thread.objects.create(**kwargs)
     reply_thread(
@@ -71,42 +75,39 @@ def post_thread(
 
 
 def reply_thread(
-        thread,
-        poster="Tester",
-        message="I am test message",
-        is_unapproved=False,
-        is_hidden=False,
-        is_event=False,
-        is_protected=False,
-        has_reports=False,
-        has_open_reports=False,
-        posted_on=None
+    thread,
+    poster="Tester",
+    message="I am test message",
+    is_unapproved=False,
+    is_hidden=False,
+    is_event=False,
+    is_protected=False,
+    has_reports=False,
+    has_open_reports=False,
+    posted_on=None,
 ):
     posted_on = posted_on or thread.last_post_on + timedelta(minutes=5)
 
     kwargs = {
-        'category': thread.category,
-        'thread': thread,
-        'original': message,
-        'parsed': message,
-        'checksum': 'nope',
-        'posted_on': posted_on,
-        'updated_on': posted_on,
-        'is_event': is_event,
-        'is_unapproved': is_unapproved,
-        'is_hidden': is_hidden,
-        'is_protected': is_protected,
-        'has_reports': has_reports,
-        'has_open_reports': has_open_reports,
+        "category": thread.category,
+        "thread": thread,
+        "original": message,
+        "parsed": message,
+        "checksum": "nope",
+        "posted_on": posted_on,
+        "updated_on": posted_on,
+        "is_event": is_event,
+        "is_unapproved": is_unapproved,
+        "is_hidden": is_hidden,
+        "is_protected": is_protected,
+        "has_reports": has_reports,
+        "has_open_reports": has_open_reports,
     }
 
     try:
-        kwargs.update({
-            'poster': poster,
-            'poster_name': poster.username,
-        })
+        kwargs.update({"poster": poster, "poster_name": poster.username})
     except AttributeError:
-        kwargs.update({'poster_name': poster})
+        kwargs.update({"poster_name": poster})
 
     post = Post.objects.create(**kwargs)
 
@@ -130,36 +131,20 @@ def post_poll(thread, poster):
         poster_slug=poster.slug,
         question="Lorem ipsum dolor met?",
         choices=[
-            {
-                'hash': 'aaaaaaaaaaaa',
-                'label': 'Alpha',
-                'votes': 1
-            },
-            {
-                'hash': 'bbbbbbbbbbbb',
-                'label': 'Beta',
-                'votes': 0
-            },
-            {
-                'hash': 'gggggggggggg',
-                'label': 'Gamma',
-                'votes': 2
-            },
-            {
-                'hash': 'dddddddddddd',
-                'label': 'Delta',
-                'votes': 1
-            },
+            {"hash": "aaaaaaaaaaaa", "label": "Alpha", "votes": 1},
+            {"hash": "bbbbbbbbbbbb", "label": "Beta", "votes": 0},
+            {"hash": "gggggggggggg", "label": "Gamma", "votes": 2},
+            {"hash": "dddddddddddd", "label": "Delta", "votes": 1},
         ],
         allowed_choices=2,
-        votes=4
+        votes=4,
     )
 
     # one user voted for Alpha choice
     try:
-        user = UserModel.objects.get(slug='bob')
+        user = UserModel.objects.get(slug="bob")
     except UserModel.DoesNotExist:
-        user = UserModel.objects.create_user('bob', 'bob@test.com', 'Pass.123')
+        user = UserModel.objects.create_user("bob", "bob@test.com", "Pass.123")
 
     poll.pollvote_set.create(
         category=thread.category,
@@ -167,7 +152,7 @@ def post_poll(thread, poster):
         voter=user,
         voter_name=user.username,
         voter_slug=user.slug,
-        choice_hash='aaaaaaaaaaaa',
+        choice_hash="aaaaaaaaaaaa",
     )
 
     # test user voted on third and last choices
@@ -177,7 +162,7 @@ def post_poll(thread, poster):
         voter=poster,
         voter_name=poster.username,
         voter_slug=poster.slug,
-        choice_hash='gggggggggggg',
+        choice_hash="gggggggggggg",
     )
     poll.pollvote_set.create(
         category=thread.category,
@@ -185,16 +170,16 @@ def post_poll(thread, poster):
         voter=poster,
         voter_name=poster.username,
         voter_slug=poster.slug,
-        choice_hash='dddddddddddd',
+        choice_hash="dddddddddddd",
     )
 
     # somebody else voted on third option before being deleted
     poll.pollvote_set.create(
         category=thread.category,
         thread=thread,
-        voter_name='deleted',
-        voter_slug='deleted',
-        choice_hash='gggggggggggg',
+        voter_name="deleted",
+        voter_slug="deleted",
+        choice_hash="gggggggggggg",
     )
 
     return poll
@@ -213,10 +198,9 @@ def like_post(post, liker=None, username=None):
             liker_slug=liker.slug,
         )
 
-        post.last_likes = [{
-            'id': liker.id,
-            'username': liker.username,
-        }] + post.last_likes
+        post.last_likes = [
+            {"id": liker.id, "username": liker.username}
+        ] + post.last_likes
     else:
         like = post.postlike_set.create(
             category=post.category,
@@ -225,10 +209,7 @@ def like_post(post, liker=None, username=None):
             liker_slug=slugify(username),
         )
 
-        post.last_likes = [{
-            'id': None,
-            'username': username,
-        }] + post.last_likes
+        post.last_likes = [{"id": None, "username": username}] + post.last_likes
 
     post.likes += 1
     post.save()

+ 2 - 1
misago/threads/threadtypes/__init__.py

@@ -3,7 +3,8 @@ from .treesmap import trees_map
 
 class ThreadType(object):
     """Abstract class for thread type strategy"""
-    root_name = 'undefined'
+
+    root_name = "undefined"
 
     def get_forum_name(self, category):
         return category.name

+ 29 - 80
misago/threads/threadtypes/privatethread.py

@@ -10,151 +10,100 @@ class PrivateThread(ThreadType):
     root_name = PRIVATE_THREADS_ROOT_NAME
 
     def get_category_name(self, category):
-        return _('Private threads')
+        return _("Private threads")
 
     def get_category_absolute_url(self, category):
-        return reverse('misago:private-threads')
+        return reverse("misago:private-threads")
 
     def get_category_last_thread_url(self, category):
         return reverse(
-            'misago:private-thread',
-            kwargs={
-                'slug': category.last_thread_slug,
-                'pk': category.last_thread_id,
-            }
+            "misago:private-thread",
+            kwargs={"slug": category.last_thread_slug, "pk": category.last_thread_id},
         )
 
     def get_category_last_thread_new_url(self, category):
         return reverse(
-            'misago:private-thread-new',
-            kwargs={
-                'slug': category.last_thread_slug,
-                'pk': category.last_thread_id,
-            }
+            "misago:private-thread-new",
+            kwargs={"slug": category.last_thread_slug, "pk": category.last_thread_id},
         )
 
     def get_category_last_post_url(self, category):
         return reverse(
-            'misago:private-thread-last',
-            kwargs={
-                'slug': category.last_thread_slug,
-                'pk': category.last_thread_id,
-            }
+            "misago:private-thread-last",
+            kwargs={"slug": category.last_thread_slug, "pk": category.last_thread_id},
         )
 
     def get_thread_absolute_url(self, thread, page=1):
         if page > 1:
             return reverse(
-                'misago:private-thread',
-                kwargs={
-                    'slug': thread.slug,
-                    'pk': thread.pk,
-                    'page': page,
-                }
+                "misago:private-thread",
+                kwargs={"slug": thread.slug, "pk": thread.pk, "page": page},
             )
         else:
             return reverse(
-                'misago:private-thread', kwargs={
-                    'slug': thread.slug,
-                    'pk': thread.pk,
-                }
+                "misago:private-thread", kwargs={"slug": thread.slug, "pk": thread.pk}
             )
 
     def get_thread_last_post_url(self, thread):
         return reverse(
-            'misago:private-thread-last', kwargs={
-                'slug': thread.slug,
-                'pk': thread.pk,
-            }
+            "misago:private-thread-last", kwargs={"slug": thread.slug, "pk": thread.pk}
         )
 
     def get_thread_new_post_url(self, thread):
         return reverse(
-            'misago:private-thread-new', kwargs={
-                'slug': thread.slug,
-                'pk': thread.pk,
-            }
+            "misago:private-thread-new", kwargs={"slug": thread.slug, "pk": thread.pk}
         )
 
     def get_thread_api_url(self, thread):
-        return reverse(
-            'misago:api:private-thread-detail', kwargs={
-                'pk': thread.pk,
-            }
-        )
+        return reverse("misago:api:private-thread-detail", kwargs={"pk": thread.pk})
 
     def get_thread_editor_api_url(self, thread):
         return reverse(
-            'misago:api:private-thread-post-editor', kwargs={
-                'thread_pk': thread.pk,
-            }
+            "misago:api:private-thread-post-editor", kwargs={"thread_pk": thread.pk}
         )
 
     def get_thread_posts_api_url(self, thread):
         return reverse(
-            'misago:api:private-thread-post-list', kwargs={
-                'thread_pk': thread.pk,
-            }
+            "misago:api:private-thread-post-list", kwargs={"thread_pk": thread.pk}
         )
 
     def get_post_merge_api_url(self, thread):
         return reverse(
-            'misago:api:private-thread-post-merge', kwargs={
-                'thread_pk': thread.pk,
-            }
+            "misago:api:private-thread-post-merge", kwargs={"thread_pk": thread.pk}
         )
 
     def get_post_absolute_url(self, post):
         return reverse(
-            'misago:private-thread-post',
-            kwargs={
-                'slug': post.thread.slug,
-                'pk': post.thread.pk,
-                'post': post.pk,
-            }
+            "misago:private-thread-post",
+            kwargs={"slug": post.thread.slug, "pk": post.thread.pk, "post": post.pk},
         )
 
     def get_post_api_url(self, post):
         return reverse(
-            'misago:api:private-thread-post-detail',
-            kwargs={
-                'thread_pk': post.thread_id,
-                'pk': post.pk,
-            }
+            "misago:api:private-thread-post-detail",
+            kwargs={"thread_pk": post.thread_id, "pk": post.pk},
         )
 
     def get_post_likes_api_url(self, post):
         return reverse(
-            'misago:api:private-thread-post-likes',
-            kwargs={
-                'thread_pk': post.thread_id,
-                'pk': post.pk,
-            }
+            "misago:api:private-thread-post-likes",
+            kwargs={"thread_pk": post.thread_id, "pk": post.pk},
         )
 
     def get_post_editor_api_url(self, post):
         return reverse(
-            'misago:api:private-thread-post-editor',
-            kwargs={
-                'thread_pk': post.thread_id,
-                'pk': post.pk,
-            }
+            "misago:api:private-thread-post-editor",
+            kwargs={"thread_pk": post.thread_id, "pk": post.pk},
         )
 
     def get_post_edits_api_url(self, post):
         return reverse(
-            'misago:api:private-thread-post-edits',
-            kwargs={
-                'thread_pk': post.thread_id,
-                'pk': post.pk,
-            }
+            "misago:api:private-thread-post-edits",
+            kwargs={"thread_pk": post.thread_id, "pk": post.pk},
         )
 
     def get_post_read_api_url(self, post):
         return reverse(
-            'misago:api:private-thread-post-read',
-            kwargs={
-                'thread_pk': post.thread_id,
-                'pk': post.pk,
-            }
+            "misago:api:private-thread-post-read",
+            kwargs={"thread_pk": post.thread_id, "pk": post.pk},
         )

+ 40 - 120
misago/threads/threadtypes/thread.py

@@ -13,213 +13,133 @@ class Thread(ThreadType):
         if category.level:
             return category.name
         else:
-            return _('None (will become top level category)')
+            return _("None (will become top level category)")
 
     def get_category_absolute_url(self, category):
         if category.level:
             return reverse(
-                'misago:category', kwargs={
-                    'pk': category.pk,
-                    'slug': category.slug,
-                }
+                "misago:category", kwargs={"pk": category.pk, "slug": category.slug}
             )
         else:
-            return reverse('misago:threads')
+            return reverse("misago:threads")
 
     def get_category_last_thread_url(self, category):
         return reverse(
-            'misago:thread',
-            kwargs={
-                'slug': category.last_thread_slug,
-                'pk': category.last_thread_id,
-            }
+            "misago:thread",
+            kwargs={"slug": category.last_thread_slug, "pk": category.last_thread_id},
         )
 
     def get_category_last_thread_new_url(self, category):
         return reverse(
-            'misago:thread-new',
-            kwargs={
-                'slug': category.last_thread_slug,
-                'pk': category.last_thread_id,
-            }
+            "misago:thread-new",
+            kwargs={"slug": category.last_thread_slug, "pk": category.last_thread_id},
         )
 
     def get_category_last_post_url(self, category):
         return reverse(
-            'misago:thread-last',
-            kwargs={
-                'slug': category.last_thread_slug,
-                'pk': category.last_thread_id,
-            }
+            "misago:thread-last",
+            kwargs={"slug": category.last_thread_slug, "pk": category.last_thread_id},
         )
 
     def get_thread_absolute_url(self, thread, page=1):
         if page > 1:
             return reverse(
-                'misago:thread', kwargs={
-                    'slug': thread.slug,
-                    'pk': thread.pk,
-                    'page': page,
-                }
+                "misago:thread",
+                kwargs={"slug": thread.slug, "pk": thread.pk, "page": page},
             )
         else:
             return reverse(
-                'misago:thread', kwargs={
-                    'slug': thread.slug,
-                    'pk': thread.pk,
-                }
+                "misago:thread", kwargs={"slug": thread.slug, "pk": thread.pk}
             )
 
     def get_thread_last_post_url(self, thread):
         return reverse(
-            'misago:thread-last', kwargs={
-                'slug': thread.slug,
-                'pk': thread.pk,
-            }
+            "misago:thread-last", kwargs={"slug": thread.slug, "pk": thread.pk}
         )
 
     def get_thread_new_post_url(self, thread):
         return reverse(
-            'misago:thread-new', kwargs={
-                'slug': thread.slug,
-                'pk': thread.pk,
-            }
+            "misago:thread-new", kwargs={"slug": thread.slug, "pk": thread.pk}
         )
 
     def get_thread_best_answer_url(self, thread):
         return reverse(
-            'misago:thread-best-answer', kwargs={
-                'slug': thread.slug,
-                'pk': thread.pk,
-            }
+            "misago:thread-best-answer", kwargs={"slug": thread.slug, "pk": thread.pk}
         )
 
     def get_thread_unapproved_post_url(self, thread):
         return reverse(
-            'misago:thread-unapproved', kwargs={
-                'slug': thread.slug,
-                'pk': thread.pk,
-            }
+            "misago:thread-unapproved", kwargs={"slug": thread.slug, "pk": thread.pk}
         )
 
     def get_thread_api_url(self, thread):
-        return reverse(
-            'misago:api:thread-detail', kwargs={
-                'pk': thread.pk,
-            }
-        )
+        return reverse("misago:api:thread-detail", kwargs={"pk": thread.pk})
 
     def get_thread_editor_api_url(self, thread):
-        return reverse(
-            'misago:api:thread-post-editor', kwargs={
-                'thread_pk': thread.pk,
-            }
-        )
+        return reverse("misago:api:thread-post-editor", kwargs={"thread_pk": thread.pk})
 
     def get_thread_merge_api_url(self, thread):
-        return reverse(
-            'misago:api:thread-merge', kwargs={
-                'pk': thread.pk,
-            }
-        )
+        return reverse("misago:api:thread-merge", kwargs={"pk": thread.pk})
 
     def get_thread_poll_api_url(self, thread):
-        return reverse(
-            'misago:api:thread-poll-list', kwargs={
-                'thread_pk': thread.pk,
-            }
-        )
+        return reverse("misago:api:thread-poll-list", kwargs={"thread_pk": thread.pk})
 
     def get_thread_posts_api_url(self, thread):
-        return reverse(
-            'misago:api:thread-post-list', kwargs={
-                'thread_pk': thread.pk,
-            }
-        )
+        return reverse("misago:api:thread-post-list", kwargs={"thread_pk": thread.pk})
 
     def get_poll_api_url(self, poll):
         return reverse(
-            'misago:api:thread-poll-detail', kwargs={
-                'thread_pk': poll.thread_id,
-                'pk': poll.pk,
-            }
+            "misago:api:thread-poll-detail",
+            kwargs={"thread_pk": poll.thread_id, "pk": poll.pk},
         )
 
     def get_poll_votes_api_url(self, poll):
         return reverse(
-            'misago:api:thread-poll-votes', kwargs={
-                'thread_pk': poll.thread_id,
-                'pk': poll.pk,
-            }
+            "misago:api:thread-poll-votes",
+            kwargs={"thread_pk": poll.thread_id, "pk": poll.pk},
         )
 
     def get_post_merge_api_url(self, thread):
-        return reverse(
-            'misago:api:thread-post-merge', kwargs={
-                'thread_pk': thread.pk,
-            }
-        )
+        return reverse("misago:api:thread-post-merge", kwargs={"thread_pk": thread.pk})
 
     def get_post_move_api_url(self, thread):
-        return reverse(
-            'misago:api:thread-post-move', kwargs={
-                'thread_pk': thread.pk,
-            }
-        )
+        return reverse("misago:api:thread-post-move", kwargs={"thread_pk": thread.pk})
 
     def get_post_split_api_url(self, thread):
-        return reverse(
-            'misago:api:thread-post-split', kwargs={
-                'thread_pk': thread.pk,
-            }
-        )
+        return reverse("misago:api:thread-post-split", kwargs={"thread_pk": thread.pk})
 
     def get_post_absolute_url(self, post):
         return reverse(
-            'misago:thread-post',
-            kwargs={
-                'slug': post.thread.slug,
-                'pk': post.thread.pk,
-                'post': post.pk,
-            }
+            "misago:thread-post",
+            kwargs={"slug": post.thread.slug, "pk": post.thread.pk, "post": post.pk},
         )
 
     def get_post_api_url(self, post):
         return reverse(
-            'misago:api:thread-post-detail', kwargs={
-                'thread_pk': post.thread_id,
-                'pk': post.pk,
-            }
+            "misago:api:thread-post-detail",
+            kwargs={"thread_pk": post.thread_id, "pk": post.pk},
         )
 
     def get_post_likes_api_url(self, post):
         return reverse(
-            'misago:api:thread-post-likes', kwargs={
-                'thread_pk': post.thread_id,
-                'pk': post.pk,
-            }
+            "misago:api:thread-post-likes",
+            kwargs={"thread_pk": post.thread_id, "pk": post.pk},
         )
 
     def get_post_editor_api_url(self, post):
         return reverse(
-            'misago:api:thread-post-editor', kwargs={
-                'thread_pk': post.thread_id,
-                'pk': post.pk,
-            }
+            "misago:api:thread-post-editor",
+            kwargs={"thread_pk": post.thread_id, "pk": post.pk},
         )
 
     def get_post_edits_api_url(self, post):
         return reverse(
-            'misago:api:thread-post-edits', kwargs={
-                'thread_pk': post.thread_id,
-                'pk': post.pk,
-            }
+            "misago:api:thread-post-edits",
+            kwargs={"thread_pk": post.thread_id, "pk": post.pk},
         )
 
     def get_post_read_api_url(self, post):
         return reverse(
-            'misago:api:thread-post-read', kwargs={
-                'thread_pk': post.thread_id,
-                'pk': post.pk,
-            }
+            "misago:api:thread-post-read",
+            kwargs={"thread_pk": post.thread_id, "pk": post.pk},
         )

+ 1 - 0
misago/threads/threadtypes/treesmap.py

@@ -25,6 +25,7 @@ class TreesMap(object):
 
     def load_trees(self, types):
         from misago.categories.models import Category
+
         trees = {}
         for category in Category.objects.filter(level=0, special_role__in=types.keys()):
             trees[category.tree_id] = types[category.special_role]

+ 78 - 42
misago/threads/urls/__init__.py

@@ -4,21 +4,30 @@ from misago.conf import settings
 
 from misago.threads.views.attachment import attachment_server
 from misago.threads.views.goto import (
-    ThreadGotoPostView, ThreadGotoLastView, ThreadGotoNewView, ThreadGotoBestAnswerView,
-    ThreadGotoUnapprovedView, PrivateThreadGotoPostView, PrivateThreadGotoLastView,
-    PrivateThreadGotoNewView
+    ThreadGotoPostView,
+    ThreadGotoLastView,
+    ThreadGotoNewView,
+    ThreadGotoBestAnswerView,
+    ThreadGotoUnapprovedView,
+    PrivateThreadGotoPostView,
+    PrivateThreadGotoLastView,
+    PrivateThreadGotoNewView,
+)
+from misago.threads.views.list import (
+    ForumThreadsList,
+    CategoryThreadsList,
+    PrivateThreadsList,
 )
-from misago.threads.views.list import ForumThreadsList, CategoryThreadsList, PrivateThreadsList
 from misago.threads.views.thread import ThreadView, PrivateThreadView
 
-LISTS_TYPES = ('all', 'my', 'new', 'unread', 'subscribed', 'unapproved', )
+LISTS_TYPES = ("all", "my", "new", "unread", "subscribed", "unapproved")
 
 
 def threads_list_patterns(prefix, view, patterns):
     urls = []
     for i, pattern in enumerate(patterns):
         if i > 0:
-            url_name = '%s-%s' % (prefix, LISTS_TYPES[i])
+            url_name = "%s-%s" % (prefix, LISTS_TYPES[i])
         else:
             url_name = prefix
 
@@ -27,7 +36,7 @@ def threads_list_patterns(prefix, view, patterns):
                 pattern,
                 view.as_view(),
                 name=url_name,
-                kwargs={'list_type': LISTS_TYPES[i]},
+                kwargs={"list_type": LISTS_TYPES[i]},
             )
         )
     return urls
@@ -35,72 +44,95 @@ def threads_list_patterns(prefix, view, patterns):
 
 if settings.MISAGO_THREADS_ON_INDEX:
     urlpatterns = threads_list_patterns(
-        'threads', ForumThreadsList,
-        (r'^$', r'^my/$', r'^new/$', r'^unread/$', r'^subscribed/$', r'^unapproved/$', )
+        "threads",
+        ForumThreadsList,
+        (r"^$", r"^my/$", r"^new/$", r"^unread/$", r"^subscribed/$", r"^unapproved/$"),
     )
 else:
     urlpatterns = threads_list_patterns(
-        'threads', ForumThreadsList, (
-            r'^threads/$', r'^threads/my/$', r'^threads/new/$', r'^threads/unread/$',
-            r'^threads/subscribed/$', r'^threads/unapproved/$',
-        )
+        "threads",
+        ForumThreadsList,
+        (
+            r"^threads/$",
+            r"^threads/my/$",
+            r"^threads/new/$",
+            r"^threads/unread/$",
+            r"^threads/subscribed/$",
+            r"^threads/unapproved/$",
+        ),
     )
 
 urlpatterns += threads_list_patterns(
-    'category', CategoryThreadsList, (
-        r'^c/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/$',
-        r'^c/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/my/$',
-        r'^c/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/new/$',
-        r'^c/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/unread/$',
-        r'^c/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/subscribed/$',
-        r'^c/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/unapproved/$',
-    )
+    "category",
+    CategoryThreadsList,
+    (
+        r"^c/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/$",
+        r"^c/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/my/$",
+        r"^c/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/new/$",
+        r"^c/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/unread/$",
+        r"^c/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/subscribed/$",
+        r"^c/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/unapproved/$",
+    ),
 )
 
 urlpatterns += threads_list_patterns(
-    'private-threads', PrivateThreadsList, (
-        r'^private-threads/$', r'^private-threads/my/$', r'^private-threads/new/$',
-        r'^private-threads/unread/$', r'^private-threads/subscribed/$',
-    )
+    "private-threads",
+    PrivateThreadsList,
+    (
+        r"^private-threads/$",
+        r"^private-threads/my/$",
+        r"^private-threads/new/$",
+        r"^private-threads/unread/$",
+        r"^private-threads/subscribed/$",
+    ),
 )
 
 
 def thread_view_patterns(prefix, view):
     urls = [
-        url(r'^%s/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/$' % prefix[0], view.as_view(), name=prefix),
         url(
-            r'^%s/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/(?P<page>\d+)/$' % prefix[0],
+            r"^%s/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/$" % prefix[0],
             view.as_view(),
-            name=prefix
+            name=prefix,
+        ),
+        url(
+            r"^%s/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/(?P<page>\d+)/$" % prefix[0],
+            view.as_view(),
+            name=prefix,
         ),
     ]
     return urls
 
 
-urlpatterns += thread_view_patterns('thread', ThreadView)
-urlpatterns += thread_view_patterns('private-thread', PrivateThreadView)
+urlpatterns += thread_view_patterns("thread", ThreadView)
+urlpatterns += thread_view_patterns("private-thread", PrivateThreadView)
 
 
 def goto_patterns(prefix, **views):
     urls = []
 
-    post_view = views.pop('post', None)
+    post_view = views.pop("post", None)
     if post_view:
-        url_pattern = r'^%s/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/post/(?P<post>\d+)/$' % prefix[0]
-        url_name = '%s-post' % prefix
+        url_pattern = (
+            r"^%s/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/post/(?P<post>\d+)/$" % prefix[0]
+        )
+        url_name = "%s-post" % prefix
         urls.append(url(url_pattern, post_view.as_view(), name=url_name))
 
     for name, view in views.items():
-        name = name.replace('_', '-')
-        url_pattern = r'^%s/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/%s/$' % (prefix[0], name)
-        url_name = '%s-%s' % (prefix, name)
+        name = name.replace("_", "-")
+        url_pattern = r"^%s/(?P<slug>[-a-zA-Z0-9]+)/(?P<pk>\d+)/%s/$" % (
+            prefix[0],
+            name,
+        )
+        url_name = "%s-%s" % (prefix, name)
         urls.append(url(url_pattern, view.as_view(), name=url_name))
 
     return urls
 
 
 urlpatterns += goto_patterns(
-    'thread',
+    "thread",
     post=ThreadGotoPostView,
     last=ThreadGotoLastView,
     new=ThreadGotoNewView,
@@ -109,18 +141,22 @@ urlpatterns += goto_patterns(
 )
 
 urlpatterns += goto_patterns(
-    'private-thread',
+    "private-thread",
     post=PrivateThreadGotoPostView,
     last=PrivateThreadGotoLastView,
     new=PrivateThreadGotoNewView,
 )
 
 urlpatterns += [
-    url(r'^a/(?P<secret>[-a-zA-Z0-9]+)/(?P<pk>\d+)/', attachment_server, name='attachment'),
     url(
-        r'^a/thumb/(?P<secret>[-a-zA-Z0-9]+)/(?P<pk>\d+)/',
+        r"^a/(?P<secret>[-a-zA-Z0-9]+)/(?P<pk>\d+)/",
+        attachment_server,
+        name="attachment",
+    ),
+    url(
+        r"^a/thumb/(?P<secret>[-a-zA-Z0-9]+)/(?P<pk>\d+)/",
         attachment_server,
-        name='attachment-thumbnail',
-        kwargs={'thumbnail': True}
+        name="attachment-thumbnail",
+        kwargs={"thumbnail": True},
     ),
 ]

+ 9 - 7
misago/threads/urls/api.py

@@ -7,19 +7,21 @@ from misago.threads.api.threads import PrivateThreadViewSet, ThreadViewSet
 
 router = MisagoApiRouter()
 
-router.register(r'attachments', AttachmentViewSet, base_name='attachment')
+router.register(r"attachments", AttachmentViewSet, base_name="attachment")
 
-router.register(r'threads', ThreadViewSet, base_name='thread')
+router.register(r"threads", ThreadViewSet, base_name="thread")
 router.register(
-    r'threads/(?P<thread_pk>[^/.]+)/posts', ThreadPostsViewSet, base_name='thread-post'
+    r"threads/(?P<thread_pk>[^/.]+)/posts", ThreadPostsViewSet, base_name="thread-post"
+)
+router.register(
+    r"threads/(?P<thread_pk>[^/.]+)/poll", ThreadPollViewSet, base_name="thread-poll"
 )
-router.register(r'threads/(?P<thread_pk>[^/.]+)/poll', ThreadPollViewSet, base_name='thread-poll')
 
-router.register(r'private-threads', PrivateThreadViewSet, base_name='private-thread')
+router.register(r"private-threads", PrivateThreadViewSet, base_name="private-thread")
 router.register(
-    r'private-threads/(?P<thread_pk>[^/.]+)/posts',
+    r"private-threads/(?P<thread_pk>[^/.]+)/posts",
     PrivateThreadPostsViewSet,
-    base_name='private-thread-post'
+    base_name="private-thread-post",
 )
 
 urlpatterns = router.urls

+ 10 - 11
misago/threads/utils.py

@@ -25,16 +25,16 @@ def add_likes_to_posts(user, posts):
 
     queryset = PostLike.objects.filter(liker=user, post_id__in=posts_map.keys())
 
-    for like in queryset.values('post_id'):
-        posts_map[like['post_id']].is_liked = True
+    for like in queryset.values("post_id"):
+        posts_map[like["post_id"]].is_liked = True
 
 
 SUPPORTED_THREAD_ROUTES = {
-    'misago:thread': 'pk',
-    'misago:thread-post': 'pk',
-    'misago:thread-last': 'pk',
-    'misago:thread-new': 'pk',
-    'misago:thread-unapproved': 'pk',
+    "misago:thread": "pk",
+    "misago:thread-post": "pk",
+    "misago:thread-last": "pk",
+    "misago:thread-new": "pk",
+    "misago:thread-unapproved": "pk",
 }
 
 
@@ -54,17 +54,17 @@ def get_thread_id_from_url(request, url):
         clean_path = bits.path
 
     try:
-        wsgi_alias = request.path[:len(request.path_info) * -1]
+        wsgi_alias = request.path[: len(request.path_info) * -1]
         if wsgi_alias and not clean_path.startswith(wsgi_alias):
             return None
-        resolution = resolve(clean_path[len(wsgi_alias):])
+        resolution = resolve(clean_path[len(wsgi_alias) :])
     except:
         return None
 
     if not resolution.namespaces:
         return None
 
-    url_name = '%s:%s' % (':'.join(resolution.namespaces), resolution.url_name)
+    url_name = "%s:%s" % (":".join(resolution.namespaces), resolution.url_name)
     kwargname = SUPPORTED_THREAD_ROUTES.get(url_name)
 
     if not kwargname:
@@ -74,4 +74,3 @@ def get_thread_id_from_url(request, url):
         return int(resolution.kwargs.get(kwargname))
     except (TypeError, ValueError):
         return None
-

+ 7 - 20
misago/threads/validators.py

@@ -15,10 +15,7 @@ from .threadtypes import trees_map
 def validate_category(user_acl, category_id, allow_root=False):
     try:
         threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
-        category = Category.objects.get(
-            tree_id=threads_tree_id,
-            id=category_id,
-        )
+        category = Category.objects.get(tree_id=threads_tree_id, id=category_id)
     except Category.DoesNotExist:
         category = None
 
@@ -55,10 +52,8 @@ def validate_thread_title_length(settings, value):
             settings.thread_title_length_min,
         )
         raise ValidationError(
-            message % {
-                'limit_value': settings.thread_title_length_min,
-                'show_value': value_len,
-            }
+            message
+            % {"limit_value": settings.thread_title_length_min, "show_value": value_len}
         )
 
     if value_len > settings.thread_title_length_max:
@@ -68,10 +63,8 @@ def validate_thread_title_length(settings, value):
             settings.thread_title_length_max,
         )
         raise ValidationError(
-            message % {
-                'limit_value': settings.thread_title_length_max,
-                'show_value': value_len,
-            }
+            message
+            % {"limit_value": settings.thread_title_length_max, "show_value": value_len}
         )
 
 
@@ -88,10 +81,7 @@ def validate_post_length(settings, value):
             settings.post_length_min,
         )
         raise ValidationError(
-            message % {
-                'limit_value': settings.post_length_min,
-                'show_value': value_len,
-            }
+            message % {"limit_value": settings.post_length_min, "show_value": value_len}
         )
 
     if settings.post_length_max and value_len > settings.post_length_max:
@@ -101,10 +91,7 @@ def validate_post_length(settings, value):
             settings.post_length_max,
         )
         raise ValidationError(
-            message % {
-                'limit_value': settings.post_length_max,
-                'show_value': value_len,
-            }
+            message % {"limit_value": settings.post_length_max, "show_value": value_len}
         )
 
 

+ 26 - 13
misago/threads/viewmodels/category.py

@@ -9,7 +9,7 @@ from misago.core.viewmodel import ViewModel as BaseViewModel
 from misago.threads.permissions import allow_use_private_threads
 
 
-__all__ = ['ThreadsRootCategory', 'ThreadsCategory', 'PrivateThreadsCategory']
+__all__ = ["ThreadsRootCategory", "ThreadsCategory", "PrivateThreadsCategory"]
 
 
 class ViewModel(BaseViewModel):
@@ -20,7 +20,9 @@ class ViewModel(BaseViewModel):
         self._model = self.get_category(request, self._categories, **kwargs)
 
         self._subcategories = list(filter(self._model.has_child, self._categories))
-        self._children = list(filter(lambda s: s.parent_id == self._model.pk, self._subcategories))
+        self._children = list(
+            filter(lambda s: s.parent_id == self._model.pk, self._subcategories)
+        )
 
     @property
     def categories(self):
@@ -35,24 +37,26 @@ class ViewModel(BaseViewModel):
         return self._children
 
     def get_categories(self, request):
-        raise NotImplementedError('Category view model has to implement get_categories(request)')
+        raise NotImplementedError(
+            "Category view model has to implement get_categories(request)"
+        )
 
     def get_category(self, request, categories, **kwargs):
         return categories[0]
 
     def get_frontend_context(self):
-        return {'CATEGORIES': BasicCategorySerializer(self._categories, many=True).data}
+        return {"CATEGORIES": BasicCategorySerializer(self._categories, many=True).data}
 
     def get_template_context(self):
-        return {'category': self._model, 'subcategories': self._children}
+        return {"category": self._model, "subcategories": self._children}
 
 
 class ThreadsRootCategory(ViewModel):
     def get_categories(self, request):
         return [Category.objects.root_category()] + list(
-            Category.objects.all_categories().filter(
-                id__in=request.user_acl['visible_categories'],
-            ).select_related('parent')
+            Category.objects.all_categories()
+            .filter(id__in=request.user_acl["visible_categories"])
+            .select_related("parent")
         )
 
 
@@ -63,14 +67,14 @@ class ThreadsCategory(ThreadsRootCategory):
 
     def get_category(self, request, categories, **kwargs):
         for category in categories:
-            if category.pk == int(kwargs['pk']):
+            if category.pk == int(kwargs["pk"]):
                 if not category.special_role:
                     # check permissions for non-special categories
                     allow_see_category(request.user_acl, category)
                     allow_browse_category(request.user_acl, category)
 
-                if 'slug' in kwargs:
-                    validate_slug(category, kwargs['slug'])
+                if "slug" in kwargs:
+                    validate_slug(category, kwargs["slug"])
 
                 return category
         raise Http404()
@@ -87,6 +91,15 @@ class PrivateThreadsCategory(ViewModel):
 
 
 BasicCategorySerializer = CategorySerializer.subset_fields(
-    'id', 'parent', 'name', 'description', 'is_closed', 'css_class',
-    'level', 'lft', 'rght', 'is_read', 'url'
+    "id",
+    "parent",
+    "name",
+    "description",
+    "is_closed",
+    "css_class",
+    "level",
+    "lft",
+    "rght",
+    "is_read",
+    "url",
 )

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

@@ -5,7 +5,7 @@ from misago.core.viewmodel import ViewModel as BaseViewModel
 from misago.threads.permissions import exclude_invisible_posts
 
 
-__all__ = ['ThreadPost']
+__all__ = ["ThreadPost"]
 
 
 class ViewModel(BaseViewModel):
@@ -23,9 +23,7 @@ class ViewModel(BaseViewModel):
             thread_model = thread
 
         queryset = self.get_queryset(request, thread_model).select_related(
-            'poster',
-            'poster__rank',
-            'poster__ban_cache',
+            "poster", "poster__rank", "poster__ban_cache"
         )
 
         post = get_object_or_404(queryset, pk=pk)
@@ -36,7 +34,9 @@ class ViewModel(BaseViewModel):
         return post
 
     def get_queryset(self, request, thread):
-        return exclude_invisible_posts(request.user_acl, thread.category, thread.post_set)
+        return exclude_invisible_posts(
+            request.user_acl, thread.category, thread.post_set
+        )
 
 
 class ThreadPost(ViewModel):

+ 26 - 21
misago/threads/viewmodels/posts.py

@@ -9,7 +9,7 @@ from misago.threads.utils import add_likes_to_posts
 from misago.users.online.utils import make_users_status_aware
 
 
-__all__ = ['ThreadPosts']
+__all__ = ["ThreadPosts"]
 
 
 class ViewModel(object):
@@ -40,7 +40,7 @@ class ViewModel(object):
 
         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 events to posts
@@ -70,22 +70,28 @@ class ViewModel(object):
         self.paginator = paginator
 
     def get_posts_queryset(self, request, thread):
-        queryset = thread.post_set.select_related(
-            'category',
-            'poster',
-            'poster__rank',
-            'poster__ban_cache',
-            'poster__online_tracker',
-        ).filter(is_event=False).order_by('id')
+        queryset = (
+            thread.post_set.select_related(
+                "category",
+                "poster",
+                "poster__rank",
+                "poster__ban_cache",
+                "poster__online_tracker",
+            )
+            .filter(is_event=False)
+            .order_by("id")
+        )
         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(
-            'category',
-            'poster',
-            'poster__rank',
-            'poster__ban_cache',
-            'poster__online_tracker',
+            "category",
+            "poster",
+            "poster__rank",
+            "poster__ban_cache",
+            "poster__online_tracker",
         ).filter(is_event=True)
 
         if first_post:
@@ -94,11 +100,13 @@ class ViewModel(object):
             queryset = queryset.filter(pk__lt=last_post.pk)
 
         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):
         context = {
-            'results': PostSerializer(self.posts, many=True, context={'user': self._user}).data
+            "results": PostSerializer(
+                self.posts, many=True, context={"user": self._user}
+            ).data
         }
 
         context.update(self.paginator)
@@ -106,10 +114,7 @@ class ViewModel(object):
         return context
 
     def get_template_context(self):
-        return {
-            'posts': self.posts,
-            'paginator': self.paginator,
-        }
+        return {"posts": self.posts, "paginator": self.paginator}
 
 
 class ThreadPosts(ViewModel):

+ 28 - 23
misago/threads/viewmodels/thread.py

@@ -10,34 +10,37 @@ from misago.readtracker.threadstracker import make_read_aware
 from misago.threads.models import Poll, Thread
 from misago.threads.participants import make_participants_aware
 from misago.threads.permissions import (
-    allow_see_private_thread, allow_see_thread, allow_use_private_threads)
+    allow_see_private_thread,
+    allow_see_thread,
+    allow_use_private_threads,
+)
 from misago.threads.serializers import PrivateThreadSerializer, ThreadSerializer
 from misago.threads.subscriptions import make_subscription_aware
 from misago.threads.threadtypes import trees_map
 
 
-__all__ = ['ForumThread', 'PrivateThread']
+__all__ = ["ForumThread", "PrivateThread"]
 
 BASE_RELATIONS = [
-    'category',
-    'poll',
-    'starter',
-    'starter__rank',
-    'starter__ban_cache',
-    'starter__online_tracker',
+    "category",
+    "poll",
+    "starter",
+    "starter__rank",
+    "starter__ban_cache",
+    "starter__online_tracker",
 ]
 
 
 class ViewModel(BaseViewModel):
     def __init__(
-            self,
-            request,
-            pk,
-            slug=None,
-            path_aware=False,
-            read_aware=False,
-            subscription_aware=False,
-            poll_votes_aware=False
+        self,
+        request,
+        pk,
+        slug=None,
+        path_aware=False,
+        read_aware=False,
+        subscription_aware=False,
+        poll_votes_aware=False,
     ):
         model = self.get_thread(request, pk, slug)
 
@@ -69,7 +72,7 @@ class ViewModel(BaseViewModel):
 
     def get_thread(self, request, pk, slug=None):
         raise NotImplementedError(
-            'Thread view model has to implement get_thread(request, pk, slug=None)'
+            "Thread view model has to implement get_thread(request, pk, slug=None)"
         )
 
     def get_thread_path(self, category):
@@ -78,7 +81,7 @@ class ViewModel(BaseViewModel):
         if category.level:
             categories = Category.objects.filter(
                 tree_id=category.tree_id, lft__lte=category.lft, rght__gte=category.rght
-            ).order_by('level')
+            ).order_by("level")
             thread_path = list(categories)
         else:
             thread_path = [category]
@@ -90,14 +93,16 @@ class ViewModel(BaseViewModel):
         raise NotImplementedError("Thread view model has to implement get_root_name()")
 
     def get_frontend_context(self):
-        raise NotImplementedError("Thread view model has to implement get_frontend_context()")
+        raise NotImplementedError(
+            "Thread view model has to implement get_frontend_context()"
+        )
 
     def get_template_context(self):
         return {
-            'thread': self._model,
-            'poll': self._poll,
-            'category': self._model.category,
-            'breadcrumbs': self._model.path,
+            "thread": self._model,
+            "poll": self._poll,
+            "category": self._model.category,
+            "breadcrumbs": self._model.path,
         }
 
 

+ 71 - 60
misago/threads/viewmodels/threads.py

@@ -14,28 +14,39 @@ from misago.readtracker import threadstracker
 from misago.readtracker.dates import get_cutoff_date
 from misago.threads.models import Post, Thread
 from misago.threads.participants import make_participants_aware
-from misago.threads.permissions import exclude_invisible_posts, exclude_invisible_threads
+from misago.threads.permissions import (
+    exclude_invisible_posts,
+    exclude_invisible_threads,
+)
 from misago.threads.serializers import ThreadsListSerializer
 from misago.threads.subscriptions import make_subscription_aware
 from misago.threads.utils import add_categories_to_items
 
-__all__ = ['ForumThreads', 'PrivateThreads', 'filter_read_threads_queryset']
+__all__ = ["ForumThreads", "PrivateThreads", "filter_read_threads_queryset"]
 
 LISTS_NAMES = {
-    'all': None,
-    'my': gettext_lazy("Your threads"),
-    'new': gettext_lazy("New threads"),
-    'unread': gettext_lazy("Unread threads"),
-    'subscribed': gettext_lazy("Subscribed threads"),
-    'unapproved': gettext_lazy("Unapproved content"),
+    "all": None,
+    "my": gettext_lazy("Your threads"),
+    "new": gettext_lazy("New threads"),
+    "unread": gettext_lazy("Unread threads"),
+    "subscribed": gettext_lazy("Subscribed threads"),
+    "unapproved": gettext_lazy("Unapproved content"),
 }
 
 LIST_DENIED_MESSAGES = {
-    'my': gettext_lazy("You have to sign in to see list of threads that you have started."),
-    'new': gettext_lazy("You have to sign in to see list of threads you haven't read."),
-    'unread': gettext_lazy("You have to sign in to see list of threads with new replies."),
-    'subscribed': gettext_lazy("You have to sign in to see list of threads you are subscribing."),
-    'unapproved': gettext_lazy("You have to sign in to see list of threads with unapproved posts."),
+    "my": gettext_lazy(
+        "You have to sign in to see list of threads that you have started."
+    ),
+    "new": gettext_lazy("You have to sign in to see list of threads you haven't read."),
+    "unread": gettext_lazy(
+        "You have to sign in to see list of threads with new replies."
+    ),
+    "subscribed": gettext_lazy(
+        "You have to sign in to see list of threads you are subscribing."
+    ),
+    "unapproved": gettext_lazy(
+        "You have to sign in to see list of threads with unapproved posts."
+    ),
 }
 
 
@@ -46,7 +57,7 @@ class ViewModel(object):
         category_model = category.unwrap()
 
         base_queryset = self.get_base_queryset(request, category.categories, list_type)
-        base_queryset = base_queryset.select_related('starter', 'last_poster')
+        base_queryset = base_queryset.select_related("starter", "last_poster")
 
         threads_categories = [category_model] + category.subcategories
 
@@ -55,7 +66,10 @@ class ViewModel(object):
         )
 
         list_page = paginate(
-            threads_queryset, page, settings.MISAGO_THREADS_PER_PAGE, settings.MISAGO_THREADS_TAIL
+            threads_queryset,
+            page,
+            settings.MISAGO_THREADS_PER_PAGE,
+            settings.MISAGO_THREADS_TAIL,
         )
         paginator = pagination_dict(list_page)
 
@@ -63,7 +77,9 @@ class ViewModel(object):
             threads = list(list_page.object_list)
         else:
             pinned_threads = list(
-                self.get_pinned_threads(base_queryset, category_model, threads_categories)
+                self.get_pinned_threads(
+                    base_queryset, category_model, threads_categories
+                )
             )
             threads = list(pinned_threads) + list(list_page.object_list)
 
@@ -71,7 +87,7 @@ class ViewModel(object):
         add_acl_to_obj(request.user_acl, threads)
         make_subscription_aware(request.user, threads)
 
-        if list_type in ('new', 'unread'):
+        if list_type in ("new", "unread"):
             # we already know all threads on list are unread
             for thread in threads:
                 thread.is_read = False
@@ -95,8 +111,8 @@ class ViewModel(object):
             if list_type in LIST_DENIED_MESSAGES:
                 raise PermissionDenied(LIST_DENIED_MESSAGES[list_type])
         else:
-            has_permission = request.user_acl['can_see_unapproved_content_lists']
-            if list_type == 'unapproved' and not has_permission:
+            has_permission = request.user_acl["can_see_unapproved_content_lists"]
+            if list_type == "unapproved" and not has_permission:
                 raise PermissionDenied(
                     _("You don't have permission to see unapproved content lists.")
                 )
@@ -105,11 +121,9 @@ class ViewModel(object):
         return LISTS_NAMES[list_type]
 
     def get_base_queryset(self, request, threads_categories, list_type):
-        return get_threads_queryset(
-            request,
-            threads_categories,
-            list_type,
-        ).order_by('-last_post_id')
+        return get_threads_queryset(request, threads_categories, list_type).order_by(
+            "-last_post_id"
+        )
 
     def get_pinned_threads(self, queryset, category, threads_categories):
         return []
@@ -122,43 +136,38 @@ class ViewModel(object):
 
     def get_frontend_context(self):
         context = {
-            'THREADS': {
-                'results': ThreadsListSerializer(self.threads, many=True).data,
-                'subcategories': [c.pk for c in self.category.children],
-            },
+            "THREADS": {
+                "results": ThreadsListSerializer(self.threads, many=True).data,
+                "subcategories": [c.pk for c in self.category.children],
+            }
         }
 
-        context['THREADS'].update(self.paginator)
+        context["THREADS"].update(self.paginator)
         return context
 
     def get_template_context(self):
         return {
-            'list_name': self.get_list_name(self.list_type),
-            'list_type': self.list_type,
-            'threads': self.threads,
-            'paginator': self.paginator,
+            "list_name": self.get_list_name(self.list_type),
+            "list_type": self.list_type,
+            "threads": self.threads,
+            "paginator": self.paginator,
         }
 
 
 class ForumThreads(ViewModel):
     def get_pinned_threads(self, queryset, category, threads_categories):
         if category.level:
-            return list(queryset.filter(weight=2)
-                        ) + list(queryset.filter(weight=1, category__in=threads_categories))
+            return list(queryset.filter(weight=2)) + list(
+                queryset.filter(weight=1, category__in=threads_categories)
+            )
         else:
             return queryset.filter(weight=2)
 
     def get_remaining_threads_queryset(self, queryset, category, threads_categories):
         if category.level:
-            return queryset.filter(
-                weight=0,
-                category__in=threads_categories,
-            )
+            return queryset.filter(weight=0, category__in=threads_categories)
         else:
-            return queryset.filter(
-                weight__lt=2,
-                category__in=threads_categories,
-            )
+            return queryset.filter(weight__lt=2, category__in=threads_categories)
 
 
 class PrivateThreads(ViewModel):
@@ -166,10 +175,12 @@ class PrivateThreads(ViewModel):
         queryset = super().get_base_queryset(request, threads_categories, list_type)
 
         # 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['can_moderate_private_threads']:
-            queryset = queryset.filter(Q(id__in=participated_threads) | Q(has_reported_posts=True))
+        if request.user_acl["can_moderate_private_threads"]:
+            queryset = queryset.filter(
+                Q(id__in=participated_threads) | Q(has_reported_posts=True)
+            )
         else:
             queryset = queryset.filter(id__in=participated_threads)
 
@@ -185,21 +196,21 @@ class PrivateThreads(ViewModel):
 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
     else:
         return filter_threads_queryset(request, 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=request.user)
-    elif list_type == 'subscribed':
-        subscribed_threads = request.user.subscription_set.values('thread_id')
+    elif list_type == "subscribed":
+        subscribed_threads = request.user.subscription_set.values("thread_id")
         return queryset.filter(id__in=subscribed_threads)
-    elif list_type == 'unapproved':
+    elif list_type == "unapproved":
         return queryset.filter(has_unapproved_posts=True)
-    elif list_type in ('new', 'unread'):
+    elif list_type in ("new", "unread"):
         return filter_read_threads_queryset(request, categories, list_type, queryset)
     else:
         return queryset
@@ -214,17 +225,17 @@ def filter_read_threads_queryset(request, categories, list_type, queryset):
     visible_posts = Post.objects.filter(posted_on__gt=cutoff_date)
     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"))
 
-    read_posts = visible_posts.filter(id__in=user.postread_set.values('post'))
+    read_posts = visible_posts.filter(id__in=user.postread_set.values("post"))
 
-    if list_type == 'new':
+    if list_type == "new":
         # new threads have no entry in reads table
-        return queryset.exclude(id__in=read_posts.distinct().values('thread'))
+        return queryset.exclude(id__in=read_posts.distinct().values("thread"))
 
-    if list_type == 'unread':
+    if list_type == "unread":
         # unread threads were read in past but have new posts
-        unread_posts = visible_posts.exclude(id__in=user.postread_set.values('post'))
-        queryset = queryset.filter(id__in=read_posts.distinct().values('thread'))
-        queryset = queryset.filter(id__in=unread_posts.distinct().values('thread'))
+        unread_posts = visible_posts.exclude(id__in=user.postread_set.values("post"))
+        queryset = queryset.filter(id__in=read_posts.distinct().values("thread"))
+        queryset = queryset.filter(id__in=unread_posts.distinct().values("thread"))
         return queryset

+ 24 - 22
misago/threads/views/admin/attachments.py

@@ -8,36 +8,38 @@ from misago.threads.models import Attachment, Post
 
 
 class AttachmentAdmin(generic.AdminBaseMixin):
-    root_link = 'misago:admin:system:attachments:index'
+    root_link = "misago:admin:system:attachments:index"
     model = Attachment
-    templates_dir = 'misago/admin/attachments'
+    templates_dir = "misago/admin/attachments"
     message_404 = _("Requested attachment could not be found.")
 
     def get_queryset(self):
         qs = super().get_queryset()
-        return qs.select_related('filetype', 'uploader', 'post', 'post__thread', 'post__category')
+        return qs.select_related(
+            "filetype", "uploader", "post", "post__thread", "post__category"
+        )
 
 
 class AttachmentsList(AttachmentAdmin, generic.ListView):
     items_per_page = 20
     ordering = [
-        ('-id', _("From newest")),
-        ('id', _("From oldest")),
-        ('filename', _("A to z")),
-        ('-filename', _("Z to a")),
-        ('size', _("Smallest files")),
-        ('-size', _("Largest files")),
+        ("-id", _("From newest")),
+        ("id", _("From oldest")),
+        ("filename", _("A to z")),
+        ("-filename", _("Z to a")),
+        ("size", _("Smallest files")),
+        ("-size", _("Largest files")),
     ]
-    selection_label = _('With attachments: 0')
-    empty_selection_label = _('Select attachments')
+    selection_label = _("With attachments: 0")
+    empty_selection_label = _("Select attachments")
     mass_actions = [
         {
-            'action': 'delete',
-            'name': _("Delete attachments"),
-            'icon': 'fa fa-times-circle',
-            'confirmation': _("Are you sure you want to delete selected attachments?"),
-            'is_atomic': False,
-        },
+            "action": "delete",
+            "name": _("Delete attachments"),
+            "icon": "fa fa-times-circle",
+            "confirmation": _("Are you sure you want to delete selected attachments?"),
+            "is_atomic": False,
+        }
     ]
 
     def get_search_form(self, request):
@@ -69,11 +71,11 @@ class AttachmentsList(AttachmentAdmin, generic.ListView):
 
         clean_cache = []
         for a in post.attachments_cache:
-            if a['id'] not in attachments:
+            if a["id"] not in attachments:
                 clean_cache.append(a)
 
         post.attachments_cache = clean_cache or None
-        post.save(update_fields=['attachments_cache'])
+        post.save(update_fields=["attachments_cache"])
 
 
 class DeleteAttachment(AttachmentAdmin, generic.ButtonView):
@@ -82,7 +84,7 @@ class DeleteAttachment(AttachmentAdmin, generic.ButtonView):
             self.delete_from_cache(target)
         target.delete()
         message = _('Attachment "%(filename)s" has been deleted.')
-        messages.success(request, message % {'filename': target.filename})
+        messages.success(request, message % {"filename": target.filename})
 
     def delete_from_cache(self, attachment):
         if not attachment.post.attachments_cache:
@@ -90,8 +92,8 @@ class DeleteAttachment(AttachmentAdmin, generic.ButtonView):
 
         clean_cache = []
         for a in attachment.post.attachments_cache:
-            if a['id'] != attachment.id:
+            if a["id"] != attachment.id:
                 clean_cache.append(a)
 
         attachment.post.attachments_cache = clean_cache or None
-        attachment.post.save(update_fields=['attachments_cache'])
+        attachment.post.save(update_fields=["attachments_cache"])

+ 6 - 6
misago/threads/views/admin/attachmenttypes.py

@@ -8,10 +8,10 @@ from misago.threads.models import AttachmentType
 
 
 class AttachmentTypeAdmin(generic.AdminBaseMixin):
-    root_link = 'misago:admin:system:attachment-types:index'
+    root_link = "misago:admin:system:attachment-types:index"
     model = AttachmentType
     form = AttachmentTypeForm
-    templates_dir = 'misago/admin/attachmenttypes'
+    templates_dir = "misago/admin/attachmenttypes"
     message_404 = _("Requested attachment type could not be found.")
 
     def update_roles(self, target, roles):
@@ -25,11 +25,11 @@ class AttachmentTypeAdmin(generic.AdminBaseMixin):
 
 
 class AttachmentTypesList(AttachmentTypeAdmin, generic.ListView):
-    ordering = (('name', None), )
+    ordering = (("name", None),)
 
     def get_queryset(self):
         queryset = super().get_queryset()
-        return queryset.annotate(num_files=Count('attachment'))
+        return queryset.annotate(num_files=Count("attachment"))
 
 
 class NewAttachmentType(AttachmentTypeAdmin, generic.ModelFormView):
@@ -46,9 +46,9 @@ class DeleteAttachmentType(AttachmentTypeAdmin, generic.ButtonView):
             message = _(
                 'Attachment type "%(name)s" has associated attachments and can\'t be deleted.'
             )
-            return message % {'name': target.name}
+            return message % {"name": target.name}
 
     def button_action(self, request, target):
         target.delete()
         message = _('Attachment type "%(name)s" has been deleted.')
-        messages.success(request, message % {'name': target.name})
+        messages.success(request, message % {"name": target.name})

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

@@ -6,8 +6,8 @@ from misago.conf import settings
 from misago.threads.models import Attachment, AttachmentType
 
 
-ATTACHMENT_404_URL = ''.join((settings.STATIC_URL, settings.MISAGO_404_IMAGE))
-ATTACHMENT_403_URL = ''.join((settings.STATIC_URL, settings.MISAGO_403_IMAGE))
+ATTACHMENT_404_URL = "".join((settings.STATIC_URL, settings.MISAGO_404_IMAGE))
+ATTACHMENT_403_URL = "".join((settings.STATIC_URL, settings.MISAGO_403_IMAGE))
 
 
 def attachment_server(request, pk, secret, thumbnail=False):
@@ -21,10 +21,10 @@ def attachment_server(request, pk, secret, thumbnail=False):
 
 
 def serve_file(request, pk, secret, thumbnail):
-    queryset = Attachment.objects.select_related('filetype')
+    queryset = Attachment.objects.select_related("filetype")
     attachment = get_object_or_404(queryset, pk=pk, secret=secret)
 
-    if not attachment.post_id and request.GET.get('shva') != '1':
+    if not attachment.post_id and request.GET.get("shva") != "1":
         # if attachment is orphaned, don't run acl test unless explictly told so
         # this saves user suprise of deleted attachment still showing in posts/quotes
         raise Http404()
@@ -50,7 +50,7 @@ def allow_file_download(request, attachment):
     if not is_authenticated or request.user.id != attachment.uploader_id:
         if not attachment.post_id:
             raise Http404()
-        if not request.user_acl['can_download_other_users_attachments']:
+        if not request.user_acl["can_download_other_users_attachments"]:
             raise PermissionDenied()
 
     allowed_roles = set(r.pk for r in attachment.filetype.limit_downloads_to.all())

+ 22 - 18
misago/threads/views/goto.py

@@ -24,7 +24,7 @@ class GotoView(View):
         )
 
         target_post = self.get_target_post(
-            request.user, thread, posts_queryset.order_by('id'), **kwargs
+            request.user, thread, posts_queryset.order_by("id"), **kwargs
         )
         target_page = self.compute_post_page(target_post, posts_queryset)
 
@@ -37,11 +37,13 @@ class GotoView(View):
         pass
 
     def get_target_post(self, user, thread, posts_queryset):
-        raise NotImplementedError("goto views should define their own get_target_post method")
+        raise NotImplementedError(
+            "goto views should define their own get_target_post method"
+        )
 
     def compute_post_page(self, target_post, posts_queryset):
         # filter out events, order queryset
-        posts_queryset = posts_queryset.filter(is_event=False).order_by('id')
+        posts_queryset = posts_queryset.filter(is_event=False).order_by("id")
         thread_length = posts_queryset.count()
 
         # is target an event?
@@ -68,37 +70,39 @@ class GotoView(View):
 
     def get_redirect(self, thread, target_post, target_page):
         thread_url = thread.thread_type.get_thread_absolute_url(thread, target_page)
-        return redirect('%s#post-%s' % (thread_url, target_post.pk))
+        return redirect("%s#post-%s" % (thread_url, target_post.pk))
 
 
 class ThreadGotoPostView(GotoView):
     thread = ForumThread
 
     def get_target_post(self, user, thread, posts_queryset, **kwargs):
-        return get_object_or_404(posts_queryset, pk=kwargs['post'])
+        return get_object_or_404(posts_queryset, pk=kwargs["post"])
 
 
 class ThreadGotoLastView(GotoView):
     thread = ForumThread
 
     def get_target_post(self, user, thread, posts_queryset, **kwargs):
-        return posts_queryset.order_by('id').last()
+        return posts_queryset.order_by("id").last()
 
 
 class GetFirstUnreadPostMixin(object):
     def get_first_unread_post(self, user, posts_queryset):
         if user.is_authenticated:
             expired_posts = Q(posted_on__lt=get_cutoff_date(user))
-            read_posts = Q(id__in=user.postread_set.values('post'))
+            read_posts = Q(id__in=user.postread_set.values("post"))
 
-            first_unread = posts_queryset.exclude(
-                expired_posts | read_posts,
-            ).order_by('id').first()
+            first_unread = (
+                posts_queryset.exclude(expired_posts | read_posts)
+                .order_by("id")
+                .first()
+            )
 
             if first_unread:
                 return first_unread
 
-        return posts_queryset.order_by('id').last()
+        return posts_queryset.order_by("id").last()
 
 
 class ThreadGotoNewView(GotoView, GetFirstUnreadPostMixin):
@@ -119,7 +123,7 @@ class ThreadGotoUnapprovedView(GotoView):
     thread = ForumThread
 
     def test_permissions(self, request, thread):
-        if not thread.acl['can_approve']:
+        if not thread.acl["can_approve"]:
             raise PermissionDenied(
                 _(
                     "You need permission to approve content to "
@@ -128,27 +132,27 @@ class ThreadGotoUnapprovedView(GotoView):
             )
 
     def get_target_post(self, user, thread, posts_queryset, **kwargs):
-        unapproved_post = posts_queryset.filter(
-            is_unapproved=True,
-        ).order_by('id').first()
+        unapproved_post = (
+            posts_queryset.filter(is_unapproved=True).order_by("id").first()
+        )
         if unapproved_post:
             return unapproved_post
         else:
-            return posts_queryset.order_by('id').last()
+            return posts_queryset.order_by("id").last()
 
 
 class PrivateThreadGotoPostView(GotoView):
     thread = PrivateThread
 
     def get_target_post(self, user, thread, posts_queryset, **kwargs):
-        return get_object_or_404(posts_queryset, pk=kwargs['post'])
+        return get_object_or_404(posts_queryset, pk=kwargs["post"])
 
 
 class PrivateThreadGotoLastView(GotoView):
     thread = PrivateThread
 
     def get_target_post(self, user, thread, posts_queryset, **kwargs):
-        return posts_queryset.order_by('id').last()
+        return posts_queryset.order_by("id").last()
 
 
 class PrivateThreadGotoNewView(GotoView, GetFirstUnreadPostMixin):

+ 11 - 8
misago/threads/views/list.py

@@ -5,7 +5,12 @@ from django.views import View
 
 from misago.core.shortcuts import get_int_or_404
 from misago.threads.viewmodels import (
-    ForumThreads, PrivateThreads, PrivateThreadsCategory, ThreadsCategory, ThreadsRootCategory)
+    ForumThreads,
+    PrivateThreads,
+    PrivateThreadsCategory,
+    ThreadsCategory,
+    ThreadsRootCategory,
+)
 
 
 class ThreadsList(View):
@@ -15,7 +20,7 @@ class ThreadsList(View):
     template_name = None
 
     def get(self, request, list_type=None, **kwargs):
-        page = get_int_or_404(request.GET.get('page', 0))
+        page = get_int_or_404(request.GET.get("page", 0))
 
         category = self.get_category(request, **kwargs)
         threads = self.get_threads(request, category, list_type, page)
@@ -59,18 +64,16 @@ class ForumThreadsList(ThreadsList):
     category = ThreadsRootCategory
     threads = ForumThreads
 
-    template_name = 'misago/threadslist/threads.html'
+    template_name = "misago/threadslist/threads.html"
 
     def get_default_frontend_context(self):
-        return {
-            'MERGE_THREADS_API': reverse('misago:api:thread-merge'),
-        }
+        return {"MERGE_THREADS_API": reverse("misago:api:thread-merge")}
 
 
 class CategoryThreadsList(ForumThreadsList):
     category = ThreadsCategory
 
-    template_name = 'misago/threadslist/category.html'
+    template_name = "misago/threadslist/category.html"
 
     def get_category(self, request, **kwargs):
         category = super().get_category(request, **kwargs)
@@ -83,4 +86,4 @@ class PrivateThreadsList(ThreadsList):
     category = PrivateThreadsCategory
     threads = PrivateThreads
 
-    template_name = 'misago/threadslist/private_threads.html'
+    template_name = "misago/threadslist/private_threads.html"

+ 12 - 12
misago/threads/views/thread.py

@@ -41,18 +41,20 @@ class ThreadBase(View):
     def get_frontend_context(self, request, thread, posts):
         context = self.get_default_frontend_context()
 
-        context.update({
-            'THREAD': thread.get_frontend_context(),
-            'POSTS': posts.get_frontend_context(),
-        })
+        context.update(
+            {
+                "THREAD": thread.get_frontend_context(),
+                "POSTS": posts.get_frontend_context(),
+            }
+        )
 
         return context
 
     def get_template_context(self, request, thread, posts):
         context = {
-            'url_name': ':'.join(request.resolver_match.namespaces + [
-                request.resolver_match.url_name,
-            ])
+            "url_name": ":".join(
+                request.resolver_match.namespaces + [request.resolver_match.url_name]
+            )
         }
 
         context.update(thread.get_template_context())
@@ -63,14 +65,12 @@ class ThreadBase(View):
 
 class ThreadView(ThreadBase):
     thread = ForumThread
-    template_name = 'misago/thread/thread.html'
+    template_name = "misago/thread/thread.html"
 
     def get_default_frontend_context(self):
-        return {
-            'THREADS_API': reverse('misago:api:thread-list'),
-        }
+        return {"THREADS_API": reverse("misago:api:thread-list")}
 
 
 class PrivateThreadView(ThreadBase):
     thread = PrivateThread
-    template_name = 'misago/thread/private_thread.html'
+    template_name = "misago/thread/private_thread.html"

+ 21 - 25
misago/urls.py

@@ -5,53 +5,49 @@ from misago.conf import settings
 from misago.core.views import forum_index
 
 
-app_name = 'misago'
+app_name = "misago"
 
 # Register Misago Apps
 urlpatterns = [
-    url(r'^', include('misago.legal.urls')),
-    url(r'^', include('misago.users.urls')),
-    url(r'^', include('misago.categories.urls')),
-    url(r'^', include('misago.threads.urls')),
-    url(r'^', include('misago.search.urls')),
-
+    url(r"^", include("misago.legal.urls")),
+    url(r"^", include("misago.users.urls")),
+    url(r"^", include("misago.categories.urls")),
+    url(r"^", include("misago.threads.urls")),
+    url(r"^", include("misago.search.urls")),
     # default robots.txt
     url(
-        r'^robots.txt$',
-        TemplateView.as_view(content_type='text/plain', template_name='misago/robots.txt')
+        r"^robots.txt$",
+        TemplateView.as_view(
+            content_type="text/plain", template_name="misago/robots.txt"
+        ),
     ),
-
     # "misago:index" link symbolises "root" of Misago links space
     # any request with path that falls below this one is assumed to be directed
     # at Misago and will be handled by misago.views.exceptionhandler if it
     # results in Http404 or PermissionDenied exception
-    url(r'^$', forum_index, name='index'),
+    url(r"^$", forum_index, name="index"),
 ]
 
 
 # Register API
 apipatterns = [
-    url(r'^', include('misago.categories.urls.api')),
-    url(r'^', include('misago.legal.urls.api')),
-    url(r'^', include('misago.markup.urls')),
-    url(r'^', include('misago.threads.urls.api')),
-    url(r'^', include('misago.users.urls.api')),
-    url(r'^', include('misago.search.urls.api')),
+    url(r"^", include("misago.categories.urls.api")),
+    url(r"^", include("misago.legal.urls.api")),
+    url(r"^", include("misago.markup.urls")),
+    url(r"^", include("misago.threads.urls.api")),
+    url(r"^", include("misago.users.urls.api")),
+    url(r"^", include("misago.search.urls.api")),
 ]
 
-urlpatterns += [
-    url(r'^api/', include((apipatterns, 'api'), namespace='api')),
-]
+urlpatterns += [url(r"^api/", include((apipatterns, "api"), namespace="api"))]
 
 
 # Register Misago ACP
 if settings.MISAGO_ADMIN_PATH:
     # Admin patterns recognised by Misago
-    adminpatterns = [
-        url(r'^', include('misago.admin.urls')),
-    ]
+    adminpatterns = [url(r"^", include("misago.admin.urls"))]
 
-    admin_prefix = r'^%s/' % settings.MISAGO_ADMIN_PATH
+    admin_prefix = r"^%s/" % settings.MISAGO_ADMIN_PATH
     urlpatterns += [
-        url(admin_prefix, include((adminpatterns, 'admin'), namespace='admin')),
+        url(admin_prefix, include((adminpatterns, "admin"), namespace="admin"))
     ]

+ 1 - 1
misago/users/__init__.py

@@ -1 +1 @@
-default_app_config = 'misago.users.apps.MisagoUsersConfig'
+default_app_config = "misago.users.apps.MisagoUsersConfig"

+ 9 - 13
misago/users/activepostersranking.py

@@ -16,15 +16,12 @@ UserModel = get_user_model()
 def get_active_posters_ranking():
     users = []
 
-    queryset = ActivityRanking.objects.select_related('user', 'user__rank')
-    for ranking in queryset.order_by('-score'):
+    queryset = ActivityRanking.objects.select_related("user", "user__rank")
+    for ranking in queryset.order_by("-score"):
         ranking.user.score = ranking.score
         users.append(ranking.user)
 
-    return {
-        'users': users,
-        'users_count': len(users),
-    }
+    return {"users": users, "users_count": len(users)}
 
 
 def build_active_posters_ranking():
@@ -38,18 +35,17 @@ def build_active_posters_ranking():
         ranked_categories.append(category.pk)
 
     queryset = (
-        UserModel.objects
-        .filter(
+        UserModel.objects.filter(
             is_active=True,
             post__posted_on__gte=tracked_since,
-            post__category__in=ranked_categories,	
+            post__category__in=ranked_categories,
         )
-        .annotate(score=Count('post'))
+        .annotate(score=Count("post"))
         .filter(score__gt=0)
-        .order_by('-score')
-    )[:settings.MISAGO_RANKING_SIZE]
+        .order_by("-score")
+    )[: settings.MISAGO_RANKING_SIZE]
 
     new_ranking = []
     for ranking in queryset.iterator():
         new_ranking.append(ActivityRanking(user=ranking, score=ranking.score))
-    ActivityRanking.objects.bulk_create(new_ranking)    
+    ActivityRanking.objects.bulk_create(new_ranking)

+ 79 - 61
misago/users/admin.py

@@ -7,9 +7,23 @@ from .djangoadmin import UserAdminModel
 from .views.admin.bans import BansList, DeleteBan, EditBan, NewBan
 from .views.admin.datadownloads import DataDownloadsList, RequestDataDownloads
 from .views.admin.ranks import (
-    DefaultRank, DeleteRank, EditRank, MoveDownRank, MoveUpRank, NewRank, RanksList, RankUsers)
+    DefaultRank,
+    DeleteRank,
+    EditRank,
+    MoveDownRank,
+    MoveUpRank,
+    NewRank,
+    RanksList,
+    RankUsers,
+)
 from .views.admin.users import (
-    DeleteAccountStep, DeletePostsStep, DeleteThreadsStep, EditUser, NewUser, UsersList)
+    DeleteAccountStep,
+    DeletePostsStep,
+    DeleteThreadsStep,
+    EditUser,
+    NewUser,
+    UsersList,
+)
 
 
 djadmin.site.register(model_or_iterable=get_user_model(), admin_class=UserAdminModel)
@@ -18,104 +32,108 @@ djadmin.site.register(model_or_iterable=get_user_model(), admin_class=UserAdminM
 class MisagoAdminExtension(object):
     def register_urlpatterns(self, urlpatterns):
         # Users section
-        urlpatterns.namespace(r'^users/', 'users')
+        urlpatterns.namespace(r"^users/", "users")
 
         # Accounts
-        urlpatterns.namespace(r'^accounts/', 'accounts', 'users')
+        urlpatterns.namespace(r"^accounts/", "accounts", "users")
         urlpatterns.patterns(
-            'users:accounts',
-            url(r'^$', UsersList.as_view(), name='index'),
-            url(r'^(?P<page>\d+)/$', UsersList.as_view(), name='index'),
-            url(r'^new/$', NewUser.as_view(), name='new'),
-            url(r'^edit/(?P<pk>\d+)/$', EditUser.as_view(), name='edit'),
+            "users:accounts",
+            url(r"^$", UsersList.as_view(), name="index"),
+            url(r"^(?P<page>\d+)/$", UsersList.as_view(), name="index"),
+            url(r"^new/$", NewUser.as_view(), name="new"),
+            url(r"^edit/(?P<pk>\d+)/$", EditUser.as_view(), name="edit"),
             url(
-                r'^delete-threads/(?P<pk>\d+)/$',
+                r"^delete-threads/(?P<pk>\d+)/$",
                 DeleteThreadsStep.as_view(),
-                name='delete-threads'
+                name="delete-threads",
             ),
-            url(r'^delete-posts/(?P<pk>\d+)/$', DeletePostsStep.as_view(), name='delete-posts'),
             url(
-                r'^delete-account/(?P<pk>\d+)/$',
+                r"^delete-posts/(?P<pk>\d+)/$",
+                DeletePostsStep.as_view(),
+                name="delete-posts",
+            ),
+            url(
+                r"^delete-account/(?P<pk>\d+)/$",
                 DeleteAccountStep.as_view(),
-                name='delete-account'
+                name="delete-account",
             ),
         )
 
         # Ranks
-        urlpatterns.namespace(r'^ranks/', 'ranks', 'users')
+        urlpatterns.namespace(r"^ranks/", "ranks", "users")
         urlpatterns.patterns(
-            'users:ranks',
-            url(r'^$', RanksList.as_view(), name='index'),
-            url(r'^new/$', NewRank.as_view(), name='new'),
-            url(r'^edit/(?P<pk>\d+)/$', EditRank.as_view(), name='edit'),
-            url(r'^default/(?P<pk>\d+)/$', DefaultRank.as_view(), name='default'),
-            url(r'^move/down/(?P<pk>\d+)/$', MoveDownRank.as_view(), name='down'),
-            url(r'^move/up/(?P<pk>\d+)/$', MoveUpRank.as_view(), name='up'),
-            url(r'^users/(?P<pk>\d+)/$', RankUsers.as_view(), name='users'),
-            url(r'^delete/(?P<pk>\d+)/$', DeleteRank.as_view(), name='delete'),
+            "users:ranks",
+            url(r"^$", RanksList.as_view(), name="index"),
+            url(r"^new/$", NewRank.as_view(), name="new"),
+            url(r"^edit/(?P<pk>\d+)/$", EditRank.as_view(), name="edit"),
+            url(r"^default/(?P<pk>\d+)/$", DefaultRank.as_view(), name="default"),
+            url(r"^move/down/(?P<pk>\d+)/$", MoveDownRank.as_view(), name="down"),
+            url(r"^move/up/(?P<pk>\d+)/$", MoveUpRank.as_view(), name="up"),
+            url(r"^users/(?P<pk>\d+)/$", RankUsers.as_view(), name="users"),
+            url(r"^delete/(?P<pk>\d+)/$", DeleteRank.as_view(), name="delete"),
         )
 
         # Bans
-        urlpatterns.namespace(r'^bans/', 'bans', 'users')
+        urlpatterns.namespace(r"^bans/", "bans", "users")
         urlpatterns.patterns(
-            'users:bans',
-            url(r'^$', BansList.as_view(), name='index'),
-            url(r'^(?P<page>\d+)/$', BansList.as_view(), name='index'),
-            url(r'^new/$', NewBan.as_view(), name='new'),
-            url(r'^edit/(?P<pk>\d+)/$', EditBan.as_view(), name='edit'),
-            url(r'^delete/(?P<pk>\d+)/$', DeleteBan.as_view(), name='delete'),
+            "users:bans",
+            url(r"^$", BansList.as_view(), name="index"),
+            url(r"^(?P<page>\d+)/$", BansList.as_view(), name="index"),
+            url(r"^new/$", NewBan.as_view(), name="new"),
+            url(r"^edit/(?P<pk>\d+)/$", EditBan.as_view(), name="edit"),
+            url(r"^delete/(?P<pk>\d+)/$", DeleteBan.as_view(), name="delete"),
         )
 
         # Data Downloads
-        urlpatterns.namespace(r'^data-downloads/', 'data-downloads', 'users')
+        urlpatterns.namespace(r"^data-downloads/", "data-downloads", "users")
         urlpatterns.patterns(
-            'users:data-downloads',
-            url(r'^$', DataDownloadsList.as_view(), name='index'),
-            url(r'^(?P<page>\d+)/$', DataDownloadsList.as_view(), name='index'),
-            url(r'^request/$', RequestDataDownloads.as_view(), name='request'),
+            "users:data-downloads",
+            url(r"^$", DataDownloadsList.as_view(), name="index"),
+            url(r"^(?P<page>\d+)/$", DataDownloadsList.as_view(), name="index"),
+            url(r"^request/$", RequestDataDownloads.as_view(), name="request"),
         )
-        
+
     def register_navigation_nodes(self, site):
         site.add_node(
             name=_("Users"),
-            icon='fa fa-users',
-            parent='misago:admin',
-            after='misago:admin:index',
-            namespace='misago:admin:users',
-            link='misago:admin:users:accounts:index',
+            icon="fa fa-users",
+            parent="misago:admin",
+            after="misago:admin:index",
+            namespace="misago:admin:users",
+            link="misago:admin:users:accounts:index",
         )
 
         site.add_node(
             name=_("User Accounts"),
-            icon='fa fa-users',
-            parent='misago:admin:users',
-            namespace='misago:admin:users:accounts',
-            link='misago:admin:users:accounts:index',
+            icon="fa fa-users",
+            parent="misago:admin:users",
+            namespace="misago:admin:users:accounts",
+            link="misago:admin:users:accounts:index",
         )
 
         site.add_node(
             name=_("Ranks"),
-            icon='fa fa-graduation-cap',
-            parent='misago:admin:users',
-            after='misago:admin:users:accounts:index',
-            namespace='misago:admin:users:ranks',
-            link='misago:admin:users:ranks:index',
+            icon="fa fa-graduation-cap",
+            parent="misago:admin:users",
+            after="misago:admin:users:accounts:index",
+            namespace="misago:admin:users:ranks",
+            link="misago:admin:users:ranks:index",
         )
 
         site.add_node(
             name=_("Bans"),
-            icon='fa fa-lock',
-            parent='misago:admin:users',
-            after='misago:admin:users:ranks:index',
-            namespace='misago:admin:users:bans',
-            link='misago:admin:users:bans:index',
+            icon="fa fa-lock",
+            parent="misago:admin:users",
+            after="misago:admin:users:ranks:index",
+            namespace="misago:admin:users:bans",
+            link="misago:admin:users:bans:index",
         )
 
         site.add_node(
             name=_("Data downloads"),
-            icon='fa fa-download',
-            parent='misago:admin:users',
-            after='misago:admin:users:bans:index',
-            namespace='misago:admin:users:data-downloads',
-            link='misago:admin:users:data-downloads:index',
+            icon="fa fa-download",
+            parent="misago:admin:users",
+            after="misago:admin:users:bans:index",
+            namespace="misago:admin:users:data-downloads",
+            link="misago:admin:users:data-downloads:index",
         )

+ 48 - 61
misago/users/api/auth.py

@@ -12,10 +12,20 @@ from misago.conf import settings
 from misago.core.decorators import require_dict_data
 from misago.core.mail import mail_user
 from misago.users.bans import get_user_ban
-from misago.users.forms.auth import AuthenticationForm, ResendActivationForm, ResetPasswordForm
-from misago.users.serializers import AnonymousUserSerializer, AuthenticatedUserSerializer
+from misago.users.forms.auth import (
+    AuthenticationForm,
+    ResendActivationForm,
+    ResetPasswordForm,
+)
+from misago.users.serializers import (
+    AnonymousUserSerializer,
+    AuthenticatedUserSerializer,
+)
 from misago.users.tokens import (
-    is_password_change_token_valid, make_activation_token, make_password_change_token)
+    is_password_change_token_valid,
+    make_activation_token,
+    make_password_change_token,
+)
 
 from .rest_permissions import UnbannedAnonOnly, UnbannedOnly
 
@@ -24,14 +34,14 @@ UserModel = auth.get_user_model()
 
 
 def gateway(request):
-    if request.method == 'POST':
+    if request.method == "POST":
         return login(request)
     else:
         return session_user(request)
 
 
-@api_view(['POST'])
-@permission_classes((UnbannedAnonOnly, ))
+@api_view(["POST"])
+@permission_classes((UnbannedAnonOnly,))
 @csrf_protect
 @require_dict_data
 def login(request):
@@ -42,14 +52,9 @@ def login(request):
     form = AuthenticationForm(request, data=request.data)
     if form.is_valid():
         auth.login(request, form.user_cache)
-        return Response(
-            AuthenticatedUserSerializer(form.user_cache).data,
-        )
+        return Response(AuthenticatedUserSerializer(form.user_cache).data)
     else:
-        return Response(
-            form.get_errors_dict(),
-            status=status.HTTP_400_BAD_REQUEST,
-        )
+        return Response(form.get_errors_dict(), status=status.HTTP_400_BAD_REQUEST)
 
 
 @api_view()
@@ -64,29 +69,29 @@ def session_user(request):
     return Response(serialized_user)
 
 
-@api_view(['GET'])
+@api_view(["GET"])
 def get_criteria(request):
     """GET /auth/criteria/ will return password and username criteria for accounts"""
     criteria = {
-        'username': {
-            'min_length': request.settings.username_length_min,
-            'max_length': request.settings.username_length_max,
+        "username": {
+            "min_length": request.settings.username_length_min,
+            "max_length": request.settings.username_length_max,
         },
-        'password': [],
+        "password": [],
     }
 
     for validator in settings.AUTH_PASSWORD_VALIDATORS:
-        validator_dict = {'name': validator['NAME'].split('.')[-1]}
+        validator_dict = {"name": validator["NAME"].split(".")[-1]}
 
-        validator_dict.update(validator.get('OPTIONS', {}))
+        validator_dict.update(validator.get("OPTIONS", {}))
 
-        criteria['password'].append(validator_dict)
+        criteria["password"].append(validator_dict)
 
     return Response(criteria)
 
 
-@api_view(['POST'])
-@permission_classes((UnbannedAnonOnly, ))
+@api_view(["POST"])
+@permission_classes((UnbannedAnonOnly,))
 @csrf_protect
 @require_dict_data
 def send_activation(request):
@@ -99,33 +104,29 @@ def send_activation(request):
         requesting_user = form.user_cache
 
         mail_subject = _("Activate %(user)s account on %(forum_name)s forums") % {
-            'user': requesting_user.username,
-            'forum_name': request.settings.forum_name,
+            "user": requesting_user.username,
+            "forum_name": request.settings.forum_name,
         }
 
         mail_user(
             requesting_user,
             mail_subject,
-            'misago/emails/activation/by_user',
+            "misago/emails/activation/by_user",
             context={
                 "activation_token": make_activation_token(requesting_user),
                 "settings": request.settings,
             },
         )
 
-        return Response({
-            'username': form.user_cache.username,
-            'email': form.user_cache.email,
-        })
-    else:
         return Response(
-            form.get_errors_dict(),
-            status=status.HTTP_400_BAD_REQUEST,
+            {"username": form.user_cache.username, "email": form.user_cache.email}
         )
+    else:
+        return Response(form.get_errors_dict(), status=status.HTTP_400_BAD_REQUEST)
 
 
-@api_view(['POST'])
-@permission_classes((UnbannedOnly, ))
+@api_view(["POST"])
+@permission_classes((UnbannedOnly,))
 @csrf_protect
 @require_dict_data
 def send_password_form(request):
@@ -138,8 +139,8 @@ def send_password_form(request):
         requesting_user = form.user_cache
 
         mail_subject = _("Change %(user)s password on %(forum_name)s forums") % {
-            'user': requesting_user.username,
-            'forum_name': request.settings.forum_name,
+            "user": requesting_user.username,
+            "forum_name": request.settings.forum_name,
         }
 
         confirmation_token = make_password_change_token(requesting_user)
@@ -147,30 +148,26 @@ def send_password_form(request):
         mail_user(
             requesting_user,
             mail_subject,
-            'misago/emails/change_password_form_link',
+            "misago/emails/change_password_form_link",
             context={
                 "confirmation_token": confirmation_token,
                 "settings": request.settings,
             },
         )
 
-        return Response({
-            'username': form.user_cache.username,
-            'email': form.user_cache.email,
-        })
-    else:
         return Response(
-            form.get_errors_dict(),
-            status=status.HTTP_400_BAD_REQUEST,
+            {"username": form.user_cache.username, "email": form.user_cache.email}
         )
+    else:
+        return Response(form.get_errors_dict(), status=status.HTTP_400_BAD_REQUEST)
 
 
 class PasswordChangeFailed(Exception):
     pass
 
 
-@api_view(['POST'])
-@permission_classes((UnbannedOnly, ))
+@api_view(["POST"])
+@permission_classes((UnbannedOnly,))
 @csrf_protect
 @require_dict_data
 def change_forgotten_password(request, pk, token):
@@ -197,24 +194,14 @@ def change_forgotten_password(request, pk, token):
         if get_user_ban(user, request.cache_versions):
             raise PasswordChangeFailed(expired_message)
     except PasswordChangeFailed as e:
-        return Response(
-            {
-                'detail': e.args[0],
-            },
-            status=status.HTTP_400_BAD_REQUEST,
-        )
+        return Response({"detail": e.args[0]}, status=status.HTTP_400_BAD_REQUEST)
 
     try:
-        new_password = request.data.get('password', '')
+        new_password = request.data.get("password", "")
         validate_password(new_password, user=user)
         user.set_password(new_password)
         user.save()
     except ValidationError as e:
-        return Response(
-            {
-                'detail': e.messages[0],
-            },
-            status=status.HTTP_400_BAD_REQUEST,
-        )
+        return Response({"detail": e.messages[0]}, status=status.HTTP_400_BAD_REQUEST)
 
-    return Response({'username': user.username})
+    return Response({"username": user.username})

+ 6 - 4
misago/users/api/captcha.py

@@ -7,9 +7,11 @@ from django.http import Http404
 @api_view()
 def question(request):
     if request.settings.qa_question:
-        return Response({
-            'question': request.settings.qa_question,
-            'help_text': request.settings.qa_help_text,
-        })
+        return Response(
+            {
+                "question": request.settings.qa_question,
+                "help_text": request.settings.qa_help_text,
+            }
+        )
     else:
         raise Http404()

+ 5 - 9
misago/users/api/mention.py

@@ -14,22 +14,18 @@ UserModel = get_user_model()
 def mention_suggestions(request):
     suggestions = []
 
-    query = request.query_params.get('q', '').lower().strip()[:100]
+    query = request.query_params.get("q", "").lower().strip()[:100]
     if query:
         queryset = UserModel.objects.filter(
-            slug__startswith=query,
-            is_active=True,
-        ).order_by('slug')[:10]
+            slug__startswith=query, is_active=True
+        ).order_by("slug")[:10]
 
         for user in queryset:
             try:
-                avatar = user.avatars[-1]['url']
+                avatar = user.avatars[-1]["url"]
             except IndexError:
                 avatar = static(settings.MISAGO_BLANK_AVATAR)
 
-            suggestions.append({
-                'username': user.username,
-                'avatar': avatar,
-            })
+            suggestions.append({"username": user.username, "avatar": avatar})
 
     return Response(suggestions)

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

@@ -6,4 +6,4 @@ from misago.users.serializers import RankSerializer
 
 class RanksViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
     serializer_class = RankSerializer
-    queryset = Rank.objects.filter(is_tab=True).order_by('order')
+    queryset = Rank.objects.filter(is_tab=True).order_by("order")

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

@@ -14,8 +14,8 @@ class UnbannedOnly(BasePermission):
         if ban:
             hydrated_ban = Ban(
                 check_type=Ban.IP,
-                user_message=ban['message'],
-                expires_on=ban['expires_on'],
+                user_message=ban["message"],
+                expires_on=ban["expires_on"],
             )
             raise Banned(hydrated_ban)
 
@@ -27,7 +27,9 @@ class UnbannedOnly(BasePermission):
 class UnbannedAnonOnly(UnbannedOnly):
     def has_permission(self, request, view):
         if request.user.is_authenticated:
-            raise PermissionDenied(_("This action is not available to signed in users."))
+            raise PermissionDenied(
+                _("This action is not available to signed in users.")
+            )
 
         self.is_request_banned(request)
         return True

+ 62 - 75
misago/users/api/userendpoints/avatar.py

@@ -24,14 +24,14 @@ def avatar_endpoint(request, pk=None):
 
         return Response(
             {
-                'detail': _("Your avatar is locked. You can't change it."),
-                'reason': reason,
+                "detail": _("Your avatar is locked. You can't change it."),
+                "reason": reason,
             },
             status=status.HTTP_403_FORBIDDEN,
         )
 
     avatar_options = get_avatar_options(request, request.user)
-    if request.method == 'POST':
+    if request.method == "POST":
         return avatar_post(request, avatar_options)
     else:
         return Response(avatar_options)
@@ -39,60 +39,56 @@ def avatar_endpoint(request, pk=None):
 
 def get_avatar_options(request, user):
     options = {
-        'avatars': user.avatars,
-        'generated': True,
-        'gravatar': False,
-        'crop_src': False,
-        'crop_tmp': False,
-        'upload': False,
-        'galleries': False,
+        "avatars": user.avatars,
+        "generated": True,
+        "gravatar": False,
+        "crop_src": False,
+        "crop_tmp": False,
+        "upload": False,
+        "galleries": False,
     }
 
     # Allow existing galleries
     if avatars.gallery.galleries_exist():
-        options['galleries'] = []
+        options["galleries"] = []
         for gallery in avatars.gallery.get_available_galleries():
             gallery_images = []
-            for image in gallery['images']:
-                gallery_images.append({
-                    'id': image.id,
-                    'url': image.url,
-                })
-            options['galleries'].append({
-                'name': gallery['name'],
-                'images': gallery_images,
-            })
+            for image in gallery["images"]:
+                gallery_images.append({"id": image.id, "url": image.url})
+            options["galleries"].append(
+                {"name": gallery["name"], "images": gallery_images}
+            )
 
     # Can't have custom avatar?
     if not request.settings.allow_custom_avatars:
         return options
 
     # Allow Gravatar download
-    options['gravatar'] = True
+    options["gravatar"] = True
 
     # Allow crop if we have uploaded temporary avatar
     if avatars.uploaded.has_source_avatar(user):
         try:
-            options['crop_src'] = {
-                'url': user.avatar_src.url,
-                'crop': json.loads(user.avatar_crop),
-                'size': max(settings.MISAGO_AVATARS_SIZES),
+            options["crop_src"] = {
+                "url": user.avatar_src.url,
+                "crop": json.loads(user.avatar_crop),
+                "size": max(settings.MISAGO_AVATARS_SIZES),
             }
         except (TypeError, ValueError):
             pass
 
     # Allow crop of uploaded avatar
     if avatars.uploaded.has_temporary_avatar(user):
-        options['crop_tmp'] = {
-            'url': user.avatar_tmp.url,
-            'size': max(settings.MISAGO_AVATARS_SIZES),
+        options["crop_tmp"] = {
+            "url": user.avatar_tmp.url,
+            "size": max(settings.MISAGO_AVATARS_SIZES),
         }
 
     # Allow upload conditions
-    options['upload'] = {
-        'limit': request.settings.avatar_upload_limit * 1024,
-        'allowed_extensions': avatars.uploaded.ALLOWED_EXTENSIONS,
-        'allowed_mime_types': avatars.uploaded.ALLOWED_MIME_TYPES,
+    options["upload"] = {
+        "limit": request.settings.avatar_upload_limit * 1024,
+        "allowed_extensions": avatars.uploaded.ALLOWED_EXTENSIONS,
+        "allowed_mime_types": avatars.uploaded.ALLOWED_MIME_TYPES,
     }
 
     return options
@@ -106,40 +102,30 @@ def avatar_post(request, options):
     user = request.user
     data = request.data
 
-    avatar_type = data.get('avatar', 'nope')
+    avatar_type = data.get("avatar", "nope")
 
     try:
         type_options = options[avatar_type]
         if not type_options:
             return Response(
-                {
-                    'detail': _("This avatar type is not allowed."),
-                },
+                {"detail": _("This avatar type is not allowed.")},
                 status=status.HTTP_400_BAD_REQUEST,
             )
 
         avatar_strategy = AVATAR_TYPES[avatar_type]
     except KeyError:
         return Response(
-            {
-                'detail': _("Unknown avatar type."),
-            },
-            status=status.HTTP_400_BAD_REQUEST,
+            {"detail": _("Unknown avatar type.")}, status=status.HTTP_400_BAD_REQUEST
         )
 
     try:
         if avatar_type == "upload":
             # avatar_upload strategy requires access to request.settings
-            response_dict = {'detail': avatar_upload(request, user, data)}
+            response_dict = {"detail": avatar_upload(request, user, data)}
         else:
-            response_dict = {'detail': avatar_strategy(user, data)}
+            response_dict = {"detail": avatar_strategy(user, data)}
     except AvatarError as e:
-        return Response(
-            {
-                'detail': e.args[0],
-            },
-            status=status.HTTP_400_BAD_REQUEST,
-        )
+        return Response({"detail": e.args[0]}, status=status.HTTP_400_BAD_REQUEST)
 
     user.save()
 
@@ -165,9 +151,9 @@ def avatar_gravatar(user, data):
 
 def avatar_gallery(user, data):
     try:
-        image_pk = int(data.get('image'))
+        image_pk = int(data.get("image"))
         image = AvatarGallery.objects.get(pk=image_pk)
-        if image.gallery == '__default__':
+        if image.gallery == "__default__":
             raise ValueError()
         avatars.gallery.set_avatar(user, image)
         return _("Avatar from gallery was set.")
@@ -176,7 +162,7 @@ def avatar_gallery(user, data):
 
 
 def avatar_upload(request, user, data):
-    new_avatar = data.get('image')
+    new_avatar = data.get("image")
     if not new_avatar:
         raise AvatarError(_("No file was sent."))
 
@@ -190,30 +176,30 @@ def avatar_upload(request, user, data):
 
 
 def avatar_crop_src(user, data):
-    avatar_crop(user, data, 'src')
+    avatar_crop(user, data, "src")
     return _("Avatar was re-cropped.")
 
 
 def avatar_crop_tmp(user, data):
-    avatar_crop(user, data, 'tmp')
+    avatar_crop(user, data, "tmp")
     return _("Uploaded avatar was set.")
 
 
 def avatar_crop(user, data, suffix):
     try:
-        crop = avatars.uploaded.crop_source_image(user, suffix, data.get('crop', {}))
+        crop = avatars.uploaded.crop_source_image(user, suffix, data.get("crop", {}))
         user.avatar_crop = json.dumps(crop)
     except ValidationError as e:
         raise AvatarError(e.args[0])
 
 
 AVATAR_TYPES = {
-    'generated': avatar_generate,
-    'gravatar': avatar_gravatar,
-    'galleries': avatar_gallery,
-    'upload': avatar_upload,
-    'crop_src': avatar_crop_src,
-    'crop_tmp': avatar_crop_tmp,
+    "generated": avatar_generate,
+    "gravatar": avatar_gravatar,
+    "galleries": avatar_gallery,
+    "upload": avatar_upload,
+    "crop_src": avatar_crop_src,
+    "crop_tmp": avatar_crop_tmp,
 }
 
 
@@ -222,24 +208,25 @@ def moderate_avatar_endpoint(request, profile):
         is_avatar_locked = profile.is_avatar_locked
         serializer = ModerateAvatarSerializer(profile, data=request.data)
         if serializer.is_valid():
-            if serializer.validated_data['is_avatar_locked'] and not is_avatar_locked:
+            if serializer.validated_data["is_avatar_locked"] and not is_avatar_locked:
                 avatars.dynamic.set_avatar(profile)
             serializer.save()
 
-            return Response({
-                'avatars': profile.avatars,
-                'is_avatar_locked': int(profile.is_avatar_locked),
-                'avatar_lock_user_message': profile.avatar_lock_user_message,
-                'avatar_lock_staff_message': profile.avatar_lock_staff_message,
-            })
-        else:
             return Response(
-                serializer.errors,
-                status=status.HTTP_400_BAD_REQUEST,
+                {
+                    "avatars": profile.avatars,
+                    "is_avatar_locked": int(profile.is_avatar_locked),
+                    "avatar_lock_user_message": profile.avatar_lock_user_message,
+                    "avatar_lock_staff_message": profile.avatar_lock_staff_message,
+                }
             )
+        else:
+            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
     else:
-        return Response({
-            'is_avatar_locked': int(profile.is_avatar_locked),
-            'avatar_lock_user_message': profile.avatar_lock_user_message,
-            'avatar_lock_staff_message': profile.avatar_lock_staff_message,
-        })
+        return Response(
+            {
+                "is_avatar_locked": int(profile.is_avatar_locked),
+                "avatar_lock_user_message": profile.avatar_lock_user_message,
+                "avatar_lock_staff_message": profile.avatar_lock_staff_message,
+            }
+        )

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

@@ -11,32 +11,28 @@ from misago.users.serializers import ChangeEmailSerializer
 
 def change_email_endpoint(request, pk=None):
     serializer = ChangeEmailSerializer(
-        data=request.data,
-        context={'user': request.user},
+        data=request.data, context={"user": request.user}
     )
 
     if serializer.is_valid():
         token = store_new_credential(
-            request, 'email', serializer.validated_data['new_email']
+            request, "email", serializer.validated_data["new_email"]
         )
 
         mail_subject = _("Confirm e-mail change on %(forum_name)s forums")
-        mail_subject = mail_subject % {'forum_name': request.settings.forum_name}
+        mail_subject = mail_subject % {"forum_name": request.settings.forum_name}
 
         # swap address with new one so email is sent to new address
-        request.user.email = serializer.validated_data['new_email']
+        request.user.email = serializer.validated_data["new_email"]
 
         mail_user(
             request.user,
             mail_subject,
-            'misago/emails/change_email',
-            context={
-                "settings": request.settings,
-                "token": token,
-            },
+            "misago/emails/change_email",
+            context={"settings": request.settings, "token": token},
         )
 
         message = _("E-mail change confirmation link was sent to new address.")
-        return Response({'detail': message})
+        return Response({"detail": message})
     else:
         return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

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

@@ -10,30 +10,26 @@ from misago.users.serializers import ChangePasswordSerializer
 
 def change_password_endpoint(request, pk=None):
     serializer = ChangePasswordSerializer(
-        data=request.data,
-        context={'user': request.user},
+        data=request.data, context={"user": request.user}
     )
 
     if serializer.is_valid():
         token = store_new_credential(
-            request, 'password', serializer.validated_data['new_password']
+            request, "password", serializer.validated_data["new_password"]
         )
 
         mail_subject = _("Confirm password change on %(forum_name)s forums")
-        mail_subject = mail_subject % {'forum_name': request.settings.forum_name}
+        mail_subject = mail_subject % {"forum_name": request.settings.forum_name}
 
         mail_user(
             request.user,
             mail_subject,
-            'misago/emails/change_password',
-            context={
-                "settings": request.settings,
-                "token": token,
-            },
+            "misago/emails/change_password",
+            context={"settings": request.settings, "token": token},
         )
 
-        return Response({
-            'detail': _("Password change confirmation link was sent to your address.")
-        })
+        return Response(
+            {"detail": _("Password change confirmation link was sent to your address.")}
+        )
     else:
         return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

+ 15 - 17
misago/users/api/userendpoints/create.py

@@ -12,7 +12,9 @@ from misago.legal.models import Agreement
 from misago.users import captcha
 from misago.users.forms.register import RegisterForm
 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
 
@@ -21,43 +23,39 @@ UserModel = get_user_model()
 
 @csrf_protect
 def create_endpoint(request):
-    if request.settings.account_activation == 'closed':
+    if request.settings.account_activation == "closed":
         raise PermissionDenied(_("New users registrations are currently closed."))
 
     form = RegisterForm(
-        request.data,
-        request=request,
-        agreements=Agreement.objects.get_agreements(),
+        request.data, request=request, agreements=Agreement.objects.get_agreements()
     )
 
     try:
         if form.is_valid():
             captcha.test_request(request)
     except ValidationError as e:
-        form.add_error('captcha', e)
+        form.add_error("captcha", e)
 
     if not form.is_valid():
         return Response(form.errors, status=status.HTTP_400_BAD_REQUEST)
 
     activation_kwargs = {}
-    if request.settings.account_activation == 'user':
-        activation_kwargs = {'requires_activation': UserModel.ACTIVATION_USER}
-    elif request.settings.account_activation == 'admin':
-        activation_kwargs = {'requires_activation': UserModel.ACTIVATION_ADMIN}
+    if request.settings.account_activation == "user":
+        activation_kwargs = {"requires_activation": UserModel.ACTIVATION_USER}
+    elif request.settings.account_activation == "admin":
+        activation_kwargs = {"requires_activation": UserModel.ACTIVATION_ADMIN}
 
     try:
         new_user = UserModel.objects.create_user(
-            form.cleaned_data['username'],
-            form.cleaned_data['email'],
-            form.cleaned_data['password'],
+            form.cleaned_data["username"],
+            form.cleaned_data["email"],
+            form.cleaned_data["password"],
             joined_from_ip=request.user_ip,
             **activation_kwargs
         )
     except IntegrityError:
         return Response(
-            {
-                '__all__': _("Please try resubmitting the form.")
-            },
+            {"__all__": _("Please try resubmitting the form.")},
             status=status.HTTP_400_BAD_REQUEST,
         )
 
@@ -67,7 +65,7 @@ def create_endpoint(request):
 
     if new_user.requires_activation == UserModel.ACTIVATION_NONE:
         authenticated_user = authenticate(
-            username=new_user.email, password=form.cleaned_data['password']
+            username=new_user.email, password=form.cleaned_data["password"]
         )
         login(request, authenticated_user)
 

+ 7 - 14
misago/users/api/userendpoints/editdetails.py

@@ -7,7 +7,7 @@ from misago.users.profilefields import profilefields, serialize_profilefields_da
 
 
 def edit_details_endpoint(request, user):
-    if request.method == 'GET':
+    if request.method == "GET":
         return get_form_description(request, user)
 
     return submit_form(request, user)
@@ -17,14 +17,11 @@ def get_form_description(request, user):
     groups = []
     for group in profilefields.get_fields_groups():
         group_fields = []
-        for field in group['fields']:
+        for field in group["fields"]:
             if field.is_editable(request, user):
                 group_fields.append(field.get_form_field_json(request, user))
         if group_fields:
-            groups.append({
-                'name': group['name'],
-                'fields': group_fields
-            })
+            groups.append({"name": group["name"], "fields": group_fields})
 
     return Response(groups)
 
@@ -35,15 +32,11 @@ def submit_form(request, user):
         if field.is_editable(request, user):
             fields.append(field)
 
-    form = DetailsForm(
-        request.data,
-        request=request,
-        user=user,
-    )
+    form = DetailsForm(request.data, request=request, user=user)
 
     if form.is_valid():
         profilefields.update_user_profile_fields(request, user, form)
-        user.save(update_fields=['profile_fields'])
+        user.save(update_fields=["profile_fields"])
 
         return Response(serialize_profilefields_data(request, profilefields, user))
 
@@ -52,8 +45,8 @@ def submit_form(request, user):
 
 class DetailsForm(forms.Form):
     def __init__(self, *args, **kwargs):
-        self.request = kwargs.pop('request')
-        self.user = kwargs.pop('user')
+        self.request = kwargs.pop("request")
+        self.user = kwargs.pop("user")
 
         super().__init__(*args, **kwargs)
 

+ 5 - 7
misago/users/api/userendpoints/list.py

@@ -18,10 +18,10 @@ def active(request):
 
 
 def rank_users(request):
-    rank_pk = get_int_or_404(request.query_params.get('rank'))
+    rank_pk = get_int_or_404(request.query_params.get("rank"))
     rank = get_object_or_404(Rank.objects, pk=rank_pk, is_tab=True)
 
-    page = get_int_or_404(request.GET.get('page', 0))
+    page = get_int_or_404(request.GET.get("page", 0))
     if page == 1:
         page = 0  # api allows explicit first page
 
@@ -29,13 +29,11 @@ def rank_users(request):
     return Response(users.get_frontend_context())
 
 
-LISTS = {
-    'active': active,
-}
+LISTS = {"active": active}
 
 
 def list_endpoint(request):
-    list_type = request.query_params.get('list')
+    list_type = request.query_params.get("list")
     list_handler = LISTS.get(list_type)
 
     if list_handler:
@@ -44,4 +42,4 @@ def list_endpoint(request):
         return rank_users(request)
 
 
-ScoredUserSerializer = UserCardSerializer.extend_fields('meta')
+ScoredUserSerializer = UserCardSerializer.extend_fields("meta")

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

@@ -10,11 +10,11 @@ from misago.users.signatures import is_user_signature_valid, set_user_signature
 
 
 def signature_endpoint(request):
-    if not request.user_acl['can_have_signature']:
+    if not request.user_acl["can_have_signature"]:
         raise PermissionDenied(_("You don't have permission to change signature."))
 
     user = request.user
-    
+
     if user.is_signature_locked:
         if user.signature_lock_user_message:
             reason = format_plaintext_for_html(user.signature_lock_user_message)
@@ -23,32 +23,26 @@ def signature_endpoint(request):
 
         return Response(
             {
-                'detail': _("Your signature is locked. You can't change it."),
-                'reason': reason
+                "detail": _("Your signature is locked. You can't change it."),
+                "reason": reason,
             },
-            status=status.HTTP_403_FORBIDDEN
+            status=status.HTTP_403_FORBIDDEN,
         )
 
-    if request.method == 'POST':
+    if request.method == "POST":
         return edit_signature(request, user)
 
     return get_signature_options(request.settings, user)
 
 
 def get_signature_options(settings, user):
-    options = {
-        'signature': None,
-        'limit': settings.signature_length_max,
-    }
+    options = {"signature": None, "limit": settings.signature_length_max}
 
     if user.signature:
-        options['signature'] = {
-            'plain': user.signature,
-            'html': user.signature_parsed,
-        }
+        options["signature"] = {"plain": user.signature, "html": user.signature_parsed}
 
         if not is_user_signature_valid(user):
-            options['signature']['html'] = None
+            options["signature"]["html"] = None
 
     return Response(options)
 
@@ -58,12 +52,12 @@ def edit_signature(request, user):
         user, data=request.data, context={"settings": request.settings}
     )
     if serializer.is_valid():
-        signature = 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(request.settings, user)
 
     return Response(
-        {'detail': serializer.errors['non_field_errors'][0]},
-        status=status.HTTP_400_BAD_REQUEST
+        {"detail": serializer.errors["non_field_errors"][0]},
+        status=status.HTTP_400_BAD_REQUEST,
     )

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

@@ -9,7 +9,7 @@ from misago.users.serializers import ChangeUsernameSerializer
 
 
 def username_endpoint(request):
-    if request.method == 'POST':
+    if request.method == "POST":
         return change_username(request)
     else:
         options = get_username_options_from_request(request)
@@ -17,90 +17,76 @@ def username_endpoint(request):
 
 
 def get_username_options_from_request(request):
-    return get_username_options(
-        request.settings, request.user, request.user_acl
-    )
+    return get_username_options(request.settings, request.user, request.user_acl)
 
 
 def options_response(options):
-    if options['next_on']:
-        options['next_on'] = options['next_on'].isoformat()
+    if options["next_on"]:
+        options["next_on"] = options["next_on"].isoformat()
     return Response(options)
 
 
 def change_username(request):
     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
+            {"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},
+        data=request.data, context={"settings": request.settings, "user": request.user}
     )
     if serializer.is_valid():
         try:
             serializer.change_username(changed_by=request.user)
             updated_options = get_username_options_from_request(request)
-            if updated_options['next_on']:
-                updated_options['next_on'] = updated_options['next_on'].isoformat()
+            if updated_options["next_on"]:
+                updated_options["next_on"] = updated_options["next_on"].isoformat()
 
-            return Response({
-                'username': request.user.username,
-                'slug': request.user.slug,
-                'options': updated_options,
-            })
-        except IntegrityError:
             return Response(
                 {
-                    'detail': _("Error changing username. Please try again."),
-                },
-                status=status.HTTP_400_BAD_REQUEST
+                    "username": request.user.username,
+                    "slug": request.user.slug,
+                    "options": updated_options,
+                }
+            )
+        except IntegrityError:
+            return Response(
+                {"detail": _("Error changing username. Please try again.")},
+                status=status.HTTP_400_BAD_REQUEST,
             )
     else:
         return Response(
-            {
-                'detail': serializer.errors['non_field_errors'][0]
-            },
-            status=status.HTTP_400_BAD_REQUEST
+            {"detail": serializer.errors["non_field_errors"][0]},
+            status=status.HTTP_400_BAD_REQUEST,
         )
 
 
 def moderate_username_endpoint(request, profile):
-    if request.method == 'POST':
+    if request.method == "POST":
         serializer = ChangeUsernameSerializer(
-            data=request.data,
-            context={'settings': request.settings, 'user': profile},
+            data=request.data, context={"settings": request.settings, "user": profile}
         )
 
         if serializer.is_valid():
             try:
                 serializer.change_username(changed_by=request.user)
-                return Response({
-                    'username': profile.username,
-                    'slug': profile.slug,
-                })
+                return Response({"username": profile.username, "slug": profile.slug})
             except IntegrityError:
                 return Response(
-                    {
-                        'detail': _("Error changing username. Please try again."),
-                    },
-                    status=status.HTTP_400_BAD_REQUEST
+                    {"detail": _("Error changing username. Please try again.")},
+                    status=status.HTTP_400_BAD_REQUEST,
                 )
         else:
             return Response(
-                {
-                    'detail': serializer.errors['non_field_errors'][0]
-                },
-                status=status.HTTP_400_BAD_REQUEST
+                {"detail": serializer.errors["non_field_errors"][0]},
+                status=status.HTTP_400_BAD_REQUEST,
             )
     else:
-        return Response({
-            'length_min': request.settings.username_length_min,
-            'length_max': request.settings.username_length_max,
-        })
+        return Response(
+            {
+                "length_min": request.settings.username_length_min,
+                "length_max": request.settings.username_length_max,
+            }
+        )

+ 18 - 16
misago/users/api/usernamechanges.py

@@ -20,41 +20,43 @@ UserModel = get_user_model()
 class UsernameChangesViewSetPermission(BasePermission):
     def has_permission(self, request, view):
         try:
-            user_pk = int(request.query_params.get('user'))
+            user_pk = int(request.query_params.get("user"))
         except (ValueError, TypeError):
             user_pk = -1
 
         if user_pk == request.user.pk:
             return True
-        elif not request.user_acl.get('can_see_users_name_history'):
-            raise PermissionDenied(_("You don't have permission to see other users name history."))
+        elif not request.user_acl.get("can_see_users_name_history"):
+            raise PermissionDenied(
+                _("You don't have permission to see other users name history.")
+            )
         return True
 
 
 class UsernameChangesViewSet(viewsets.GenericViewSet):
-    permission_classes = (UsernameChangesViewSetPermission, )
+    permission_classes = (UsernameChangesViewSetPermission,)
     serializer_class = UsernameChangeSerializer
 
     def get_queryset(self):
         queryset = UsernameChange.objects
 
-        if self.request.query_params.get('user'):
-            user_pk = get_int_or_404(self.request.query_params.get('user'))
+        if self.request.query_params.get("user"):
+            user_pk = get_int_or_404(self.request.query_params.get("user"))
             queryset = get_object_or_404(UserModel.objects, pk=user_pk).namechanges
 
-        if self.request.query_params.get('search'):
-            search_phrase = self.request.query_params.get('search').strip()
+        if self.request.query_params.get("search"):
+            search_phrase = self.request.query_params.get("search").strip()
             if search_phrase:
                 queryset = queryset.filter(
-                    Q(changed_by_username__istartswith=search_phrase) | Q(
-                        new_username__istartswith=search_phrase
-                    ) | Q(old_username__istartswith=search_phrase)
+                    Q(changed_by_username__istartswith=search_phrase)
+                    | Q(new_username__istartswith=search_phrase)
+                    | Q(old_username__istartswith=search_phrase)
                 )
 
-        return queryset.select_related('user', 'changed_by').order_by('-id')
+        return queryset.select_related("user", "changed_by").order_by("-id")
 
     def list(self, request):
-        page = get_int_or_404(request.query_params.get('page', 0))
+        page = get_int_or_404(request.query_params.get("page", 0))
         if page == 1:
             page = 0  # api allows explicit first page
 
@@ -63,8 +65,8 @@ class UsernameChangesViewSet(viewsets.GenericViewSet):
         list_page = paginate(queryset, page, 12, 4)
 
         data = pagination_dict(list_page)
-        data.update({
-            'results': UsernameChangeSerializer(list_page.object_list, many=True).data,
-        })
+        data.update(
+            {"results": UsernameChangeSerializer(list_page.object_list, many=True).data}
+        )
 
         return Response(data)

+ 99 - 78
misago/users/api/users.py

@@ -18,15 +18,28 @@ from misago.core.rest_permissions import IsAuthenticatedOrReadOnly
 from misago.core.shortcuts import get_int_or_404
 from misago.threads.moderation import hide_post, hide_thread
 from misago.users.bans import get_user_ban
-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.online.utils import get_user_status
 from misago.users.permissions import (
-    allow_browse_users_list, allow_delete_user, allow_edit_profile_details, allow_follow_user,
-    allow_moderate_avatar, allow_rename_user, allow_see_ban_details)
+    allow_browse_users_list,
+    allow_delete_user,
+    allow_edit_profile_details,
+    allow_follow_user,
+    allow_moderate_avatar,
+    allow_rename_user,
+    allow_see_ban_details,
+)
 from misago.users.profilefields import profilefields, serialize_profilefields_data
 from misago.users.serializers import (
-    BanDetailsSerializer, DataDownloadSerializer, DeleteOwnAccountSerializer, ForumOptionsSerializer,
-    UserSerializer)
+    BanDetailsSerializer,
+    DataDownloadSerializer,
+    DeleteOwnAccountSerializer,
+    ForumOptionsSerializer,
+    UserSerializer,
+)
 from misago.users.viewmodels import Followers, Follows, UserPosts, UserThreads
 
 from .rest_permissions import BasePermission, UnbannedAnonOnly
@@ -45,7 +58,7 @@ UserModel = get_user_model()
 
 class UserViewSetPermission(BasePermission):
     def has_permission(self, request, view):
-        if view.action == 'create':
+        if view.action == "create":
             policy = UnbannedAnonOnly()
         else:
             policy = IsAuthenticatedOrReadOnly()
@@ -60,12 +73,12 @@ def allow_self_only(user, pk, message):
 
 
 class UserViewSet(viewsets.GenericViewSet):
-    permission_classes = (UserViewSetPermission, )
+    permission_classes = (UserViewSetPermission,)
     parser_classes = (FormParser, JSONParser, MultiPartParser)
     queryset = UserModel.objects
 
     def get_queryset(self):
-        relations = ('rank', 'online_tracker', 'ban_cache')
+        relations = ("rank", "online_tracker", "ban_cache")
         return self.queryset.select_related(*relations)
 
     def get_user(self, request, pk):
@@ -87,24 +100,24 @@ class UserViewSet(viewsets.GenericViewSet):
         add_acl_to_obj(request.user_acl, profile)
         profile.status = get_user_status(request, profile)
 
-        serializer = UserProfileSerializer(profile, context={'request': request})
+        serializer = UserProfileSerializer(profile, context={"request": request})
         profile_json = serializer.data
 
         if not profile.is_active:
-            profile_json['is_active'] = False
+            profile_json["is_active"] = False
         if profile.is_deleting_account:
-            profile_json['is_deleting_account'] = True
+            profile_json["is_deleting_account"] = True
 
         return Response(profile_json)
 
-    @detail_route(methods=['get', 'post'])
+    @detail_route(methods=["get", "post"])
     def avatar(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(request.user, pk, _("You can't change other users avatars."))
 
         return avatar_endpoint(request)
 
-    @detail_route(methods=['post'])
+    @detail_route(methods=["post"])
     def forum_options(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(request.user, pk, _("You can't change other users options."))
@@ -112,61 +125,62 @@ class UserViewSet(viewsets.GenericViewSet):
         serializer = ForumOptionsSerializer(request.user, data=request.data)
         if serializer.is_valid():
             serializer.save()
-            return Response({'detail': _("Your forum options have been changed.")})
+            return Response({"detail": _("Your forum options have been changed.")})
         else:
             return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
 
-    @detail_route(methods=['get', 'post'])
+    @detail_route(methods=["get", "post"])
     def username(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(request.user, pk, _("You can't change other users names."))
 
         return username_endpoint(request)
 
-    @detail_route(methods=['get', 'post'])
+    @detail_route(methods=["get", "post"])
     def signature(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(request.user, pk, _("You can't change other users signatures."))
 
         return signature_endpoint(request)
 
-    @detail_route(methods=['post'])
+    @detail_route(methods=["post"])
     def change_password(self, request, pk=None):
         get_int_or_404(pk)
         allow_self_only(request.user, pk, _("You can't change other users passwords."))
 
         return change_password_endpoint(request)
 
-    @detail_route(methods=['post'])
+    @detail_route(methods=["post"])
     def change_email(self, request, pk=None):
         get_int_or_404(pk)
-        allow_self_only(request.user, pk, _("You can't change other users e-mail addresses."))
+        allow_self_only(
+            request.user, pk, _("You can't change other users e-mail addresses.")
+        )
 
         return change_email_endpoint(request)
 
-    @detail_route(methods=['get'])
+    @detail_route(methods=["get"])
     def details(self, request, pk=None):
         profile = self.get_user(request, pk)
         data = serialize_profilefields_data(request, profilefields, profile)
         return Response(data)
 
-    @detail_route(methods=['get', 'post'])
+    @detail_route(methods=["get", "post"])
     def edit_details(self, request, pk=None):
         profile = self.get_user(request, pk)
         allow_edit_profile_details(request.user_acl, profile)
         return edit_details_endpoint(request, profile)
 
-    @detail_route(methods=['post'])
+    @detail_route(methods=["post"])
     def delete_own_account(self, request, pk=None):
         serializer = DeleteOwnAccountSerializer(
-            data=request.data,
-            context={'user': request.user},
+            data=request.data, context={"user": request.user}
         )
         serializer.is_valid(raise_exception=True)
         serializer.mark_account_for_deletion(request)
         return Response({})
 
-    @detail_route(methods=['post'])
+    @detail_route(methods=["post"])
     def follow(self, request, pk=None):
         profile = self.get_user(request, pk)
         allow_follow_user(request.user_acl, profile)
@@ -179,20 +193,20 @@ class UserViewSet(viewsets.GenericViewSet):
                 followed = False
 
                 profile_followers -= 1
-                profile.followers = F('followers') - 1
-                request.user.following = F('following') - 1
+                profile.followers = F("followers") - 1
+                request.user.following = F("following") - 1
             else:
                 request.user.follows.add(profile)
                 followed = True
 
                 profile_followers += 1
-                profile.followers = F('followers') + 1
-                request.user.following = F('following') + 1
+                profile.followers = F("followers") + 1
+                request.user.following = F("following") + 1
 
-            profile.save(update_fields=['followers'])
-            request.user.save(update_fields=['following'])
+            profile.save(update_fields=["followers"])
+            request.user.save(update_fields=["following"])
 
-            return Response({'is_followed': followed, 'followers': profile_followers})
+            return Response({"is_followed": followed, "followers": profile_followers})
 
     @detail_route()
     def ban(self, request, pk=None):
@@ -205,57 +219,62 @@ class UserViewSet(viewsets.GenericViewSet):
         else:
             return Response({})
 
-    @detail_route(methods=['get', 'post'])
+    @detail_route(methods=["get", "post"])
     def moderate_avatar(self, request, pk=None):
         profile = self.get_user(request, pk)
         allow_moderate_avatar(request.user_acl, profile)
 
         return moderate_avatar_endpoint(request, profile)
 
-    @detail_route(methods=['get', 'post'])
+    @detail_route(methods=["get", "post"])
     def moderate_username(self, request, pk=None):
         profile = self.get_user(request, pk)
         allow_rename_user(request.user_acl, profile)
 
         return moderate_username_endpoint(request, profile)
 
-    @detail_route(methods=['post'])
+    @detail_route(methods=["post"])
     def request_data_download(self, request, pk=None):
         get_int_or_404(pk)
-        allow_self_only(request.user, pk, _("You can't request data downloads for other users."))
+        allow_self_only(
+            request.user, pk, _("You can't request data downloads for other users.")
+        )
 
         if not settings.MISAGO_ENABLE_DOWNLOAD_OWN_DATA:
             raise PermissionDenied(_("You can't download your data."))
 
         if user_has_data_download_request(request.user):
             raise PermissionDenied(
-                _("You can't have more than one data download request at single time."))
-            
+                _("You can't have more than one data download request at single time.")
+            )
+
         request_user_data_download(request.user)
 
-        return Response({'detail': 'ok'})
+        return Response({"detail": "ok"})
 
-    @detail_route(methods=['get', 'post'])
+    @detail_route(methods=["get", "post"])
     def delete(self, request, pk=None):
         profile = self.get_user(request, pk)
         allow_delete_user(request.user_acl, profile)
 
-        if request.method == 'POST':
+        if request.method == "POST":
             with transaction.atomic():
                 profile.lock()
 
-                if request.data.get('with_content'):
+                if request.data.get("with_content"):
                     profile.delete_content()
                 else:
                     categories_to_sync = set()
 
-                    threads = profile.thread_set.select_related('category', 'first_post')
+                    threads = profile.thread_set.select_related(
+                        "category", "first_post"
+                    )
                     for thread in threads.filter(is_hidden=False).iterator():
                         categories_to_sync.add(thread.category_id)
                         hide_thread(request, thread)
 
                     posts = profile.post_set.select_related(
-                        'category', 'thread', 'thread__category'
+                        "category", "thread", "thread__category"
                     )
                     for post in posts.filter(is_hidden=False).iterator():
                         categories_to_sync.add(post.category_id)
@@ -272,48 +291,50 @@ class UserViewSet(viewsets.GenericViewSet):
 
         return Response({})
 
-    @detail_route(methods=['get'])
+    @detail_route(methods=["get"])
     def data_downloads(self, request, pk=None):
         get_int_or_404(pk)
-        allow_self_only(request.user, pk, _("You can't see other users data downloads."))
+        allow_self_only(
+            request.user, pk, _("You can't see other users data downloads.")
+        )
 
         queryset = request.user.datadownload_set.all()[:5]
         serializer = DataDownloadSerializer(queryset, many=True)
         return Response(serializer.data)
 
-    @detail_route(methods=['get'])
+    @detail_route(methods=["get"])
     def followers(self, request, pk=None):
         profile = self.get_user(request, pk)
 
-        page = get_int_or_404(request.query_params.get('page', 0))
+        page = get_int_or_404(request.query_params.get("page", 0))
         if page == 1:
             page = 0  # api allows explicit first page
 
-        search = request.query_params.get('search')
+        search = request.query_params.get("search")
 
         users = Followers(request, profile, page, search)
 
         return Response(users.get_frontend_context())
 
-    @detail_route(methods=['get'])
+    @detail_route(methods=["get"])
     def follows(self, request, pk=None):
         profile = self.get_user(request, pk)
 
-        page = get_int_or_404(request.query_params.get('page', 0))
+        page = get_int_or_404(request.query_params.get("page", 0))
         if page == 1:
             page = 0  # api allows explicit first page
 
-        search = request.query_params.get('search')
+        search = request.query_params.get("search")
 
         users = Follows(request, profile, page, search)
 
         return Response(users.get_frontend_context())
 
-    @detail_route(methods=['get'])
+    @detail_route(methods=["get"])
     def threads(self, request, pk=None):
         profile = self.get_user(request, pk)
 
-        page = get_int_or_404(request.query_params.get('page', 0))
+        page = get_int_or_404(request.query_params.get("page", 0))
         if page == 1:
             page = 0  # api allows explicit first page
 
@@ -321,11 +342,11 @@ class UserViewSet(viewsets.GenericViewSet):
 
         return Response(feed.get_frontend_context())
 
-    @detail_route(methods=['get'])
+    @detail_route(methods=["get"])
     def posts(self, request, pk=None):
         profile = self.get_user(request, pk)
 
-        page = get_int_or_404(request.query_params.get('page', 0))
+        page = get_int_or_404(request.query_params.get("page", 0))
         if page == 1:
             page = 0  # api allows explicit first page
 
@@ -335,26 +356,26 @@ class UserViewSet(viewsets.GenericViewSet):
 
 
 UserProfileSerializer = UserSerializer.subset_fields(
-    'id',
-    'username',
-    'slug',
-    'email',
-    'joined_on',
-    'rank',
-    'title',
-    'avatars',
-    'is_avatar_locked',
-    'signature',
-    'is_signature_locked',
-    'followers',
-    'following',
-    'threads',
-    'posts',
-    'acl',
-    'is_followed',
-    'is_blocked',
-    'real_name',
-    'status',
-    'api',
-    'url',
+    "id",
+    "username",
+    "slug",
+    "email",
+    "joined_on",
+    "rank",
+    "title",
+    "avatars",
+    "is_avatar_locked",
+    "signature",
+    "is_signature_locked",
+    "followers",
+    "following",
+    "threads",
+    "posts",
+    "acl",
+    "is_followed",
+    "is_blocked",
+    "real_name",
+    "status",
+    "api",
+    "url",
 )

+ 45 - 47
misago/users/apps.py

@@ -7,8 +7,8 @@ from .pages import user_profile, usercp, users_list
 
 
 class MisagoUsersConfig(AppConfig):
-    name = 'misago.users'
-    label = 'misago_users'
+    name = "misago.users"
+    label = "misago_users"
     verbose_name = "Misago Auth"
 
     def ready(self):
@@ -20,66 +20,67 @@ class MisagoUsersConfig(AppConfig):
 
     def register_default_usercp_pages(self):
         usercp.add_section(
-            link='misago:usercp-change-forum-options',
+            link="misago:usercp-change-forum-options",
             name=_("Forum options"),
-            component='forum-options',
-            icon='settings',
+            component="forum-options",
+            icon="settings",
         )
         usercp.add_section(
-            link='misago:usercp-edit-details',
+            link="misago:usercp-edit-details",
             name=_("Edit details"),
-            component='edit-details',
-            icon='person_outline',
+            component="edit-details",
+            icon="person_outline",
         )
         usercp.add_section(
-            link='misago:usercp-change-username',
+            link="misago:usercp-change-username",
             name=_("Change username"),
-            component='change-username',
-            icon='card_membership',
+            component="change-username",
+            icon="card_membership",
         )
         usercp.add_section(
-            link='misago:usercp-change-email-password',
+            link="misago:usercp-change-email-password",
             name=_("Change email or password"),
-            component='sign-in-credentials',
-            icon='vpn_key',
+            component="sign-in-credentials",
+            icon="vpn_key",
         )
 
         if settings.MISAGO_ENABLE_DOWNLOAD_OWN_DATA:
             usercp.add_section(
-                link='misago:usercp-download-data',
+                link="misago:usercp-download-data",
                 name=_("Download data"),
-                component='download-data',
-                icon='save_alt',
+                component="download-data",
+                icon="save_alt",
             )
 
         if settings.MISAGO_ENABLE_DELETE_OWN_ACCOUNT:
             usercp.add_section(
-                link='misago:usercp-delete-account',
+                link="misago:usercp-delete-account",
                 name=_("Delete account"),
-                component='delete-account',
-                icon='cancel',
+                component="delete-account",
+                icon="cancel",
             )
 
     def register_default_users_list_pages(self):
         users_list.add_section(
-            link='misago:users-active-posters',
-            component='active-posters',
-            name=_("Active poster")
+            link="misago:users-active-posters",
+            component="active-posters",
+            name=_("Active poster"),
         )
 
     def register_default_user_profile_pages(self):
         def can_see_names_history(request, profile):
             if request.user.is_authenticated:
                 is_account_owner = profile.pk == request.user.pk
-                has_permission = request.user_acl['can_see_users_name_history']
+                has_permission = request.user_acl["can_see_users_name_history"]
                 return is_account_owner or has_permission
             else:
                 return False
 
         def can_see_ban_details(request, profile):
             if request.user.is_authenticated:
-                if request.user_acl['can_see_ban_details']:
+                if request.user_acl["can_see_ban_details"]:
                     from .bans import get_user_ban
+
                     return bool(get_user_ban(profile, request.cache_versions))
                 else:
                     return False
@@ -87,46 +88,43 @@ class MisagoUsersConfig(AppConfig):
                 return False
 
         user_profile.add_section(
-            link='misago:user-posts',
-            name=_("Posts"),
-            icon='message',
-            component='posts',
+            link="misago:user-posts", name=_("Posts"), icon="message", component="posts"
         )
         user_profile.add_section(
-            link='misago:user-threads',
+            link="misago:user-threads",
             name=_("Threads"),
-            icon='forum',
-            component='threads',
+            icon="forum",
+            component="threads",
         )
         user_profile.add_section(
-            link='misago:user-followers',
+            link="misago:user-followers",
             name=_("Followers"),
-            icon='favorite',
-            component='followers',
+            icon="favorite",
+            component="followers",
         )
         user_profile.add_section(
-            link='misago:user-follows',
+            link="misago:user-follows",
             name=_("Follows"),
-            icon='favorite_border',
-            component='follows',
+            icon="favorite_border",
+            component="follows",
         )
         user_profile.add_section(
-            link='misago:user-details',
+            link="misago:user-details",
             name=_("Details"),
-            icon='person_outline',
-            component='details',
+            icon="person_outline",
+            component="details",
         )
         user_profile.add_section(
-            link='misago:username-history',
+            link="misago:username-history",
             name=_("Username history"),
-            icon='card_membership',
-            component='username-history',
+            icon="card_membership",
+            component="username-history",
             visible_if=can_see_names_history,
         )
         user_profile.add_section(
-            link='misago:user-ban',
+            link="misago:user-ban",
             name=_("Ban details"),
-            icon='remove_circle_outline',
-            component='ban-details',
+            icon="remove_circle_outline",
+            component="ban-details",
             visible_if=can_see_ban_details,
         )

+ 1 - 3
misago/users/audittrail.py

@@ -13,7 +13,5 @@ def create_user_audit_trail(user, ip_address, obj):
         return None
 
     return user.audittrail_set.create(
-        user=user,
-        ip_address=ip_address,
-        content_object=obj,
+        user=user, ip_address=ip_address, content_object=obj
     )

+ 3 - 3
misago/users/authbackends.py

@@ -7,8 +7,8 @@ UserModel = get_user_model()
 
 class MisagoBackend(ModelBackend):
     def authenticate(self, request, username=None, password=None, **kwargs):
-        if kwargs.get('email'):
-            username = kwargs['email']  # Bias to email if it was passed explictly
+        if kwargs.get("email"):
+            username = kwargs["email"]  # Bias to email if it was passed explictly
 
         if not username or not password:
             # If no username or password was given, skip rest of this auth
@@ -28,7 +28,7 @@ class MisagoBackend(ModelBackend):
     def get_user(self, pk):
         try:
             manager = UserModel._default_manager
-            relations = ('rank', 'online_tracker', 'ban_cache')
+            relations = ("rank", "online_tracker", "ban_cache")
             user = manager.select_related(*relations).get(pk=pk)
         except UserModel.DoesNotExist:
             return None

+ 4 - 4
misago/users/avatars/__init__.py

@@ -2,12 +2,12 @@ from misago.conf import settings
 
 from . import store, gravatar, dynamic, gallery, uploaded
 
-AVATAR_TYPES = ('gravatar', 'dynamic', 'gallery', 'uploaded')
+AVATAR_TYPES = ("gravatar", "dynamic", "gallery", "uploaded")
 
 SET_DEFAULT_AVATAR = {
-    'gravatar': gravatar.set_avatar,
-    'dynamic': dynamic.set_avatar,
-    'gallery': gallery.set_random_avatar,
+    "gravatar": gravatar.set_avatar,
+    "dynamic": dynamic.set_avatar,
+    "gallery": gallery.set_random_avatar,
 }
 
 

+ 19 - 5
misago/users/avatars/dynamic.py

@@ -10,12 +10,26 @@ from . import store
 
 
 COLOR_WHEEL = (
-    '#d32f2f', '#c2185b', '#7b1fa2', '#512da8', '#303f9f', '#1976d2', '#0288D1', '#0288d1',
-    '#0097a7', '#00796b', '#388e3c', '#689f38', '#afb42b', '#fbc02d', '#ffa000', '#f57c00',
-    '#e64a19',
+    "#d32f2f",
+    "#c2185b",
+    "#7b1fa2",
+    "#512da8",
+    "#303f9f",
+    "#1976d2",
+    "#0288D1",
+    "#0288d1",
+    "#0097a7",
+    "#00796b",
+    "#388e3c",
+    "#689f38",
+    "#afb42b",
+    "#fbc02d",
+    "#ffa000",
+    "#f57c00",
+    "#e64a19",
 )
 COLOR_WHEEL_LEN = len(COLOR_WHEEL)
-FONT_FILE = os.path.join(os.path.dirname(__file__), 'font.ttf')
+FONT_FILE = os.path.join(os.path.dirname(__file__), "font.ttf")
 
 
 def set_avatar(user):
@@ -59,7 +73,7 @@ def draw_avatar_flavour(user, image):
     font = ImageFont.truetype(FONT_FILE, size=size)
 
     text_size = font.getsize(string)
-    text_pos = ((image_size - text_size[0]) / 2, (image_size - text_size[1]) / 2, )
+    text_pos = ((image_size - text_size[0]) / 2, (image_size - text_size[1]) / 2)
 
     writer = ImageDraw.Draw(image)
     writer.text(text_pos, string, font=font)

+ 15 - 13
misago/users/avatars/gallery.py

@@ -10,7 +10,7 @@ from misago.conf import settings
 from . import store
 
 
-DEFAULT_GALLERY = '__default__'
+DEFAULT_GALLERY = "__default__"
 
 
 def get_available_galleries(include_default=False):
@@ -30,17 +30,18 @@ def get_available_galleries(include_default=False):
             continue
 
         if image.gallery not in galleries_dicts:
-            galleries_dicts[image.gallery] = {'name': image.gallery, 'images': []}
+            galleries_dicts[image.gallery] = {"name": image.gallery, "images": []}
 
             galleries.append(galleries_dicts[image.gallery])
 
-        galleries_dicts[image.gallery]['images'].append(image)
+        galleries_dicts[image.gallery]["images"].append(image)
 
     return galleries
 
 
 def galleries_exist():
     from misago.users.models import AvatarGallery
+
     return AvatarGallery.objects.exists()
 
 
@@ -56,20 +57,21 @@ def load_avatar_galleries():
         images = glob_gallery_images(directory)
 
         for image in images:
-            with open(image, 'rb') as image_file:
+            with open(image, "rb") as image_file:
                 galleries.append(
-                    AvatarGallery.objects.
-                    create(gallery=name, image=ContentFile(image_file.read(), 'image'))
+                    AvatarGallery.objects.create(
+                        gallery=name, image=ContentFile(image_file.read(), "image")
+                    )
                 )
     return galleries
 
 
 def glob_gallery_images(directory):
     images = []
-    images.extend(directory.glob('*.gif'))
-    images.extend(directory.glob('*.jpg'))
-    images.extend(directory.glob('*.jpeg'))
-    images.extend(directory.glob('*.png'))
+    images.extend(directory.glob("*.gif"))
+    images.extend(directory.glob("*.jpg"))
+    images.extend(directory.glob("*.jpeg"))
+    images.extend(directory.glob("*.png"))
     return images
 
 
@@ -84,11 +86,11 @@ def set_random_avatar(user):
 
     avatars_list = []
     for gallery in galleries:
-        if gallery['name'] == DEFAULT_GALLERY:
-            avatars_list = gallery['images']
+        if gallery["name"] == DEFAULT_GALLERY:
+            avatars_list = gallery["images"]
             break
         else:
-            avatars_list += gallery['images']
+            avatars_list += gallery["images"]
 
     random_avatar = random.choice(avatars_list)
     store.store_new_avatar(user, Image.open(random_avatar.image))

+ 1 - 1
misago/users/avatars/gravatar.py

@@ -8,7 +8,7 @@ from misago.conf import settings
 from . import store
 
 
-GRAVATAR_URL = 'http://www.gravatar.com/avatar/%s?s=%s&d=404'
+GRAVATAR_URL = "http://www.gravatar.com/avatar/%s?s=%s&d=404"
 
 
 class GravatarError(RuntimeError):

+ 12 - 9
misago/users/avatars/store.py

@@ -13,7 +13,7 @@ from misago.conf import settings
 def normalize_image(image):
     """strip image of animation, convert to RGBA"""
     image.seek(0)
-    return image.copy().convert('RGBA')
+    return image.copy().convert("RGBA")
 
 
 def delete_avatar(user, delete_tmp=True, delete_src=True):
@@ -30,6 +30,7 @@ def delete_avatar(user, delete_tmp=True, delete_src=True):
 
 def store_avatar(user, image):
     from misago.users.models import Avatar
+
     image = normalize_image(image)
 
     avatars = []
@@ -43,12 +44,12 @@ def store_avatar(user, image):
             Avatar.objects.create(
                 user=user,
                 size=size,
-                image=ContentFile(image_stream.getvalue(), 'avatar'),
+                image=ContentFile(image_stream.getvalue(), "avatar"),
             )
         )
 
-    user.avatars = [{'size': a.size, 'url': a.url} for a in avatars]
-    user.save(update_fields=['avatars'])
+    user.avatars = [{"size": a.size, "url": a.url} for a in avatars]
+    user.save(update_fields=["avatars"])
 
 
 def store_new_avatar(user, image, delete_tmp=True, delete_src=True):
@@ -65,8 +66,8 @@ def store_temporary_avatar(user, image):
     if user.avatar_tmp:
         user.avatar_tmp.delete(save=False)
 
-    user.avatar_tmp = ContentFile(image_stream.getvalue(), 'avatar')
-    user.save(update_fields=['avatar_tmp'])
+    user.avatar_tmp = ContentFile(image_stream.getvalue(), "avatar")
+    user.save(update_fields=["avatar_tmp"])
 
 
 def store_original_avatar(user):
@@ -74,12 +75,14 @@ def store_original_avatar(user):
         user.avatar_src.delete(save=False)
     user.avatar_src = user.avatar_tmp
     user.avatar_tmp = None
-    user.save(update_fields=['avatar_tmp', 'avatar_src'])
+    user.save(update_fields=["avatar_tmp", "avatar_src"])
 
 
 def upload_to(instance, filename):
     spread_path = md5(get_random_string(64).encode()).hexdigest()
     secret = get_random_string(32)
-    filename_clean = '%s.png' % get_random_string(32)
+    filename_clean = "%s.png" % get_random_string(32)
 
-    return os.path.join('avatars', spread_path[:2], spread_path[2:4], secret, filename_clean)
+    return os.path.join(
+        "avatars", spread_path[:2], spread_path[2:4], secret, filename_clean
+    )

+ 25 - 25
misago/users/avatars/uploaded.py

@@ -9,8 +9,8 @@ from misago.conf import settings
 
 from . import store
 
-ALLOWED_EXTENSIONS = ('.gif', '.png', '.jpg', '.jpeg')
-ALLOWED_MIME_TYPES = ('image/gif', 'image/jpeg', 'image/png', 'image/mpo')
+ALLOWED_EXTENSIONS = (".gif", ".png", ".jpg", ".jpeg")
+ALLOWED_MIME_TYPES = ("image/gif", "image/jpeg", "image/png", "image/mpo")
 
 
 def handle_uploaded_file(request, user, uploaded_file):
@@ -60,7 +60,7 @@ def validate_dimensions(uploaded_file):
     min_size = max(settings.MISAGO_AVATARS_SIZES)
     if min(image.size) < min_size:
         message = _("Uploaded image should be at least %(size)s pixels tall and wide.")
-        raise ValidationError(message % {'size': min_size})
+        raise ValidationError(message % {"size": min_size})
 
     if image.size[0] * image.size[1] > 2000 * 3000:
         message = _("Uploaded image is too big.")
@@ -79,45 +79,42 @@ def clean_crop(image, crop):
     crop_dict = {}
     try:
         crop_dict = {
-            'x': float(crop['offset']['x']),
-            'y': float(crop['offset']['y']),
-            'zoom': float(crop['zoom']),
+            "x": float(crop["offset"]["x"]),
+            "y": float(crop["offset"]["y"]),
+            "zoom": float(crop["zoom"]),
         }
     except (KeyError, TypeError, ValueError):
         raise ValidationError(message)
 
-    if crop_dict['zoom'] < 0 or crop_dict['zoom'] > 1:
+    if crop_dict["zoom"] < 0 or crop_dict["zoom"] > 1:
         raise ValidationError(message)
 
     min_size = max(settings.MISAGO_AVATARS_SIZES)
 
     zoomed_size = (
-        round(float(image.size[0]) * crop_dict['zoom'], 2),
-        round(float(image.size[1]) * crop_dict['zoom'], 2)
+        round(float(image.size[0]) * crop_dict["zoom"], 2),
+        round(float(image.size[1]) * crop_dict["zoom"], 2),
     )
 
     if min(zoomed_size) < min_size:
         raise ValidationError(message)
 
-    crop_square = {
-        'x': crop_dict['x'] * -1,
-        'y': crop_dict['y'] * -1,
-    }
+    crop_square = {"x": crop_dict["x"] * -1, "y": crop_dict["y"] * -1}
 
-    if crop_square['x'] < 0 or crop_square['y'] < 0:
+    if crop_square["x"] < 0 or crop_square["y"] < 0:
         raise ValidationError(message)
 
-    if crop_square['x'] + min_size > zoomed_size[0]:
+    if crop_square["x"] + min_size > zoomed_size[0]:
         raise ValidationError(message)
 
-    if crop_square['y'] + min_size > zoomed_size[1]:
+    if crop_square["y"] + min_size > zoomed_size[1]:
         raise ValidationError(message)
 
     return crop_dict
 
 
 def crop_source_image(user, source, crop):
-    if source == 'tmp':
+    if source == "tmp":
         image = Image.open(user.avatar_tmp)
     else:
         image = Image.open(user.avatar_src)
@@ -127,14 +124,17 @@ def crop_source_image(user, source, crop):
     if image.size[0] == min_size and image.size[0] == image.size[1]:
         cropped_image = image
     else:
-        upscale = 1.0 / crop['zoom']
-        cropped_image = image.crop((
-            int(round(crop['x'] * upscale * -1, 0)), int(round(crop['y'] * upscale * -1, 0)),
-            int(round((crop['x'] - min_size) * upscale * -1, 0)),
-            int(round((crop['y'] - min_size) * upscale * -1, 0)),
-        ))
-
-    if source == 'tmp':
+        upscale = 1.0 / crop["zoom"]
+        cropped_image = image.crop(
+            (
+                int(round(crop["x"] * upscale * -1, 0)),
+                int(round(crop["y"] * upscale * -1, 0)),
+                int(round((crop["x"] - min_size) * upscale * -1, 0)),
+                int(round((crop["y"] - min_size) * upscale * -1, 0)),
+            )
+        )
+
+    if source == "tmp":
         store.store_new_avatar(user, cropped_image, delete_tmp=False)
         store.store_original_avatar(user)
     else:

+ 17 - 19
misago/users/bans.py

@@ -12,7 +12,7 @@ from django.utils.dateparse import parse_datetime
 from .constants import BANS_CACHE
 from .models import Ban, BanCache
 
-CACHE_SESSION_KEY = 'misago_ip_check'
+CACHE_SESSION_KEY = "misago_ip_check"
 
 
 def get_username_ban(username, registration_only=False):
@@ -64,9 +64,7 @@ def _set_user_ban_cache(user, cache_versions):
 
     try:
         user_ban = Ban.objects.get_ban(
-            username=user.username,
-            email=user.email,
-            registration_only=False,
+            username=user.username, email=user.email, registration_only=False
         )
 
         ban_cache.ban = user_ban
@@ -92,7 +90,7 @@ def get_request_ip_ban(request):
     """
     session_ban_cache = _get_session_bancache(request)
     if session_ban_cache:
-        if session_ban_cache['is_banned']:
+        if session_ban_cache["is_banned"]:
             return session_ban_cache
         else:
             return False
@@ -100,21 +98,21 @@ def get_request_ip_ban(request):
     found_ban = get_ip_ban(request.user_ip)
 
     ban_cache = request.session[CACHE_SESSION_KEY] = {
-        'version': request.cache_versions[BANS_CACHE],
-        'ip': request.user_ip,
+        "version": request.cache_versions[BANS_CACHE],
+        "ip": request.user_ip,
     }
 
     if found_ban:
         if found_ban.expires_on:
-            ban_cache['expires_on'] = found_ban.expires_on.isoformat()
+            ban_cache["expires_on"] = found_ban.expires_on.isoformat()
         else:
-            ban_cache['expires_on'] = None
+            ban_cache["expires_on"] = None
 
-        ban_cache.update({'is_banned': True, 'message': found_ban.user_message})
+        ban_cache.update({"is_banned": True, "message": found_ban.user_message})
         request.session[CACHE_SESSION_KEY] = ban_cache
         return _hydrate_session_cache(request.session[CACHE_SESSION_KEY])
     else:
-        ban_cache['is_banned'] = False
+        ban_cache["is_banned"] = False
         request.session[CACHE_SESSION_KEY] = ban_cache
         return None
 
@@ -123,12 +121,12 @@ def _get_session_bancache(request):
     try:
         ban_cache = request.session[CACHE_SESSION_KEY]
         ban_cache = _hydrate_session_cache(ban_cache)
-        if ban_cache['ip'] != request.user_ip:
+        if ban_cache["ip"] != request.user_ip:
             return None
-        if ban_cache['version'] != request.cache_versions[BANS_CACHE]:
+        if ban_cache["version"] != request.cache_versions[BANS_CACHE]:
             return None
-        if ban_cache.get('expires_on'):
-            if ban_cache['expires_on'] < timezone.today():
+        if ban_cache.get("expires_on"):
+            if ban_cache["expires_on"] < timezone.today():
                 return None
         return ban_cache
     except KeyError:
@@ -138,8 +136,8 @@ def _get_session_bancache(request):
 def _hydrate_session_cache(ban_cache):
     hydrated = ban_cache.copy()
 
-    if hydrated.get('expires_on'):
-        hydrated['expires_on'] = parse_datetime(hydrated['expires_on'])
+    if hydrated.get("expires_on"):
+        hydrated["expires_on"] = parse_datetime(hydrated["expires_on"])
 
     return hydrated
 
@@ -153,7 +151,7 @@ def ban_user(user, user_message=None, staff_message=None, length=None, expires_o
         banned_value=user.username.lower(),
         user_message=user_message,
         staff_message=staff_message,
-        expires_on=expires_on
+        expires_on=expires_on,
     )
     Ban.objects.invalidate_cache()
     return ban
@@ -168,7 +166,7 @@ def ban_ip(ip, user_message=None, staff_message=None, length=None, expires_on=No
         banned_value=ip,
         user_message=user_message,
         staff_message=staff_message,
-        expires_on=expires_on
+        expires_on=expires_on,
     )
     Ban.objects.invalidate_cache()
     return ban

+ 8 - 12
misago/users/captcha.py

@@ -6,24 +6,24 @@ from django.utils.translation import gettext as _
 
 def recaptcha_test(request):
     r = requests.post(
-        'https://www.google.com/recaptcha/api/siteverify',
+        "https://www.google.com/recaptcha/api/siteverify",
         data={
-            'secret': request.settings.recaptcha_secret_key,
-            'response': request.data.get('captcha'),
-            'remoteip': request.user_ip
-        }
+            "secret": request.settings.recaptcha_secret_key,
+            "response": request.data.get("captcha"),
+            "remoteip": request.user_ip,
+        },
     )
 
     if r.status_code == 200:
         response_json = r.json()
-        if not response_json.get('success'):
+        if not response_json.get("success"):
             raise ValidationError(_("Please try again."))
     else:
         raise ValidationError(_("Failed to contact reCAPTCHA API."))
 
 
 def qacaptcha_test(request):
-    answer = request.data.get('captcha', '').lower().strip()
+    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."))
@@ -38,11 +38,7 @@ def nocaptcha_test(request):
     return  # no captcha means no validation
 
 
-CAPTCHA_TESTS = {
-    're': recaptcha_test,
-    'qa': qacaptcha_test,
-    'no': nocaptcha_test,
-}
+CAPTCHA_TESTS = {"re": recaptcha_test, "qa": qacaptcha_test, "no": nocaptcha_test}
 
 
 def test_request(request):

+ 1 - 1
misago/users/constants.py

@@ -1 +1 @@
-BANS_CACHE = "bans"
+BANS_CACHE = "bans"

+ 22 - 20
misago/users/context_processors.py

@@ -6,24 +6,26 @@ from .serializers import AnonymousUserSerializer, AuthenticatedUserSerializer
 
 def user_links(request):
     if request.include_frontend_context:
-        request.frontend_context.update({
-            'REQUEST_ACTIVATION_URL': reverse('misago:request-activation'),
-            'FORGOTTEN_PASSWORD_URL': reverse('misago:forgotten-password'),
-            'BANNED_URL': reverse('misago:banned'),
-            'USERCP_URL': reverse('misago:options'),
-            'USERS_LIST_URL': reverse('misago:users'),
-            'AUTH_API': reverse('misago:api:auth'),
-            'AUTH_CRITERIA_API': reverse('misago:api:auth-criteria'),
-            'USERS_API': reverse('misago:api:user-list'),
-            'CAPTCHA_API': reverse('misago:api:captcha-question'),
-            'USERNAME_CHANGES_API': reverse('misago:api:usernamechange-list'),
-            'MENTION_API': reverse('misago:api:mention-suggestions'),
-        })
+        request.frontend_context.update(
+            {
+                "REQUEST_ACTIVATION_URL": reverse("misago:request-activation"),
+                "FORGOTTEN_PASSWORD_URL": reverse("misago:forgotten-password"),
+                "BANNED_URL": reverse("misago:banned"),
+                "USERCP_URL": reverse("misago:options"),
+                "USERS_LIST_URL": reverse("misago:users"),
+                "AUTH_API": reverse("misago:api:auth"),
+                "AUTH_CRITERIA_API": reverse("misago:api:auth-criteria"),
+                "USERS_API": reverse("misago:api:user-list"),
+                "CAPTCHA_API": reverse("misago:api:captcha-question"),
+                "USERNAME_CHANGES_API": reverse("misago:api:usernamechange-list"),
+                "MENTION_API": reverse("misago:api:mention-suggestions"),
+            }
+        )
 
     return {
-        'USERCP_URL': usercp.get_default_link(),
-        'USERS_LIST_URL': users_list.get_default_link(),
-        'USER_PROFILE_URL': user_profile.get_default_link(),
+        "USERCP_URL": usercp.get_default_link(),
+        "USERS_LIST_URL": users_list.get_default_link(),
+        "USER_PROFILE_URL": user_profile.get_default_link(),
     }
 
 
@@ -31,9 +33,9 @@ def preload_user_json(request):
     if not request.include_frontend_context:
         return {}
 
-    request.frontend_context.update({
-        'isAuthenticated': bool(request.user.is_authenticated),
-    })
+    request.frontend_context.update(
+        {"isAuthenticated": bool(request.user.is_authenticated)}
+    )
 
     if request.user.is_authenticated:
         serializer = AuthenticatedUserSerializer
@@ -41,6 +43,6 @@ def preload_user_json(request):
         serializer = AnonymousUserSerializer
 
     serialized_user = serializer(request.user, context={"acl": request.user_acl}).data
-    request.frontend_context.update({'user': serialized_user})
+    request.frontend_context.update({"user": serialized_user})
 
     return {}

+ 15 - 11
misago/users/credentialchange.py

@@ -10,13 +10,13 @@ from django.utils.encoding import force_bytes
 
 
 def store_new_credential(request, credential_type, credential_value):
-    credential_key = 'new_credential_%s' % credential_type
+    credential_key = "new_credential_%s" % credential_type
     token = _make_change_token(request.user, credential_type)
 
     request.session[credential_key] = {
-        'user_pk': request.user.pk,
-        'credential': credential_value,
-        'token': token,
+        "user_pk": request.user.pk,
+        "credential": credential_value,
+        "token": token,
     }
 
     return token
@@ -24,27 +24,31 @@ def store_new_credential(request, credential_type, credential_value):
 
 def read_new_credential(request, credential_type, link_token):
     try:
-        credential_key = 'new_credential_%s' % credential_type
+        credential_key = "new_credential_%s" % credential_type
         new_credential = request.session.pop(credential_key)
     except KeyError:
         return None
 
-    if new_credential['user_pk'] != request.user.pk:
+    if new_credential["user_pk"] != request.user.pk:
         return None
 
     current_token = _make_change_token(request.user, credential_type)
     if link_token != current_token:
         return None
-    if new_credential['token'] != current_token:
+    if new_credential["token"] != current_token:
         return None
 
-    return new_credential['credential']
+    return new_credential["credential"]
 
 
 def _make_change_token(user, token_type):
     seeds = (
-        user.pk, user.email, user.password, user.last_login.replace(microsecond=0, tzinfo=None),
-        settings.SECRET_KEY, str(token_type)
+        user.pk,
+        user.email,
+        user.password,
+        user.last_login.replace(microsecond=0, tzinfo=None),
+        settings.SECRET_KEY,
+        str(token_type),
     )
 
-    return sha256(force_bytes('+'.join([str(s) for s in seeds]))).hexdigest()
+    return sha256(force_bytes("+".join([str(s) for s in seeds]))).hexdigest()

+ 3 - 4
misago/users/datadownloads/__init__.py

@@ -21,9 +21,7 @@ def request_user_data_download(user, requester=None):
     requester = requester or user
 
     return DataDownload.objects.create(
-        user=user,
-        requester=requester,
-        requester_name=requester.username,
+        user=user, requester=requester, requester_name=requester.username
     )
 
 
@@ -35,7 +33,8 @@ def prepare_user_data_download(download, logger=None):
             archive_user_data.send(user, archive=archive)
             download.status = DataDownload.STATUS_READY
             download.expires_on = timezone.now() + timedelta(
-                hours=settings.MISAGO_USER_DATA_DOWNLOADS_EXPIRE_IN_HOURS)
+                hours=settings.MISAGO_USER_DATA_DOWNLOADS_EXPIRE_IN_HOURS
+            )
             download.file = archive.get_file()
             download.save()
             # todo: send an e-mail with download link

+ 11 - 11
misago/users/datadownloads/dataarchive.py

@@ -53,13 +53,13 @@ class DataArchive(object):
             shutil.rmtree(self.tmp_dir_path)
             self.tmp_dir_path = None
             self.data_dir_path = None
-            
+
     def get_file(self):
         file_name = get_tmp_filename(self.user)
         file_path = os.path.join(self.working_dir_path, file_name)
 
-        self.file_path = shutil.make_archive(file_path, 'zip', self.tmp_dir_path)
-        self.file = open(self.file_path, 'rb')
+        self.file_path = shutil.make_archive(file_path, "zip", self.tmp_dir_path)
+        self.file = open(self.file_path, "rb")
 
         return File(self.file)
 
@@ -75,8 +75,8 @@ class DataArchive(object):
     def add_text(self, name, value, date=None, directory=None):
         clean_filename = slugify(str(name))
         file_dir_path = self.make_final_path(date=date, directory=directory)
-        file_path = os.path.join(file_dir_path, '%s.txt' % clean_filename)
-        with open(file_path, 'w') as fp:
+        file_path = os.path.join(file_dir_path, "%s.txt" % clean_filename)
+        with open(file_path, "w") as fp:
             fp.write(str(value))
             return file_path
 
@@ -84,7 +84,7 @@ class DataArchive(object):
         text_lines = []
         for key, value in value.items():
             text_lines.append("%s: %s" % (key, value))
-        text = '\n'.join(text_lines)
+        text = "\n".join(text_lines)
         return self.add_text(name, text, date=date, directory=directory)
 
     def add_model_file(self, model_file, prefix=None, date=None, directory=None):
@@ -102,7 +102,7 @@ class DataArchive(object):
             clean_filename = trim_long_filename(filename)
             target_path = os.path.join(target_dir_path, clean_filename)
 
-        with open(target_path, 'wb') as fp:
+        with open(target_path, "wb") as fp:
             for chunk in model_file.chunks():
                 fp.write(chunk)
 
@@ -117,7 +117,7 @@ class DataArchive(object):
 
         if date:
             final_path = data_dir_path
-            path_items = [date.strftime('%Y'), date.strftime('%m'), date.strftime('%d')]
+            path_items = [date.strftime("%Y"), date.strftime("%m"), date.strftime("%d")]
             for path_item in path_items:
                 final_path = os.path.join(final_path, str(path_item))
                 if not os.path.isdir(final_path):
@@ -136,11 +136,11 @@ class DataArchive(object):
 def get_tmp_filename(user):
     filename_bits = [
         user.slug,
-        timezone.now().strftime('%Y%m%d-%H%M%S'),
+        timezone.now().strftime("%Y%m%d-%H%M%S"),
         get_random_string(6),
     ]
 
-    return '-'.join(filename_bits)
+    return "-".join(filename_bits)
 
 
 def trim_long_filename(filename):
@@ -151,4 +151,4 @@ def trim_long_filename(filename):
 
     name, extension = os.path.splitext(filename)
     name_len = FILENAME_MAX_LEN - len(extension)
-    return '%s%s' % (name[:name_len], extension)
+    return "%s%s" % (name[:name_len], extension)

+ 3 - 3
misago/users/decorators.py

@@ -22,7 +22,7 @@ def deny_authenticated(f):
 def deny_guests(f):
     def decorator(request, *args, **kwargs):
         if request.user.is_anonymous:
-            if request.GET.get('ref') == 'login':
+            if request.GET.get("ref") == "login":
                 return redirect(settings.LOGIN_REDIRECT_URL)
             raise PermissionDenied(_("You have to sign in to access this page."))
         else:
@@ -37,8 +37,8 @@ def deny_banned_ips(f):
         if ban:
             hydrated_ban = Ban(
                 check_type=Ban.IP,
-                user_message=ban['message'],
-                expires_on=ban['expires_on'],
+                user_message=ban["message"],
+                expires_on=ban["expires_on"],
             )
             raise Banned(hydrated_ban)
         else:

+ 40 - 19
misago/users/djangoadmin.py

@@ -16,26 +16,45 @@ class UserAdminModel(ModelAdmin):
     """
 
     list_display = (
-        'username', 'email', 'rank', 'is_staff', 'is_superuser', 'get_edit_from_misago_url',
+        "username",
+        "email",
+        "rank",
+        "is_staff",
+        "is_superuser",
+        "get_edit_from_misago_url",
     )
-    search_fields = ('username', 'email')
-    list_filter = ('groups', 'rank', 'is_staff', 'is_superuser')
+    search_fields = ("username", "email")
+    list_filter = ("groups", "rank", "is_staff", "is_superuser")
 
     actions = None
     readonly_fields = (
-        'username', 'email', 'joined_on', 'last_login', 'rank', 'is_staff', 'is_superuser',
-        'get_edit_from_misago_url',
+        "username",
+        "email",
+        "joined_on",
+        "last_login",
+        "rank",
+        "is_staff",
+        "is_superuser",
+        "get_edit_from_misago_url",
+    )
+    fieldsets = (
+        (
+            _("Misago user data"),
+            {
+                "fields": (
+                    "username",
+                    "email",
+                    "joined_on",
+                    "last_login",
+                    "rank",
+                    "is_staff",
+                    "is_superuser",
+                    "get_edit_from_misago_url",
+                )
+            },
+        ),
+        (_("Edit permissions and groups"), {"fields": ("groups", "user_permissions")}),
     )
-    fieldsets = ((
-        _("Misago user data"), {
-            'fields': (
-                'username', 'email', 'joined_on', 'last_login', 'rank', 'is_staff', 'is_superuser',
-                'get_edit_from_misago_url',
-            )
-        },
-    ), (_("Edit permissions and groups"), {
-        'fields': ('groups', 'user_permissions', )
-    }, ), )
 
     def has_add_permission(self, request):
         return False
@@ -47,11 +66,13 @@ class UserAdminModel(ModelAdmin):
         return format_html(
             '<a href="{link}" class="{cls}" target="blank">{text}</a>',
             link=reverse(
-                viewname='misago:admin:users:accounts:edit',
-                kwargs={'pk': user_instance.pk},
+                viewname="misago:admin:users:accounts:edit",
+                kwargs={"pk": user_instance.pk},
             ),
-            cls='changelink',
+            cls="changelink",
             text=_("Edit"),
         )
 
-    get_edit_from_misago_url.short_description = _("Edit the user from Misago admin panel")
+    get_edit_from_misago_url.short_description = _(
+        "Edit the user from Misago admin panel"
+    )

+ 193 - 210
misago/users/forms/admin.py

@@ -24,35 +24,35 @@ class UserBaseForm(forms.ModelForm):
 
     class Meta:
         model = UserModel
-        fields = ['username', 'email', 'title']
+        fields = ["username", "email", "title"]
 
     def __init__(self, *args, **kwargs):
-        self.request = kwargs.pop('request')
+        self.request = kwargs.pop("request")
         self.settings = self.request.settings
 
         super().__init__(*args, **kwargs)
 
     def clean_username(self):
-        data = self.cleaned_data['username']
+        data = self.cleaned_data["username"]
         validate_username(self.settings, data, exclude=self.instance)
         return data
 
     def clean_email(self):
-        data = self.cleaned_data['email']
+        data = self.cleaned_data["email"]
         validate_email(data, exclude=self.instance)
         return data
 
     def clean_new_password(self):
-        data = self.cleaned_data['new_password']
+        data = self.cleaned_data["new_password"]
         if data:
             validate_password(data, user=self.instance)
         return data
 
     def clean_roles(self):
-        data = self.cleaned_data['roles']
+        data = self.cleaned_data["roles"]
 
         for role in data:
-            if role.special_role == 'authenticated':
+            if role.special_role == "authenticated":
                 break
         else:
             message = _('All registered members must have "Member" role.')
@@ -63,14 +63,12 @@ class UserBaseForm(forms.ModelForm):
 
 class NewUserForm(UserBaseForm):
     new_password = forms.CharField(
-        label=_("Password"),
-        strip=False,
-        widget=forms.PasswordInput,
+        label=_("Password"), strip=False, widget=forms.PasswordInput
     )
 
     class Meta:
         model = UserModel
-        fields = ['username', 'email', 'title']
+        fields = ["username", "email", "title"]
 
 
 class EditUserForm(UserBaseForm):
@@ -89,7 +87,7 @@ class EditUserForm(UserBaseForm):
         "can also change other members admin levels."
     )
 
-    IS_ACTIVE_LABEL = _('Is active')
+    IS_ACTIVE_LABEL = _("Is active")
     IS_ACTIVE_HELP_TEXT = _(
         "Designates whether this user should be treated as active. "
         "Turning this off is non-destructible way to remove user accounts."
@@ -114,7 +112,7 @@ class EditUserForm(UserBaseForm):
             "Setting this to yes will stop user from changing "
             "his/her avatar, and will reset his/her avatar to "
             "procedurally generated one."
-        )
+        ),
     )
     avatar_lock_user_message = forms.CharField(
         label=_("User message"),
@@ -122,8 +120,8 @@ class EditUserForm(UserBaseForm):
             "Optional message for user explaining "
             "why he/she is banned form changing avatar."
         ),
-        widget=forms.Textarea(attrs={'rows': 3}),
-        required=False
+        widget=forms.Textarea(attrs={"rows": 3}),
+        required=False,
     )
     avatar_lock_staff_message = forms.CharField(
         label=_("Staff message"),
@@ -131,13 +129,13 @@ class EditUserForm(UserBaseForm):
             "Optional message for forum team members explaining "
             "why user is banned form changing avatar."
         ),
-        widget=forms.Textarea(attrs={'rows': 3}),
-        required=False
+        widget=forms.Textarea(attrs={"rows": 3}),
+        required=False,
     )
 
     signature = forms.CharField(
         label=_("Signature contents"),
-        widget=forms.Textarea(attrs={'rows': 3}),
+        widget=forms.Textarea(attrs={"rows": 3}),
         required=False,
     )
     is_signature_locked = YesNoSwitch(
@@ -145,19 +143,23 @@ class EditUserForm(UserBaseForm):
         help_text=_(
             "Setting this to yes will stop user from "
             "making changes to his/her signature."
-        )
+        ),
     )
     signature_lock_user_message = forms.CharField(
         label=_("User message"),
-        help_text=_("Optional message to user explaining why his/hers signature is locked."),
-        widget=forms.Textarea(attrs={'rows': 3}),
-        required=False
+        help_text=_(
+            "Optional message to user explaining why his/hers signature is locked."
+        ),
+        widget=forms.Textarea(attrs={"rows": 3}),
+        required=False,
     )
     signature_lock_staff_message = forms.CharField(
         label=_("Staff message"),
-        help_text=_("Optional message to team members explaining why user signature is locked."),
-        widget=forms.Textarea(attrs={'rows': 3}),
-        required=False
+        help_text=_(
+            "Optional message to team members explaining why user signature is locked."
+        ),
+        widget=forms.Textarea(attrs={"rows": 3}),
+        required=False,
     )
 
     is_hiding_presence = YesNoSwitch(label=_("Hides presence"))
@@ -165,7 +167,7 @@ class EditUserForm(UserBaseForm):
     limits_private_thread_invites_to = forms.TypedChoiceField(
         label=_("Who can add user to private threads"),
         coerce=int,
-        choices=UserModel.LIMIT_INVITES_TO_CHOICES
+        choices=UserModel.LIMIT_INVITES_TO_CHOICES,
     )
 
     subscribe_to_started_threads = forms.TypedChoiceField(
@@ -178,20 +180,20 @@ class EditUserForm(UserBaseForm):
     class Meta:
         model = UserModel
         fields = [
-            'username',
-            'email',
-            'title',
-            'is_avatar_locked',
-            'avatar_lock_user_message',
-            'avatar_lock_staff_message',
-            'signature',
-            'is_signature_locked',
-            'is_hiding_presence',
-            'limits_private_thread_invites_to',
-            'signature_lock_user_message',
-            'signature_lock_staff_message',
-            'subscribe_to_started_threads',
-            'subscribe_to_replied_threads',
+            "username",
+            "email",
+            "title",
+            "is_avatar_locked",
+            "avatar_lock_user_message",
+            "avatar_lock_staff_message",
+            "signature",
+            "is_signature_locked",
+            "is_hiding_presence",
+            "limits_private_thread_invites_to",
+            "signature_lock_user_message",
+            "signature_lock_staff_message",
+            "subscribe_to_started_threads",
+            "subscribe_to_replied_threads",
         ]
 
     def __init__(self, *args, **kwargs):
@@ -201,19 +203,16 @@ class EditUserForm(UserBaseForm):
     def get_profile_fields_groups(self):
         profile_fields_groups = []
         for group in self._profile_fields_groups:
-            fields_group = {
-                'name': group['name'],
-                'fields': [],
-            }
+            fields_group = {"name": group["name"], "fields": []}
 
-            for fieldname in group['fields']:
-                fields_group['fields'].append(self[fieldname])
+            for fieldname in group["fields"]:
+                fields_group["fields"].append(self[fieldname])
 
             profile_fields_groups.append(fields_group)
         return profile_fields_groups
 
     def clean_signature(self):
-        data = self.cleaned_data['signature']
+        data = self.cleaned_data["signature"]
 
         length_limit = self.settings.signature_length_max
         if len(data) > length_limit:
@@ -222,7 +221,8 @@ class EditUserForm(UserBaseForm):
                     "Signature can't be longer than %(limit)s character.",
                     "Signature can't be longer than %(limit)s characters.",
                     length_limit,
-                ) % {'limit': length_limit}
+                )
+                % {"limit": length_limit}
             )
 
         return data
@@ -235,66 +235,70 @@ class EditUserForm(UserBaseForm):
 def UserFormFactory(FormType, instance):
     extra_fields = {}
 
-    extra_fields['rank'] = forms.ModelChoiceField(
+    extra_fields["rank"] = forms.ModelChoiceField(
         label=_("Rank"),
         help_text=_(
             "Ranks are used to group and distinguish users. They are "
             "also used to add permissions to groups of users."
         ),
-        queryset=Rank.objects.order_by('name'),
-        initial=instance.rank
+        queryset=Rank.objects.order_by("name"),
+        initial=instance.rank,
     )
 
-    roles = Role.objects.order_by('name')
+    roles = Role.objects.order_by("name")
 
-    extra_fields['roles'] = forms.ModelMultipleChoiceField(
+    extra_fields["roles"] = forms.ModelMultipleChoiceField(
         label=_("Roles"),
-        help_text=_('Individual roles of this user. All users must have "member" role.'),
+        help_text=_(
+            'Individual roles of this user. All users must have "member" role.'
+        ),
         queryset=roles,
         initial=instance.roles.all() if instance.pk else None,
-        widget=forms.CheckboxSelectMultiple
+        widget=forms.CheckboxSelectMultiple,
     )
 
-    return type('UserFormFinal', (FormType, ), extra_fields)
+    return type("UserFormFinal", (FormType,), extra_fields)
 
 
 def StaffFlagUserFormFactory(FormType, instance):
     staff_fields = {
-        'is_staff': YesNoSwitch(
+        "is_staff": YesNoSwitch(
             label=EditUserForm.IS_STAFF_LABEL,
             help_text=EditUserForm.IS_STAFF_HELP_TEXT,
-            initial=instance.is_staff
+            initial=instance.is_staff,
         ),
-        'is_superuser': YesNoSwitch(
+        "is_superuser": YesNoSwitch(
             label=EditUserForm.IS_SUPERUSER_LABEL,
             help_text=EditUserForm.IS_SUPERUSER_HELP_TEXT,
-            initial=instance.is_superuser
+            initial=instance.is_superuser,
         ),
     }
 
-    return type('StaffUserForm', (FormType, ), staff_fields)
+    return type("StaffUserForm", (FormType,), staff_fields)
 
 
 def UserIsActiveFormFactory(FormType, instance):
     is_active_fields = {
-        'is_active': YesNoSwitch(
+        "is_active": YesNoSwitch(
             label=EditUserForm.IS_ACTIVE_LABEL,
             help_text=EditUserForm.IS_ACTIVE_HELP_TEXT,
-            initial=instance.is_active
+            initial=instance.is_active,
         ),
-        'is_active_staff_message': forms.CharField(
+        "is_active_staff_message": forms.CharField(
             label=EditUserForm.IS_ACTIVE_STAFF_MESSAGE_LABEL,
             help_text=EditUserForm.IS_ACTIVE_STAFF_MESSAGE_HELP_TEXT,
             initial=instance.is_active_staff_message,
-            widget=forms.Textarea(attrs={'rows': 3}),
-            required=False
+            widget=forms.Textarea(attrs={"rows": 3}),
+            required=False,
         ),
     }
 
-    return type('UserIsActiveForm', (FormType, ), is_active_fields)
+    return type("UserIsActiveForm", (FormType,), is_active_fields)
 
 
-def EditUserFormFactory(FormType, instance, add_is_active_fields=False, add_admin_fields=False):
+def EditUserFormFactory(
+    FormType, instance, add_is_active_fields=False, add_admin_fields=False
+):
     FormType = UserFormFactory(FormType, instance)
 
     if add_is_active_fields:
@@ -316,33 +320,36 @@ class BaseSearchUsersForm(forms.Form):
     is_deleting_account = YesNoSwitch(label=_("Deleting their accounts"))
 
     def filter_queryset(self, criteria, queryset):
-        if criteria.get('username'):
-            queryset = queryset.filter(slug__startswith=criteria.get('username').lower())
+        if criteria.get("username"):
+            queryset = queryset.filter(
+                slug__startswith=criteria.get("username").lower()
+            )
 
-        if criteria.get('email'):
-            queryset = queryset.filter(email__istartswith=criteria.get('email'))
+        if criteria.get("email"):
+            queryset = queryset.filter(email__istartswith=criteria.get("email"))
 
-        if criteria.get('rank'):
-            queryset = queryset.filter(rank_id=criteria.get('rank'))
+        if criteria.get("rank"):
+            queryset = queryset.filter(rank_id=criteria.get("rank"))
 
-        if criteria.get('role'):
-            queryset = queryset.filter(roles__id=criteria.get('role'))
+        if criteria.get("role"):
+            queryset = queryset.filter(roles__id=criteria.get("role"))
 
-        if criteria.get('inactive'):
+        if criteria.get("inactive"):
             queryset = queryset.filter(requires_activation__gt=0)
 
-        if criteria.get('disabled'):
+        if criteria.get("disabled"):
             queryset = queryset.filter(is_active=False)
 
-        if criteria.get('is_staff'):
+        if criteria.get("is_staff"):
             queryset = queryset.filter(is_staff=True)
 
-        if criteria.get('is_deleting_account'):
+        if criteria.get("is_deleting_account"):
             queryset = queryset.filter(is_deleting_account=True)
 
-        if criteria.get('profilefields', '').strip():
+        if criteria.get("profilefields", "").strip():
             queryset = profilefields.search_users(
-                criteria.get('profilefields').strip(), queryset)
+                criteria.get("profilefields").strip(), queryset
+            )
 
         return queryset
 
@@ -353,30 +360,24 @@ def create_search_users_form():
     and makes those ranks and roles typed choice fields that play nice
     with passing values via GET
     """
-    ranks_choices = [('', _("All ranks"))]
-    for rank in Rank.objects.order_by('name').iterator():
+    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 = [("", _("All roles"))]
+    for role in Role.objects.order_by("name").iterator():
         roles_choices.append((role.pk, role.name))
 
     extra_fields = {
-        'rank': forms.TypedChoiceField(
-            label=_("Has rank"),
-            coerce=int,
-            required=False,
-            choices=ranks_choices,
+        "rank": forms.TypedChoiceField(
+            label=_("Has rank"), coerce=int, required=False, choices=ranks_choices
+        ),
+        "role": forms.TypedChoiceField(
+            label=_("Has role"), coerce=int, required=False, choices=roles_choices
         ),
-        'role': forms.TypedChoiceField(
-            label=_("Has role"),
-            coerce=int,
-            required=False,
-            choices=roles_choices,
-        )
     }
 
-    return type('SearchUsersForm', (BaseSearchUsersForm, ), extra_fields)
+    return type("SearchUsersForm", (BaseSearchUsersForm,), extra_fields)
 
 
 class RankForm(forms.ModelForm):
@@ -384,39 +385,41 @@ class RankForm(forms.ModelForm):
         label=_("Name"),
         validators=[validate_sluggable()],
         help_text=_(
-            'Short and descriptive name of all users with this rank. '
+            "Short and descriptive name of all users with this rank. "
             '"The Team" or "Game Masters" are good examples.'
-        )
+        ),
     )
     title = forms.CharField(
         label=_("User title"),
         required=False,
         help_text=_(
-            'Optional, singular version of rank name displayed by user names. '
+            "Optional, singular version of rank name displayed by user names. "
             'For example "GM" or "Dev".'
-        )
+        ),
     )
     description = forms.CharField(
         label=_("Description"),
         max_length=2048,
         required=False,
-        widget=forms.Textarea(attrs={'rows': 3}),
+        widget=forms.Textarea(attrs={"rows": 3}),
         help_text=_(
             "Optional description explaining function or status of "
             "members distincted with this rank."
-        )
+        ),
     )
     roles = forms.ModelMultipleChoiceField(
         label=_("User roles"),
         widget=forms.CheckboxSelectMultiple,
-        queryset=Role.objects.order_by('name'),
+        queryset=Role.objects.order_by("name"),
         required=False,
-        help_text=_("Rank can give additional roles to users with it.")
+        help_text=_("Rank can give additional roles to users with it."),
     )
     css_class = forms.CharField(
         label=_("CSS class"),
         required=False,
-        help_text=_("Optional css class added to content belonging to this rank owner.")
+        help_text=_(
+            "Optional css class added to content belonging to this rank owner."
+        ),
     )
     is_tab = forms.BooleanField(
         label=_("Give rank dedicated tab on users list"),
@@ -424,22 +427,15 @@ class RankForm(forms.ModelForm):
         help_text=_(
             "Selecting this option will make users with this rank easily discoverable "
             "by others through dedicated page on forum users list."
-        )
+        ),
     )
 
     class Meta:
         model = Rank
-        fields = [
-            'name',
-            'description',
-            'css_class',
-            'title',
-            'roles',
-            'is_tab',
-        ]
+        fields = ["name", "description", "css_class", "title", "roles", "is_tab"]
 
     def clean_name(self):
-        data = self.cleaned_data['name']
+        data = self.cleaned_data["name"]
         self.instance.set_name(data)
 
         unique_qs = Rank.objects.filter(slug=self.instance.slug)
@@ -454,61 +450,57 @@ class RankForm(forms.ModelForm):
 
 class BanUsersForm(forms.Form):
     ban_type = forms.MultipleChoiceField(
-        label=_("Values to ban"),
-        widget=forms.CheckboxSelectMultiple,
-        choices=[]
+        label=_("Values to ban"), widget=forms.CheckboxSelectMultiple, choices=[]
     )
     user_message = forms.CharField(
         label=_("User message"),
         required=False,
         max_length=1000,
         help_text=_("Optional message displayed to users instead of default one."),
-        widget=forms.Textarea(attrs={'rows': 3}),
+        widget=forms.Textarea(attrs={"rows": 3}),
         error_messages={
-            'max_length': _("Message can't be longer than 1000 characters."),
-        }
+            "max_length": _("Message can't be longer than 1000 characters.")
+        },
     )
     staff_message = forms.CharField(
         label=_("Team message"),
         required=False,
         max_length=1000,
         help_text=_("Optional ban message for moderators and administrators."),
-        widget=forms.Textarea(attrs={'rows': 3}),
+        widget=forms.Textarea(attrs={"rows": 3}),
         error_messages={
-            'max_length': _("Message can't be longer than 1000 characters."),
-        }
+            "max_length": _("Message can't be longer than 1000 characters.")
+        },
     )
     expires_on = IsoDateTimeField(
         label=_("Expires on"),
         required=False,
-        help_text=_("Leave this field empty for set bans to never expire.")
+        help_text=_("Leave this field empty for set bans to never expire."),
     )
 
     def __init__(self, *args, **kwargs):
-        users = kwargs.pop('users')
+        users = kwargs.pop("users")
 
         super().__init__(*args, **kwargs)
 
-        self.fields['ban_type'].choices = [
-            ('usernames', _('Usernames')),
-            ('emails', _('E-mails')),
-            ('domains', _('E-mail domains')),
+        self.fields["ban_type"].choices = [
+            ("usernames", _("Usernames")),
+            ("emails", _("E-mails")),
+            ("domains", _("E-mail domains")),
         ]
 
         enable_ip_bans = list(filter(None, [u.joined_from_ip for u in users]))
         if enable_ip_bans:
-            self.fields['ban_type'].choices += [
-                ('ip', _('IP addresses')),
-                ('ip_first', _('First segment of IP addresses')),
-                ('ip_two', _('First two segments of IP addresses')),
+            self.fields["ban_type"].choices += [
+                ("ip", _("IP addresses")),
+                ("ip_first", _("First segment of IP addresses")),
+                ("ip_two", _("First two segments of IP addresses")),
             ]
 
 
 class BanForm(forms.ModelForm):
     check_type = forms.TypedChoiceField(
-        label=_("Check type"),
-        coerce=int,
-        choices=Ban.CHOICES,
+        label=_("Check type"), coerce=int, choices=Ban.CHOICES
     )
     registration_only = YesNoSwitch(
         label=_("Restrict this ban to registrations"),
@@ -522,57 +514,57 @@ class BanForm(forms.ModelForm):
         label=_("Banned value"),
         max_length=250,
         help_text=_(
-            'This value is case-insensitive and accepts asterisk (*) '
-            'for rought matches. For example, making IP ban for value '
+            "This value is case-insensitive and accepts asterisk (*) "
+            "for rought matches. For example, making IP ban for value "
             '"83.*" will ban all IP addresses beginning with "83.".'
         ),
         error_messages={
-            'max_length': _("Banned value can't be longer than 250 characters."),
-        }
+            "max_length": _("Banned value can't be longer than 250 characters.")
+        },
     )
     user_message = forms.CharField(
         label=_("User message"),
         required=False,
         max_length=1000,
         help_text=_("Optional message displayed to user instead of default one."),
-        widget=forms.Textarea(attrs={'rows': 3}),
+        widget=forms.Textarea(attrs={"rows": 3}),
         error_messages={
-            'max_length': _("Message can't be longer than 1000 characters."),
-        }
+            "max_length": _("Message can't be longer than 1000 characters.")
+        },
     )
     staff_message = forms.CharField(
         label=_("Team message"),
         required=False,
         max_length=1000,
         help_text=_("Optional ban message for moderators and administrators."),
-        widget=forms.Textarea(attrs={'rows': 3}),
+        widget=forms.Textarea(attrs={"rows": 3}),
         error_messages={
-            'max_length': _("Message can't be longer than 1000 characters."),
-        }
+            "max_length": _("Message can't be longer than 1000 characters.")
+        },
     )
     expires_on = IsoDateTimeField(
         label=_("Expires on"),
         required=False,
-        help_text=_("Leave this field empty for this ban to never expire.")
+        help_text=_("Leave this field empty for this ban to never expire."),
     )
 
     class Meta:
         model = Ban
         fields = [
-            'check_type',
-            'registration_only',
-            'banned_value',
-            'user_message',
-            'staff_message',
-            'expires_on',
+            "check_type",
+            "registration_only",
+            "banned_value",
+            "user_message",
+            "staff_message",
+            "expires_on",
         ]
 
     def clean_banned_value(self):
-        data = self.cleaned_data['banned_value']
-        while '**' in data:
-            data = data.replace('**', '*')
+        data = self.cleaned_data["banned_value"]
+        while "**" in data:
+            data = data.replace("**", "*")
 
-        if data == '*':
+        if data == "*":
             raise forms.ValidationError(_("Banned value is too vague."))
 
         return data
@@ -583,56 +575,50 @@ class SearchBansForm(forms.Form):
         label=_("Type"),
         required=False,
         choices=[
-            ('', _('All bans')),
-            ('names', _('Usernames')),
-            ('emails', _('E-mails')),
-            ('ips', _('IPs')),
+            ("", _("All bans")),
+            ("names", _("Usernames")),
+            ("emails", _("E-mails")),
+            ("ips", _("IPs")),
         ],
     )
     value = forms.CharField(label=_("Banned value begins with"), required=False)
     registration_only = forms.ChoiceField(
         label=_("Registration only"),
         required=False,
-        choices=[
-            ('', _('Any')),
-            ('only', _('Yes')),
-            ('exclude', _('No')),
-        ]
+        choices=[("", _("Any")), ("only", _("Yes")), ("exclude", _("No"))],
     )
     state = forms.ChoiceField(
         label=_("State"),
         required=False,
-        choices=[
-            ('', _('Any')),
-            ('used', _('Active')),
-            ('unused', _('Expired')),
-        ]
+        choices=[("", _("Any")), ("used", _("Active")), ("unused", _("Expired"))],
     )
 
     def filter_queryset(self, search_criteria, queryset):
         criteria = search_criteria
-        if criteria.get('check_type') == 'names':
+        if criteria.get("check_type") == "names":
             queryset = queryset.filter(check_type=0)
 
-        if criteria.get('check_type') == 'emails':
+        if criteria.get("check_type") == "emails":
             queryset = queryset.filter(check_type=1)
 
-        if criteria.get('check_type') == 'ips':
+        if criteria.get("check_type") == "ips":
             queryset = queryset.filter(check_type=2)
 
-        if criteria.get('value'):
-            queryset = queryset.filter(banned_value__startswith=criteria.get('value').lower())
+        if criteria.get("value"):
+            queryset = queryset.filter(
+                banned_value__startswith=criteria.get("value").lower()
+            )
 
-        if criteria.get('state') == 'used':
+        if criteria.get("state") == "used":
             queryset = queryset.filter(is_checked=True)
 
-        if criteria.get('state') == 'unused':
+        if criteria.get("state") == "unused":
             queryset = queryset.filter(is_checked=False)
 
-        if criteria.get('registration_only') == 'only':
+        if criteria.get("registration_only") == "only":
             queryset = queryset.filter(registration_only=True)
 
-        if criteria.get('registration_only') == 'exclude':
+        if criteria.get("registration_only") == "exclude":
             queryset = queryset.filter(registration_only=False)
 
         return queryset
@@ -651,59 +637,56 @@ class RequestDataDownloadsForm(forms.Form):
     )
 
     def clean_user_identifiers(self):
-        user_identifiers = self.cleaned_data['user_identifiers'].lower().splitlines()
+        user_identifiers = self.cleaned_data["user_identifiers"].lower().splitlines()
         user_identifiers = list(filter(bool, user_identifiers))
         user_identifiers = list(set(user_identifiers))
-        
+
         if len(user_identifiers) > 20:
             raise forms.ValidationError(
                 _(
                     "You may not enter more than 20 items at single time "
                     "(You have entered %(show_value)s)."
-                ) % {'show_value': len(user_identifiers)}
+                )
+                % {"show_value": len(user_identifiers)}
             )
-        
+
         return user_identifiers
 
     def clean(self):
         data = super().clean()
 
-        if data.get('user_identifiers'):
-            username_match = Q(slug__in=data['user_identifiers'])
-            email_match = Q(email_hash__in=map(hash_email, data['user_identifiers']))
+        if data.get("user_identifiers"):
+            username_match = Q(slug__in=data["user_identifiers"])
+            email_match = Q(email_hash__in=map(hash_email, data["user_identifiers"]))
 
-            data['users'] = list(UserModel.objects.filter(username_match | email_match))
+            data["users"] = list(UserModel.objects.filter(username_match | email_match))
 
-            if len(data['users']) != len(data['user_identifiers']):
-                raise forms.ValidationError(_("One or more specified users could not be found."))
+            if len(data["users"]) != len(data["user_identifiers"]):
+                raise forms.ValidationError(
+                    _("One or more specified users could not be found.")
+                )
 
         return data
 
 
 class SearchDataDownloadsForm(forms.Form):
     status = forms.ChoiceField(
-        label=_("Status"),
-        required=False,
-        choices=DataDownload.STATUS_CHOICES,
-    )
-    user = forms.CharField(
-        label=_("User"),
-        required=False,
-    )
-    requested_by = forms.CharField(
-        label=_("Requested by"),
-        required=False,
+        label=_("Status"), required=False, choices=DataDownload.STATUS_CHOICES
     )
+    user = forms.CharField(label=_("User"), required=False)
+    requested_by = forms.CharField(label=_("Requested by"), required=False)
 
     def filter_queryset(self, search_criteria, queryset):
         criteria = search_criteria
-        if criteria.get('status') is not None:
-            queryset = queryset.filter(status=criteria['status'])
+        if criteria.get("status") is not None:
+            queryset = queryset.filter(status=criteria["status"])
 
-        if criteria.get('user'):
-            queryset = queryset.filter(user__slug__istartswith=criteria['user'])
+        if criteria.get("user"):
+            queryset = queryset.filter(user__slug__istartswith=criteria["user"])
 
-        if criteria.get('requested_by'):
-            queryset = queryset.filter(requester__slug__istartswith=criteria['requested_by'])
+        if criteria.get("requested_by"):
+            queryset = queryset.filter(
+                requester__slug__istartswith=criteria["requested_by"]
+            )
 
         return queryset

+ 50 - 36
misago/users/forms/auth.py

@@ -13,10 +13,12 @@ UserModel = get_user_model()
 
 class MisagoAuthMixin(object):
     error_messages = {
-        'empty_data': _("Fill out both fields."),
-        'invalid_login': _("Login or password is incorrect."),
-        'inactive_user': _("You have to activate your account before you will be able to sign in."),
-        'inactive_admin': _(
+        "empty_data": _("Fill out both fields."),
+        "invalid_login": _("Login or password is incorrect."),
+        "inactive_user": _(
+            "You have to activate your account before you will be able to sign in."
+        ),
+        "inactive_admin": _(
             "Your account has to be activated by site administrator before you will be able "
             "to sign in."
         ),
@@ -24,25 +26,29 @@ class MisagoAuthMixin(object):
 
     def confirm_user_active(self, user):
         if user.requires_activation_by_admin:
-            raise ValidationError(self.error_messages['inactive_admin'], code='inactive_admin')
+            raise ValidationError(
+                self.error_messages["inactive_admin"], code="inactive_admin"
+            )
 
         if user.requires_activation_by_user:
-            raise ValidationError(self.error_messages['inactive_user'], code='inactive_user')
+            raise ValidationError(
+                self.error_messages["inactive_user"], code="inactive_user"
+            )
 
     def confirm_user_not_banned(self, user):
         if not user.is_staff:
             self.user_ban = get_user_ban(user, self.request.cache_versions)
             if self.user_ban:
-                raise ValidationError('', code='banned')
+                raise ValidationError("", code="banned")
 
     def get_errors_dict(self):
-        error = self.errors.as_data()['__all__'][0]
-        if error.code == 'banned':
+        error = self.errors.as_data()["__all__"][0]
+        if error.code == "banned":
             error.message = self.user_ban.ban.get_serialized_message()
         else:
             error.message = error.messages[0]
 
-        return {'detail': error.message, 'code': error.code}
+        return {"detail": error.message, "code": error.code}
 
 
 class AuthenticationForm(MisagoAuthMixin, BaseAuthenticationForm):
@@ -50,16 +56,12 @@ class AuthenticationForm(MisagoAuthMixin, BaseAuthenticationForm):
     Base class for authenticating users, Floppy-forms and
     Misago login field compliant
     """
+
     username = forms.CharField(
-        label=_("Username or e-mail"),
-        required=False,
-        max_length=254,
+        label=_("Username or e-mail"), required=False, max_length=254
     )
     password = forms.CharField(
-        label=_("Password"),
-        strip=False,
-        required=False,
-        widget=forms.PasswordInput,
+        label=_("Password"), strip=False, required=False, widget=forms.PasswordInput
     )
 
     def __init__(self, *args, request=None, **kwargs):
@@ -67,18 +69,20 @@ class AuthenticationForm(MisagoAuthMixin, BaseAuthenticationForm):
         super().__init__(*args, **kwargs)
 
     def clean(self):
-        username = self.cleaned_data.get('username')
-        password = self.cleaned_data.get('password')
+        username = self.cleaned_data.get("username")
+        password = self.cleaned_data.get("password")
 
         if username and password:
             self.user_cache = authenticate(username=username, password=password)
 
             if self.user_cache is None or not self.user_cache.is_active:
-                raise ValidationError(self.error_messages['invalid_login'], code='invalid_login')
+                raise ValidationError(
+                    self.error_messages["invalid_login"], code="invalid_login"
+                )
             else:
                 self.confirm_login_allowed(self.user_cache)
         else:
-            raise ValidationError(self.error_messages['empty_data'], code='empty_data')
+            raise ValidationError(self.error_messages["empty_data"], code="empty_data")
 
         return self.cleaned_data
 
@@ -88,18 +92,20 @@ class AuthenticationForm(MisagoAuthMixin, BaseAuthenticationForm):
 
 
 class AdminAuthenticationForm(AuthenticationForm):
-    required_css_class = 'required'
+    required_css_class = "required"
 
     def __init__(self, *args, **kwargs):
-        self.error_messages.update({
-            'not_staff': _("Your account does not have admin privileges."),
-        })
+        self.error_messages.update(
+            {"not_staff": _("Your account does not have admin privileges.")}
+        )
 
         super().__init__(*args, **kwargs)
 
     def confirm_login_allowed(self, user):
         if not user.is_staff:
-            raise forms.ValidationError(self.error_messages['not_staff'], code='not_staff')
+            raise forms.ValidationError(
+                self.error_messages["not_staff"], code="not_staff"
+            )
 
 
 class GetUserForm(MisagoAuthMixin, forms.Form):
@@ -108,22 +114,26 @@ class GetUserForm(MisagoAuthMixin, forms.Form):
     def clean(self):
         data = super().clean()
 
-        email = data.get('email')
+        email = data.get("email")
         if not email or len(email) > 250:
-            raise forms.ValidationError(_("Enter e-mail address."), code='empty_email')
+            raise forms.ValidationError(_("Enter e-mail address."), code="empty_email")
 
         try:
             validate_email(email)
         except forms.ValidationError:
-            raise forms.ValidationError(_("Entered e-mail is invalid."), code='invalid_email')
+            raise forms.ValidationError(
+                _("Entered e-mail is invalid."), code="invalid_email"
+            )
 
         try:
-            user = UserModel.objects.get_by_email(data['email'])
+            user = UserModel.objects.get_by_email(data["email"])
             if not user.is_active:
                 raise UserModel.DoesNotExist()
             self.user_cache = user
         except UserModel.DoesNotExist:
-            raise forms.ValidationError(_("No user with this e-mail exists."), code='not_found')
+            raise forms.ValidationError(
+                _("No user with this e-mail exists."), code="not_found"
+            )
 
         self.confirm_allowed(user)
 
@@ -135,24 +145,28 @@ class GetUserForm(MisagoAuthMixin, forms.Form):
 
 class ResendActivationForm(GetUserForm):
     def confirm_allowed(self, user):
-        username_format = {'user': user.username}
+        username_format = {"user": user.username}
 
         if not user.requires_activation:
             message = _("%(user)s, your account is already active.")
-            raise forms.ValidationError(message % username_format, code='already_active')
+            raise forms.ValidationError(
+                message % username_format, code="already_active"
+            )
 
         if user.requires_activation_by_admin:
             message = _("%(user)s, only administrator may activate your account.")
-            raise forms.ValidationError(message % username_format, code='inactive_admin')
+            raise forms.ValidationError(
+                message % username_format, code="inactive_admin"
+            )
 
 
 class ResetPasswordForm(GetUserForm):
     error_messages = {
-        'inactive_user': _(
+        "inactive_user": _(
             "You have to activate your account before "
             "you will be able to request new password."
         ),
-        'inactive_admin': _(
+        "inactive_admin": _(
             "Administrator has to activate your account before "
             "you will be able to request new password."
         ),

+ 16 - 12
misago/users/forms/register.py

@@ -6,7 +6,9 @@ from django.utils.translation import gettext as _
 
 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
+    validate_email,
+    validate_new_registration,
+    validate_username,
 )
 
 
@@ -21,12 +23,12 @@ class BaseRegisterForm(forms.Form):
     privacy_policy = forms.IntegerField(required=False)
 
     def __init__(self, *args, **kwargs):
-        self.agreements = kwargs.pop('agreements')
-        self.request = kwargs.pop('request')
+        self.agreements = kwargs.pop("agreements")
+        self.request = kwargs.pop("request")
         super().__init__(*args, **kwargs)
 
     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)
@@ -38,7 +40,7 @@ class BaseRegisterForm(forms.Form):
         return data
 
     def clean_email(self):
-        data = self.cleaned_data['email']
+        data = self.cleaned_data["email"]
 
         ban = get_email_ban(data, registration_only=True)
         if ban:
@@ -50,7 +52,7 @@ class BaseRegisterForm(forms.Form):
 
     def clean_agreements(self, data):
         for field_name, agreement in self.agreements.items():
-            if data.get(field_name) != agreement['id']:
+            if data.get(field_name) != agreement["id"]:
                 error = ValueError(_("This agreement is required."))
                 self.add_error(field_name, error)
 
@@ -60,7 +62,9 @@ class BaseRegisterForm(forms.Form):
             if ban.user_message:
                 raise ValidationError(ban.user_message)
             else:
-                raise ValidationError(_("New registrations from this IP address are not allowed."))
+                raise ValidationError(
+                    _("New registrations from this IP address are not allowed.")
+                )
 
 
 class SocialAuthRegisterForm(BaseRegisterForm):
@@ -82,12 +86,12 @@ class RegisterForm(BaseRegisterForm):
     captcha = forms.CharField(required=False)
 
     def full_clean_password(self, cleaned_data):
-        if cleaned_data.get('password'):
+        if cleaned_data.get("password"):
             validate_password(
-                cleaned_data['password'],
+                cleaned_data["password"],
                 user=UserModel(
-                    username=cleaned_data.get('username'),
-                    email=cleaned_data.get('email'),
+                    username=cleaned_data.get("username"),
+                    email=cleaned_data.get("email"),
                 ),
             )
 
@@ -100,7 +104,7 @@ class RegisterForm(BaseRegisterForm):
         try:
             self.full_clean_password(cleaned_data)
         except forms.ValidationError as e:
-            self.add_error('password', e)
+            self.add_error("password", e)
 
         validate_new_registration(self.request, cleaned_data, self.add_error)
 

+ 1 - 1
misago/users/management/commands/buildactivepostersranking.py

@@ -5,7 +5,7 @@ from misago.users.activepostersranking import build_active_posters_ranking
 
 
 class Command(BaseCommand):
-    help = 'Builds active posters ranking'
+    help = "Builds active posters ranking"
 
     def handle(self, *args, **options):
         self.stdout.write("\nBuilding active posters ranking...")

+ 31 - 29
misago/users/management/commands/createsuperuser.py

@@ -30,28 +30,28 @@ class Command(BaseCommand):
 
     def add_arguments(self, parser):
         parser.add_argument(
-            '--username',
-            dest='username',
+            "--username",
+            dest="username",
             default=None,
             help="Specifies the username for the superuser.",
         )
         parser.add_argument(
-            '--email',
-            dest='email',
+            "--email",
+            dest="email",
             default=None,
             help="Specifies the e-mail for the superuser.",
         )
         parser.add_argument(
-            '--password',
-            dest='password',
+            "--password",
+            dest="password",
             default=None,
             help="Specifies the password for the superuser.",
         )
         parser.add_argument(
-            '--noinput',
-            '--no-input',
-            action='store_false',
-            dest='interactive',
+            "--noinput",
+            "--no-input",
+            action="store_false",
+            dest="interactive",
             default=True,
             help=(
                 "Tells Misago to NOT prompt the user for input "
@@ -63,23 +63,23 @@ class Command(BaseCommand):
             ),
         )
         parser.add_argument(
-            '--database',
-            action='store',
-            dest='database',
+            "--database",
+            action="store",
+            dest="database",
             default=DEFAULT_DB_ALIAS,
             help=('Specifies the database to use. Default is "default".'),
         )
 
     def execute(self, *args, **options):
-        self.stdin = options.get('stdin', sys.stdin)  # Used for testing
+        self.stdin = options.get("stdin", sys.stdin)  # Used for testing
         return super().execute(*args, **options)
 
     def handle(self, *args, **options):
-        username = options.get('username')
-        email = options.get('email')
-        password = options.get('password')
-        interactive = options.get('interactive')
-        verbosity = int(options.get('verbosity', 1))
+        username = options.get("username")
+        email = options.get("email")
+        password = options.get("password")
+        interactive = options.get("interactive")
+        verbosity = int(options.get("verbosity", 1))
 
         cache_versions = get_cache_versions()
         settings = DynamicSettings(cache_versions)
@@ -90,7 +90,7 @@ class Command(BaseCommand):
                 username = username.strip()
                 validate_username(settings, username)
             except ValidationError as e:
-                self.stderr.write('\n'.join(e.messages))
+                self.stderr.write("\n".join(e.messages))
                 username = None
 
         if email is not None:
@@ -98,12 +98,12 @@ class Command(BaseCommand):
                 email = email.strip()
                 validate_email(email)
             except ValidationError as e:
-                self.stderr.write('\n'.join(e.messages))
+                self.stderr.write("\n".join(e.messages))
                 email = None
 
         if password is not None:
             password = password.strip()
-            if password == '':
+            if password == "":
                 self.stderr.write("Error: Blank passwords aren't allowed.")
 
         if not interactive:
@@ -112,7 +112,7 @@ class Command(BaseCommand):
                 self.create_superuser(username, email, password, settings, verbosity)
         else:
             try:
-                if hasattr(self.stdin, 'isatty') and not self.stdin.isatty():
+                if hasattr(self.stdin, "isatty") and not self.stdin.isatty():
                     raise NotRunningInTTYException("Not running in a TTY")
 
                 # Prompt for username/password, and any other required fields.
@@ -125,7 +125,7 @@ class Command(BaseCommand):
                         validate_username(raw_value)
                         username = raw_value
                     except ValidationError as e:
-                        self.stderr.write('\n'.join(e.messages))
+                        self.stderr.write("\n".join(e.messages))
 
                 while not email:
                     try:
@@ -133,7 +133,7 @@ class Command(BaseCommand):
                         validate_email(raw_value)
                         email = raw_value
                     except ValidationError as e:
-                        self.stderr.write('\n'.join(e.messages))
+                        self.stderr.write("\n".join(e.messages))
 
                 while not password:
                     raw_value = getpass("Enter password: ")
@@ -142,7 +142,7 @@ class Command(BaseCommand):
                         self.stderr.write("Error: Your passwords didn't match.")
                         # Don't validate passwords that don't match.
                         continue
-                    if raw_value.strip() == '':
+                    if raw_value.strip() == "":
                         self.stderr.write("Error: Blank passwords aren't allowed.")
                         # Don't validate blank passwords.
                         continue
@@ -151,9 +151,11 @@ class Command(BaseCommand):
                             raw_value, user=User(username=username, email=email)
                         )
                     except ValidationError as e:
-                        self.stderr.write('\n'.join(e.messages))
-                        response = input('Bypass password validation and create user anyway? [y/N]: ')
-                        if response.lower() != 'y':
+                        self.stderr.write("\n".join(e.messages))
+                        response = input(
+                            "Bypass password validation and create user anyway? [y/N]: "
+                        )
+                        if response.lower() != "y":
                             continue
                     password = raw_value
 

+ 7 - 7
misago/users/management/commands/deleteinactiveusers.py

@@ -12,20 +12,20 @@ UserModel = get_user_model()
 
 
 class Command(BaseCommand):
-    help = (
-        "Deletes inactive user accounts older than set time."
-    )
+    help = "Deletes inactive user accounts older than set time."
 
     def handle(self, *args, **options):
         if not settings.MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS:
-            self.stdout.write("Automatic deletion of inactive users is currently disabled.")
+            self.stdout.write(
+                "Automatic deletion of inactive users is currently disabled."
+            )
             return
 
-
         users_deleted = 0
-        
+
         joined_on_cutoff = timezone.now() - timedelta(
-            days=settings.MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS)
+            days=settings.MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS
+        )
 
         queryset = UserModel.objects.filter(
             requires_activation__gt=UserModel.ACTIVATION_NONE,

+ 1 - 1
misago/users/management/commands/deletemarkedusers.py

@@ -18,7 +18,7 @@ class Command(BaseCommand):
 
     def handle(self, *args, **options):
         users_deleted = 0
-        
+
         queryset = UserModel.objects.filter(is_deleting_account=True)
 
         for user in chunk_queryset(queryset):

+ 6 - 13
misago/users/management/commands/deleteprofilefield.py

@@ -10,32 +10,25 @@ class Command(BaseCommand):
     help = "Deletes specified profile field from database."
 
     def add_arguments(self, parser):
-        parser.add_argument(
-            'fieldname',
-            help="field to delete",
-            nargs='?',
-        )
+        parser.add_argument("fieldname", help="field to delete", nargs="?")
 
     def handle(self, *args, **options):
-        fieldname = options['fieldname']
+        fieldname = options["fieldname"]
         if not fieldname:
             self.stderr.write("Specify fieldname to delete.")
             return
 
         fields_deleted = 0
 
-        queryset = UserModel.objects.filter(
-            profile_fields__has_keys=[fieldname],
-        )
+        queryset = UserModel.objects.filter(profile_fields__has_keys=[fieldname])
 
         for user in chunk_queryset(queryset):
             if fieldname in user.profile_fields.keys():
                 user.profile_fields.pop(fieldname)
-                user.save(update_fields=['profile_fields'])
+                user.save(update_fields=["profile_fields"])
                 fields_deleted += 1
 
         self.stdout.write(
-            '"%s" profile field has been deleted from %s users.' % (
-                fieldname, fields_deleted
-            )
+            '"%s" profile field has been deleted from %s users.'
+            % (fieldname, fields_deleted)
         )

+ 2 - 3
misago/users/management/commands/expireuserdatadownloads.py

@@ -11,10 +11,9 @@ class Command(BaseCommand):
 
     def handle(self, *args, **options):
         downloads_expired = 0
-        queryset = DataDownload.objects.select_related('user')
+        queryset = DataDownload.objects.select_related("user")
         queryset = queryset.filter(
-            status=DataDownload.STATUS_READY,
-            expires_on__lte=timezone.now(),
+            status=DataDownload.STATUS_READY, expires_on__lte=timezone.now()
         )
 
         for data_download in chunk_queryset(queryset):

+ 1 - 1
misago/users/management/commands/listusedprofilefields.py

@@ -21,7 +21,7 @@ class Command(BaseCommand):
         if keys:
             max_len = max([len(k) for k in keys.keys()])
             for key in sorted(keys.keys()):
-                space = ' ' * (max_len + 1 - len(key))
+                space = " " * (max_len + 1 - len(key))
                 self.stdout.write("%s:%s%s" % (key, space, keys[key]))
         else:
             self.stdout.write("No profile fields are currently in use.")

+ 18 - 10
misago/users/management/commands/prepareuserdatadownloads.py

@@ -12,7 +12,7 @@ from misago.users.datadownloads import prepare_user_data_download
 from misago.users.models import DataDownload
 
 
-logger = logging.getLogger('misago.users.datadownloads')
+logger = logging.getLogger("misago.users.datadownloads")
 
 
 class Command(BaseCommand):
@@ -24,24 +24,32 @@ class Command(BaseCommand):
         if not working_dir:
             self.stdout.write(
                 "MISAGO_USER_DATA_DOWNLOADS_WORKING_DIR has to be set in order for "
-                "this feature to work.")
+                "this feature to work."
+            )
             return
-        
+
         cache_versions = get_cache_versions()
         dynamic_settings = DynamicSettings(cache_versions)
 
         downloads_prepared = 0
-        queryset = DataDownload.objects.select_related('user')
+        queryset = DataDownload.objects.select_related("user")
         queryset = queryset.filter(status=DataDownload.STATUS_PENDING)
         for data_download in chunk_queryset(queryset):
             if prepare_user_data_download(data_download, logger):
                 user = data_download.user
-                subject = gettext("%(user)s, your data download is ready") % { 'user': user }
-                mail_user(user, subject, 'misago/emails/data_download', context={
-                    'data_download': data_download,
-                    'expires_in': settings.MISAGO_USER_DATA_DOWNLOADS_EXPIRE_IN_HOURS,
-                    "settings": dynamic_settings,
-                })
+                subject = gettext("%(user)s, your data download is ready") % {
+                    "user": user
+                }
+                mail_user(
+                    user,
+                    subject,
+                    "misago/emails/data_download",
+                    context={
+                        "data_download": data_download,
+                        "expires_in": settings.MISAGO_USER_DATA_DOWNLOADS_EXPIRE_IN_HOURS,
+                        "settings": dynamic_settings,
+                    },
+                )
 
                 downloads_prepared += 1
 

+ 4 - 2
misago/users/management/commands/removeoldips.py

@@ -6,7 +6,7 @@ from misago.users.signals import remove_old_ips
 
 class Command(BaseCommand):
     help = "Removes users IPs stored for longer than set in MISAGO_IP_STORE_TIME."
-    
+
     def handle(self, *args, **options):
         if not settings.MISAGO_IP_STORE_TIME:
             self.stdout.write("Old IP removal is disabled.")
@@ -15,4 +15,6 @@ class Command(BaseCommand):
         remove_old_ips.send(sender=self)
 
         self.stdout.write(
-            "IP addresses older than %s days have been removed." % settings.MISAGO_IP_STORE_TIME)
+            "IP addresses older than %s days have been removed."
+            % settings.MISAGO_IP_STORE_TIME
+        )

+ 3 - 7
misago/users/management/commands/synchronizeusers.py

@@ -30,18 +30,14 @@ class Command(BaseCommand):
         synchronized_count = 0
         show_progress(self, synchronized_count, users_to_sync)
         start_time = time.time()
-        
+
         for user in chunk_queryset(UserModel.objects.all()):
             user.threads = user.thread_set.filter(
-                category__in=categories,
-                is_hidden=False,
-                is_unapproved=False,
+                category__in=categories, is_hidden=False, is_unapproved=False
             ).count()
 
             user.posts = user.post_set.filter(
-                category__in=categories,
-                is_event=False,
-                is_unapproved=False,
+                category__in=categories, is_event=False, is_unapproved=False
             ).count()
 
             user.followers = user.followed_by.count()

+ 6 - 7
misago/users/middleware.py

@@ -8,11 +8,11 @@ from .online import tracker
 
 class RealIPMiddleware(MiddlewareMixin):
     def process_request(self, request):
-        x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
+        x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
         if x_forwarded_for:
-            request.user_ip = x_forwarded_for.split(',')[0]
+            request.user_ip = x_forwarded_for.split(",")[0]
         else:
-            request.user_ip = request.META.get('REMOTE_ADDR')
+            request.user_ip = request.META.get("REMOTE_ADDR")
 
 
 class UserMiddleware(MiddlewareMixin):
@@ -20,9 +20,8 @@ class UserMiddleware(MiddlewareMixin):
         if request.user.is_anonymous:
             request.user = AnonymousUser()
         elif not request.user.is_staff:
-            if (
-                get_request_ip_ban(request) or
-                get_user_ban(request.user, request.cache_versions)
+            if get_request_ip_ban(request) or get_user_ban(
+                request.user, request.cache_versions
             ):
                 logout(request)
                 request.user = AnonymousUser()
@@ -39,7 +38,7 @@ class OnlineTrackerMiddleware(MiddlewareMixin):
             request._misago_online_tracker = None
 
     def process_response(self, request, response):
-        if hasattr(request, '_misago_online_tracker'):
+        if hasattr(request, "_misago_online_tracker"):
             online_tracker = request._misago_online_tracker
 
             if online_tracker:

+ 264 - 196
misago/users/migrations/0001_initial.py

@@ -11,332 +11,400 @@ class Migration(migrations.Migration):
 
     initial = True
 
-    dependencies = [
-        ('auth', '0001_initial'),
-        ('misago_acl', '0001_initial'),
-    ]
+    dependencies = [("auth", "0001_initial"), ("misago_acl", "0001_initial")]
 
     operations = [
         migrations.CreateModel(
-            name='User',
+            name="User",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
                 ),
-                ('password', models.CharField(max_length=128, verbose_name='password')),
+                ("password", models.CharField(max_length=128, verbose_name="password")),
                 (
-                    'last_login',
-                    models.DateTimeField(null=True, blank=True, verbose_name='last login')
+                    "last_login",
+                    models.DateTimeField(
+                        null=True, blank=True, verbose_name="last login"
+                    ),
                 ),
-                ('username', models.CharField(max_length=30)),
-                ('slug', models.CharField(unique=True, max_length=30)),
-                ('email', models.EmailField(max_length=255, db_index=True)),
-                ('email_hash', models.CharField(unique=True, max_length=32)),
+                ("username", models.CharField(max_length=30)),
+                ("slug", models.CharField(unique=True, max_length=30)),
+                ("email", models.EmailField(max_length=255, db_index=True)),
+                ("email_hash", models.CharField(unique=True, max_length=32)),
                 (
-                    'joined_on', models.DateTimeField(
-                        default=django.utils.timezone.now, verbose_name='joined on'
-                    )
+                    "joined_on",
+                    models.DateTimeField(
+                        default=django.utils.timezone.now, verbose_name="joined on"
+                    ),
                 ),
-                ('joined_from_ip', models.GenericIPAddressField()),
-                ('last_ip', models.GenericIPAddressField(null=True, blank=True)),
-                ('is_hiding_presence', models.BooleanField(default=False)),
-                ('title', models.CharField(max_length=255, null=True, blank=True)),
-                ('requires_activation', models.PositiveIntegerField(default=0)),
+                ("joined_from_ip", models.GenericIPAddressField()),
+                ("last_ip", models.GenericIPAddressField(null=True, blank=True)),
+                ("is_hiding_presence", models.BooleanField(default=False)),
+                ("title", models.CharField(max_length=255, null=True, blank=True)),
+                ("requires_activation", models.PositiveIntegerField(default=0)),
                 (
-                    'is_staff', models.BooleanField(
+                    "is_staff",
+                    models.BooleanField(
                         default=False,
-                        help_text='Designates whether the user can log into admin sites.',
-                        verbose_name='staff status'
-                    )
+                        help_text="Designates whether the user can log into admin sites.",
+                        verbose_name="staff status",
+                    ),
                 ),
                 (
-                    'is_superuser', models.BooleanField(
+                    "is_superuser",
+                    models.BooleanField(
                         default=False,
-                        help_text='Designates that this user has all permissions without explicitly assigning them.',
-                        verbose_name='superuser status'
-                    )
+                        help_text="Designates that this user has all permissions without explicitly assigning them.",
+                        verbose_name="superuser status",
+                    ),
                 ),
-                ('acl_key', models.CharField(max_length=12, null=True, blank=True)),
+                ("acl_key", models.CharField(max_length=12, null=True, blank=True)),
                 (
-                    'is_active', models.BooleanField(
+                    "is_active",
+                    models.BooleanField(
                         db_index=True,
                         default=True,
-                        verbose_name='active',
+                        verbose_name="active",
                         help_text=(
-                            'Designates whether this user should be treated as active. Unselect this instead of deleting '
-                            'accounts.'
-                        )
-                    )
+                            "Designates whether this user should be treated as active. Unselect this instead of deleting "
+                            "accounts."
+                        ),
+                    ),
                 ),
-                ('is_active_staff_message', models.TextField(null=True, blank=True)),
+                ("is_active_staff_message", models.TextField(null=True, blank=True)),
                 (
-                    'groups', models.ManyToManyField(
-                        related_query_name='user',
-                        related_name='user_set',
-                        to='auth.Group',
+                    "groups",
+                    models.ManyToManyField(
+                        related_query_name="user",
+                        related_name="user_set",
+                        to="auth.Group",
                         blank=True,
-                        help_text='The groups this user belongs to. A user will get all permissions granted to each of his/her group.',
-                        verbose_name='groups'
-                    )
+                        help_text="The groups this user belongs to. A user will get all permissions granted to each of his/her group.",
+                        verbose_name="groups",
+                    ),
                 ),
-                ('roles', models.ManyToManyField(to='misago_acl.Role')),
+                ("roles", models.ManyToManyField(to="misago_acl.Role")),
                 (
-                    'user_permissions', models.ManyToManyField(
-                        related_query_name='user',
-                        related_name='user_set',
-                        to='auth.Permission',
+                    "user_permissions",
+                    models.ManyToManyField(
+                        related_query_name="user",
+                        related_name="user_set",
+                        to="auth.Permission",
                         blank=True,
-                        help_text='Specific permissions for this user.',
-                        verbose_name='user permissions'
-                    )
+                        help_text="Specific permissions for this user.",
+                        verbose_name="user permissions",
+                    ),
                 ),
                 (
-                    'avatar_tmp', models.ImageField(
+                    "avatar_tmp",
+                    models.ImageField(
                         max_length=255,
                         upload_to=misago.users.avatars.store.upload_to,
                         null=True,
-                        blank=True
-                    )
+                        blank=True,
+                    ),
                 ),
                 (
-                    'avatar_src', models.ImageField(
+                    "avatar_src",
+                    models.ImageField(
                         max_length=255,
                         upload_to=misago.users.avatars.store.upload_to,
                         null=True,
-                        blank=True
-                    )
-                ),
-                ('avatar_crop', models.CharField(max_length=255, null=True, blank=True)),
-                ('avatars', JSONField(null=True, blank=True)),
-                ('is_avatar_locked', models.BooleanField(default=False)),
-                ('avatar_lock_user_message', models.TextField(null=True, blank=True)),
-                ('avatar_lock_staff_message', models.TextField(null=True, blank=True)),
-                ('signature', models.TextField(null=True, blank=True)),
-                ('signature_parsed', models.TextField(null=True, blank=True)),
-                ('signature_checksum', models.CharField(max_length=64, null=True, blank=True)),
-                ('is_signature_locked', models.BooleanField(default=False)),
-                ('signature_lock_user_message', models.TextField(null=True, blank=True)),
-                ('signature_lock_staff_message', models.TextField(null=True, blank=True)),
-                ('following', models.PositiveIntegerField(default=0)),
-                ('followers', models.PositiveIntegerField(default=0)),
-                ('limits_private_thread_invites_to', models.PositiveIntegerField(default=0)),
-                ('unread_private_threads', models.PositiveIntegerField(default=0)),
-                ('sync_unread_private_threads', models.BooleanField(default=False)),
-                ('subscribe_to_started_threads', models.PositiveIntegerField(default=0)),
-                ('subscribe_to_replied_threads', models.PositiveIntegerField(default=0)),
-                ('threads', models.PositiveIntegerField(default=0)),
-                ('posts', models.PositiveIntegerField(default=0, db_index=True)),
-                ('last_posted_on', models.DateTimeField(null=True, blank=True)),
+                        blank=True,
+                    ),
+                ),
+                (
+                    "avatar_crop",
+                    models.CharField(max_length=255, null=True, blank=True),
+                ),
+                ("avatars", JSONField(null=True, blank=True)),
+                ("is_avatar_locked", models.BooleanField(default=False)),
+                ("avatar_lock_user_message", models.TextField(null=True, blank=True)),
+                ("avatar_lock_staff_message", models.TextField(null=True, blank=True)),
+                ("signature", models.TextField(null=True, blank=True)),
+                ("signature_parsed", models.TextField(null=True, blank=True)),
+                (
+                    "signature_checksum",
+                    models.CharField(max_length=64, null=True, blank=True),
+                ),
+                ("is_signature_locked", models.BooleanField(default=False)),
+                (
+                    "signature_lock_user_message",
+                    models.TextField(null=True, blank=True),
+                ),
+                (
+                    "signature_lock_staff_message",
+                    models.TextField(null=True, blank=True),
+                ),
+                ("following", models.PositiveIntegerField(default=0)),
+                ("followers", models.PositiveIntegerField(default=0)),
+                (
+                    "limits_private_thread_invites_to",
+                    models.PositiveIntegerField(default=0),
+                ),
+                ("unread_private_threads", models.PositiveIntegerField(default=0)),
+                ("sync_unread_private_threads", models.BooleanField(default=False)),
+                (
+                    "subscribe_to_started_threads",
+                    models.PositiveIntegerField(default=0),
+                ),
+                (
+                    "subscribe_to_replied_threads",
+                    models.PositiveIntegerField(default=0),
+                ),
+                ("threads", models.PositiveIntegerField(default=0)),
+                ("posts", models.PositiveIntegerField(default=0, db_index=True)),
+                ("last_posted_on", models.DateTimeField(null=True, blank=True)),
             ],
-            options={
-                'abstract': False,
-            },
-            bases=(models.Model, ),
+            options={"abstract": False},
+            bases=(models.Model,),
         ),
         migrations.CreateModel(
-            name='Online',
+            name="Online",
             fields=[
-                ('current_ip', models.GenericIPAddressField()),
-                ('last_click', models.DateTimeField(default=django.utils.timezone.now)),
+                ("current_ip", models.GenericIPAddressField()),
+                ("last_click", models.DateTimeField(default=django.utils.timezone.now)),
                 (
-                    'user', models.OneToOneField(
-                        related_name='online_tracker',
+                    "user",
+                    models.OneToOneField(
+                        related_name="online_tracker",
                         on_delete=django.db.models.deletion.CASCADE,
                         primary_key=True,
                         serialize=False,
-                        to=settings.AUTH_USER_MODEL
-                    )
+                        to=settings.AUTH_USER_MODEL,
+                    ),
                 ),
             ],
             options={},
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
         migrations.CreateModel(
-            name='UsernameChange',
+            name="UsernameChange",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
                 ),
-                ('changed_by_username', models.CharField(max_length=30)),
-                ('changed_on', models.DateTimeField(default=django.utils.timezone.now)),
-                ('new_username', models.CharField(max_length=255)),
-                ('old_username', models.CharField(max_length=255)),
+                ("changed_by_username", models.CharField(max_length=30)),
+                ("changed_on", models.DateTimeField(default=django.utils.timezone.now)),
+                ("new_username", models.CharField(max_length=255)),
+                ("old_username", models.CharField(max_length=255)),
                 (
-                    'changed_by', models.ForeignKey(
-                        related_name='user_renames',
+                    "changed_by",
+                    models.ForeignKey(
+                        related_name="user_renames",
                         on_delete=django.db.models.deletion.SET_NULL,
                         blank=True,
                         to=settings.AUTH_USER_MODEL,
-                        null=True
-                    )
+                        null=True,
+                    ),
                 ),
                 (
-                    'user', models.ForeignKey(
-                        related_name='namechanges',
+                    "user",
+                    models.ForeignKey(
+                        related_name="namechanges",
                         on_delete=django.db.models.deletion.CASCADE,
                         to=settings.AUTH_USER_MODEL,
-                    )
+                    ),
                 ),
             ],
-            options={
-                'get_latest_by': 'changed_on',
-            },
-            bases=(models.Model, ),
+            options={"get_latest_by": "changed_on"},
+            bases=(models.Model,),
         ),
         migrations.CreateModel(
-            name='Rank',
+            name="Rank",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
-                ),
-                ('name', models.CharField(max_length=255)),
-                ('slug', models.CharField(unique=True, max_length=255)),
-                ('description', models.TextField(null=True, blank=True)),
-                ('title', models.CharField(max_length=255, null=True, blank=True)),
-                ('css_class', models.CharField(max_length=255, null=True, blank=True)),
-                ('is_default', models.BooleanField(default=False)),
-                ('is_tab', models.BooleanField(default=False)),
-                ('order', models.IntegerField(default=0)),
-                ('roles', models.ManyToManyField(to='misago_acl.Role', null=True, blank=True)),
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
+                ),
+                ("name", models.CharField(max_length=255)),
+                ("slug", models.CharField(unique=True, max_length=255)),
+                ("description", models.TextField(null=True, blank=True)),
+                ("title", models.CharField(max_length=255, null=True, blank=True)),
+                ("css_class", models.CharField(max_length=255, null=True, blank=True)),
+                ("is_default", models.BooleanField(default=False)),
+                ("is_tab", models.BooleanField(default=False)),
+                ("order", models.IntegerField(default=0)),
+                (
+                    "roles",
+                    models.ManyToManyField(to="misago_acl.Role", null=True, blank=True),
+                ),
             ],
-            options={
-                'get_latest_by': 'order',
-            },
-            bases=(models.Model, ),
+            options={"get_latest_by": "order"},
+            bases=(models.Model,),
         ),
         migrations.AddField(
-            model_name='user',
-            name='rank',
+            model_name="user",
+            name="rank",
             field=models.ForeignKey(
                 on_delete=django.db.models.deletion.PROTECT,
-                to_field='id',
+                to_field="id",
                 blank=True,
-                to='misago_users.Rank',
-                null=True
+                to="misago_users.Rank",
+                null=True,
             ),
             preserve_default=True,
         ),
         migrations.AddField(
-            model_name='user',
-            name='follows',
-            field=models.ManyToManyField(related_name='followed_by', to=settings.AUTH_USER_MODEL),
+            model_name="user",
+            name="follows",
+            field=models.ManyToManyField(
+                related_name="followed_by", to=settings.AUTH_USER_MODEL
+            ),
             preserve_default=True,
         ),
         migrations.AddField(
-            model_name='user',
-            name='blocks',
-            field=models.ManyToManyField(related_name='blocked_by', to=settings.AUTH_USER_MODEL),
+            model_name="user",
+            name="blocks",
+            field=models.ManyToManyField(
+                related_name="blocked_by", to=settings.AUTH_USER_MODEL
+            ),
             preserve_default=True,
         ),
         migrations.CreateModel(
-            name='ActivityRanking',
+            name="ActivityRanking",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
                 ),
                 (
-                    'user', models.ForeignKey(
-                        related_name='+',
+                    "user",
+                    models.ForeignKey(
+                        related_name="+",
                         on_delete=django.db.models.deletion.CASCADE,
                         to=settings.AUTH_USER_MODEL,
-                    )
+                    ),
                 ),
-                ('score', models.PositiveIntegerField(default=0, db_index=True)),
+                ("score", models.PositiveIntegerField(default=0, db_index=True)),
             ],
             options={},
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
         migrations.CreateModel(
-            name='Avatar',
+            name="Avatar",
             fields=[
                 (
-                    'id', models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    )
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
                 ),
                 (
-                    'user', models.ForeignKey(
-                        on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
-                    )
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
                 ),
-                ('size', models.PositiveIntegerField(default=0)),
+                ("size", models.PositiveIntegerField(default=0)),
                 (
-                    'image', models.ImageField(
+                    "image",
+                    models.ImageField(
                         max_length=255, upload_to=misago.users.avatars.store.upload_to
-                    )
+                    ),
                 ),
             ],
         ),
         migrations.CreateModel(
-            name='AvatarGallery',
+            name="AvatarGallery",
             fields=[
                 (
-                    'id', models.AutoField(
-                        auto_created=True, primary_key=True, serialize=False, verbose_name='ID'
-                    )
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
                 ),
-                ('gallery', models.CharField(max_length=255)),
+                ("gallery", models.CharField(max_length=255)),
                 (
-                    'image', models.ImageField(
+                    "image",
+                    models.ImageField(
                         max_length=255, upload_to=misago.users.avatars.store.upload_to
-                    )
+                    ),
                 ),
             ],
-            options={
-                'ordering': ['gallery', 'pk'],
-            },
+            options={"ordering": ["gallery", "pk"]},
         ),
         migrations.CreateModel(
-            name='Ban',
+            name="Ban",
             fields=[
                 (
-                    'id', models.AutoField(
-                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
-                    )
+                    "id",
+                    models.AutoField(
+                        verbose_name="ID",
+                        serialize=False,
+                        auto_created=True,
+                        primary_key=True,
+                    ),
+                ),
+                ("check_type", models.PositiveIntegerField(default=0, db_index=True)),
+                ("banned_value", models.CharField(max_length=255, db_index=True)),
+                ("user_message", models.TextField(null=True, blank=True)),
+                ("staff_message", models.TextField(null=True, blank=True)),
+                (
+                    "expires_on",
+                    models.DateTimeField(null=True, blank=True, db_index=True),
                 ),
-                ('check_type', models.PositiveIntegerField(default=0, db_index=True)),
-                ('banned_value', models.CharField(max_length=255, db_index=True)),
-                ('user_message', models.TextField(null=True, blank=True)),
-                ('staff_message', models.TextField(null=True, blank=True)),
-                ('expires_on', models.DateTimeField(null=True, blank=True, db_index=True)),
-                ('is_checked', models.BooleanField(default=True, db_index=True)),
+                ("is_checked", models.BooleanField(default=True, db_index=True)),
             ],
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
         migrations.CreateModel(
-            name='BanCache',
+            name="BanCache",
             fields=[
-                ('user_message', models.TextField(null=True, blank=True)),
-                ('staff_message', models.TextField(null=True, blank=True)),
-                ('bans_version', models.PositiveIntegerField(default=0)),
-                ('expires_on', models.DateTimeField(null=True, blank=True)),
+                ("user_message", models.TextField(null=True, blank=True)),
+                ("staff_message", models.TextField(null=True, blank=True)),
+                ("bans_version", models.PositiveIntegerField(default=0)),
+                ("expires_on", models.DateTimeField(null=True, blank=True)),
                 (
-                    'ban', models.ForeignKey(
+                    "ban",
+                    models.ForeignKey(
                         on_delete=django.db.models.deletion.SET_NULL,
                         blank=True,
-                        to='misago_users.Ban',
-                        null=True
-                    )
+                        to="misago_users.Ban",
+                        null=True,
+                    ),
                 ),
                 (
-                    'user', models.OneToOneField(
-                        related_name='ban_cache',
+                    "user",
+                    models.OneToOneField(
+                        related_name="ban_cache",
                         on_delete=django.db.models.deletion.CASCADE,
                         primary_key=True,
                         serialize=False,
-                        to=settings.AUTH_USER_MODEL
-                    )
+                        to=settings.AUTH_USER_MODEL,
+                    ),
                 ),
             ],
             options={},
-            bases=(models.Model, ),
+            bases=(models.Model,),
         ),
     ]

+ 176 - 201
misago/users/migrations/0002_users_settings.py

@@ -8,261 +8,236 @@ _ = lambda s: s
 
 def create_users_settings_group(apps, schema_editor):
     migrate_settings_group(
-        apps, {
-            'key': 'users',
-            'name': _("Users"),
-            'description': _(
+        apps,
+        {
+            "key": "users",
+            "name": _("Users"),
+            "description": _(
                 "Those settings control user accounts default behaviour and features availability."
             ),
-            'settings': [
-                {
-                    'setting': 'account_activation',
-                    'name': _("New accounts activation"),
-                    'legend': _("New accounts"),
-                    'value': 'none',
-                    'form_field': 'select',
-                    'field_extra': {
-                        'choices': [
-                            ('none', _("No activation required")),
-                            ('user', _("Activation token sent to User")),
-                            ('admin', _("Activation by administrator")),
-                            ('closed', _("Don't allow new registrations")),
-                        ],
-                    },
-                    'is_public': True,
-                },
-                {
-                    'setting': 'username_length_min',
-                    'name': _("Minimum length"),
-                    'description': _("Minimum allowed username length."),
-                    'legend': _("User names"),
-                    'python_type': 'int',
-                    'value': 3,
-                    'field_extra': {
-                        'min_value': 2,
-                        'max_value': 20,
-                    },
-                    'is_public': True,
-                },
-                {
-                    'setting': 'username_length_max',
-                    'name': _("Maximum length"),
-                    'description': _("Maximum allowed username length."),
-                    'python_type': 'int',
-                    'value': 14,
-                    'field_extra': {
-                        'min_value': 2,
-                        'max_value': 20,
-                    },
-                    'is_public': True,
-                },
-                {
-                    'setting': 'password_length_min',
-                    'name': _("Minimum length"),
-                    'description': _("Minimum allowed user password length."),
-                    'legend': _("Passwords"),
-                    'python_type': 'int',
-                    'value': 5,
-                    'field_extra': {
-                        'min_value': 2,
-                        'max_value': 255,
-                    },
-                    'is_public': True,
-                },
-                {
-                    'setting': 'allow_custom_avatars',
-                    'name': _("Allow custom avatars"),
-                    'legend': _("Avatars"),
-                    'description': _(
+            "settings": [
+                {
+                    "setting": "account_activation",
+                    "name": _("New accounts activation"),
+                    "legend": _("New accounts"),
+                    "value": "none",
+                    "form_field": "select",
+                    "field_extra": {
+                        "choices": [
+                            ("none", _("No activation required")),
+                            ("user", _("Activation token sent to User")),
+                            ("admin", _("Activation by administrator")),
+                            ("closed", _("Don't allow new registrations")),
+                        ]
+                    },
+                    "is_public": True,
+                },
+                {
+                    "setting": "username_length_min",
+                    "name": _("Minimum length"),
+                    "description": _("Minimum allowed username length."),
+                    "legend": _("User names"),
+                    "python_type": "int",
+                    "value": 3,
+                    "field_extra": {"min_value": 2, "max_value": 20},
+                    "is_public": True,
+                },
+                {
+                    "setting": "username_length_max",
+                    "name": _("Maximum length"),
+                    "description": _("Maximum allowed username length."),
+                    "python_type": "int",
+                    "value": 14,
+                    "field_extra": {"min_value": 2, "max_value": 20},
+                    "is_public": True,
+                },
+                {
+                    "setting": "password_length_min",
+                    "name": _("Minimum length"),
+                    "description": _("Minimum allowed user password length."),
+                    "legend": _("Passwords"),
+                    "python_type": "int",
+                    "value": 5,
+                    "field_extra": {"min_value": 2, "max_value": 255},
+                    "is_public": True,
+                },
+                {
+                    "setting": "allow_custom_avatars",
+                    "name": _("Allow custom avatars"),
+                    "legend": _("Avatars"),
+                    "description": _(
                         "Turning this option off will forbid "
                         "forum users from using avatars from "
                         "outside forums. Good for forums "
                         "adressed at young users."
                     ),
-                    'python_type': 'bool',
-                    'value': True,
-                    'form_field': 'yesno',
+                    "python_type": "bool",
+                    "value": True,
+                    "form_field": "yesno",
                 },
                 {
-                    'setting': 'default_avatar',
-                    'name': _("Default avatar"),
-                    'value': 'gravatar',
-                    'form_field': 'select',
-                    'field_extra': {
-                        'choices': [
-                            ('dynamic', _("Individual")),
-                            ('gravatar', _("Gravatar")),
-                            ('gallery', _("Random avatar from gallery")),
-                        ],
+                    "setting": "default_avatar",
+                    "name": _("Default avatar"),
+                    "value": "gravatar",
+                    "form_field": "select",
+                    "field_extra": {
+                        "choices": [
+                            ("dynamic", _("Individual")),
+                            ("gravatar", _("Gravatar")),
+                            ("gallery", _("Random avatar from gallery")),
+                        ]
                     },
                 },
                 {
-                    'setting': 'default_gravatar_fallback',
-                    'name': _("Fallback for default gravatar"),
-                    'description': _(
+                    "setting": "default_gravatar_fallback",
+                    "name": _("Fallback for default gravatar"),
+                    "description": _(
                         "Select which avatar to use when user has no "
                         "gravatar associated with his e-mail address."
                     ),
-                    'value': 'dynamic',
-                    'form_field': 'select',
-                    'field_extra': {
-                        'choices': [
-                            ('dynamic', _("Individual")),
-                            ('gallery', _("Random avatar from gallery")),
-                        ],
+                    "value": "dynamic",
+                    "form_field": "select",
+                    "field_extra": {
+                        "choices": [
+                            ("dynamic", _("Individual")),
+                            ("gallery", _("Random avatar from gallery")),
+                        ]
                     },
                 },
                 {
-                    'setting': 'avatar_upload_limit',
-                    'name': _("Maximum size of uploaded avatar"),
-                    'description': _("Enter maximum allowed file size (in KB) for avatar uploads."),
-                    'python_type': 'int',
-                    'value': 1536,
-                    'field_extra': {
-                        'min_value': 0,
-                    },
-                    'is_public': True,
-                },
-                {
-                    'setting': 'signature_length_max',
-                    'name': _("Maximum length"),
-                    'legend': _("Signatures"),
-                    'description': _("Maximum allowed signature length."),
-                    'python_type': 'int',
-                    'value': 256,
-                    'field_extra': {
-                        'min_value': 10,
-                        'max_value': 5000,
-                    },
-                    'is_public': True,
-                },
-                {
-                    'setting': 'subscribe_start',
-                    'name': _("Started threads"),
-                    'legend': _("Default subscriptions settings"),
-                    'value': 'watch_email',
-                    'form_field': 'select',
-                    'field_extra': {
-                        'choices': [
-                            ('no', _("Don't watch")),
-                            ('watch', _("Put on watched threads list")),
+                    "setting": "avatar_upload_limit",
+                    "name": _("Maximum size of uploaded avatar"),
+                    "description": _(
+                        "Enter maximum allowed file size (in KB) for avatar uploads."
+                    ),
+                    "python_type": "int",
+                    "value": 1536,
+                    "field_extra": {"min_value": 0},
+                    "is_public": True,
+                },
+                {
+                    "setting": "signature_length_max",
+                    "name": _("Maximum length"),
+                    "legend": _("Signatures"),
+                    "description": _("Maximum allowed signature length."),
+                    "python_type": "int",
+                    "value": 256,
+                    "field_extra": {"min_value": 10, "max_value": 5000},
+                    "is_public": True,
+                },
+                {
+                    "setting": "subscribe_start",
+                    "name": _("Started threads"),
+                    "legend": _("Default subscriptions settings"),
+                    "value": "watch_email",
+                    "form_field": "select",
+                    "field_extra": {
+                        "choices": [
+                            ("no", _("Don't watch")),
+                            ("watch", _("Put on watched threads list")),
                             (
-                                'watch_email', _(
+                                "watch_email",
+                                _(
                                     "Put on watched threads "
                                     "list and e-mail user when "
                                     "somebody replies"
-                                )
+                                ),
                             ),
-                        ],
+                        ]
                     },
                 },
                 {
-                    'setting': 'subscribe_reply',
-                    'name': _("Replied threads"),
-                    'value': 'watch_email',
-                    'form_field': 'select',
-                    'field_extra': {
-                        'choices': [
-                            ('no', _("Don't watch")),
-                            ('watch', _("Put on watched threads list")),
+                    "setting": "subscribe_reply",
+                    "name": _("Replied threads"),
+                    "value": "watch_email",
+                    "form_field": "select",
+                    "field_extra": {
+                        "choices": [
+                            ("no", _("Don't watch")),
+                            ("watch", _("Put on watched threads list")),
                             (
-                                'watch_email', _(
+                                "watch_email",
+                                _(
                                     "Put on watched threads "
                                     "list and e-mail user when "
                                     "somebody replies"
-                                )
+                                ),
                             ),
-                        ],
+                        ]
                     },
                 },
             ],
-        }
+        },
     )
 
     migrate_settings_group(
-        apps, {
-            'key': 'captcha',
-            'name': _("CAPTCHA"),
-            'description': _("Those settings allow you to combat automatic registrations on your forum."),
-            'settings': [
+        apps,
+        {
+            "key": "captcha",
+            "name": _("CAPTCHA"),
+            "description": _(
+                "Those settings allow you to combat automatic registrations on your forum."
+            ),
+            "settings": [
                 {
-                    'setting': 'captcha_type',
-                    'name': _("Select CAPTCHA type"),
-                    'legend': _("CAPTCHA type"),
-                    'value': 'no',
-                    'form_field': 'select',
-                    'field_extra': {
-                        'choices': [
-                            ('no', _("No CAPTCHA")),
-                            ('re', _("reCaptcha")),
-                            ('qa', _("Question and answer")),
-                        ],
+                    "setting": "captcha_type",
+                    "name": _("Select CAPTCHA type"),
+                    "legend": _("CAPTCHA type"),
+                    "value": "no",
+                    "form_field": "select",
+                    "field_extra": {
+                        "choices": [
+                            ("no", _("No CAPTCHA")),
+                            ("re", _("reCaptcha")),
+                            ("qa", _("Question and answer")),
+                        ]
                     },
-                    'is_public': True,
+                    "is_public": True,
                 },
                 {
-                    'setting': 'recaptcha_site_key',
-                    'name': _("Site key"),
-                    'legend': _("reCAPTCHA"),
-                    'value': '',
-                    'field_extra': {
-                        'required': False,
-                        'max_length': 100,
-                    },
-                    'is_public': True,
+                    "setting": "recaptcha_site_key",
+                    "name": _("Site key"),
+                    "legend": _("reCAPTCHA"),
+                    "value": "",
+                    "field_extra": {"required": False, "max_length": 100},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'recaptcha_secret_key',
-                    'name': _("Secret key"),
-                    'value': '',
-                    'field_extra': {
-                        'required': False,
-                        'max_length': 100,
-                    },
+                    "setting": "recaptcha_secret_key",
+                    "name": _("Secret key"),
+                    "value": "",
+                    "field_extra": {"required": False, "max_length": 100},
                 },
                 {
-                    'setting': 'qa_question',
-                    'name': _("Test question"),
-                    'legend': _("Question and answer"),
-                    'value': '',
-                    'field_extra': {
-                        'required': False,
-                        'max_length': 250,
-                    },
+                    "setting": "qa_question",
+                    "name": _("Test question"),
+                    "legend": _("Question and answer"),
+                    "value": "",
+                    "field_extra": {"required": False, "max_length": 250},
                 },
                 {
-                    'setting': 'qa_help_text',
-                    'name': _("Question help text"),
-                    'value': '',
-                    'field_extra': {
-                        'required': False,
-                        'max_length': 250,
-                    },
+                    "setting": "qa_help_text",
+                    "name": _("Question help text"),
+                    "value": "",
+                    "field_extra": {"required": False, "max_length": 250},
                 },
                 {
-                    'setting': 'qa_answers',
-                    'name': _("Valid answers"),
-                    'description': _("Enter each answer in new line. Answers are case-insensitive."),
-                    'value': '',
-                    'form_field': 'textarea',
-                    'field_extra': {
-                        'rows': 4,
-                        'required': False,
-                        'max_length': 250,
-                    },
+                    "setting": "qa_answers",
+                    "name": _("Valid answers"),
+                    "description": _(
+                        "Enter each answer in new line. Answers are case-insensitive."
+                    ),
+                    "value": "",
+                    "form_field": "textarea",
+                    "field_extra": {"rows": 4, "required": False, "max_length": 250},
                 },
             ],
-        }
+        },
     )
 
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_users', '0001_initial'),
-        ('misago_conf', '0001_initial'),
-    ]
+    dependencies = [("misago_users", "0001_initial"), ("misago_conf", "0001_initial")]
 
-    operations = [
-        migrations.RunPython(create_users_settings_group),
-    ]
+    operations = [migrations.RunPython(create_users_settings_group)]

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

@@ -5,8 +5,8 @@ class Migration(migrations.Migration):
     """Migration superseded by 0016"""
 
     dependencies = [
-        ('misago_users', '0002_users_settings'),
-        ('misago_core', '0001_initial'),
+        ("misago_users", "0002_users_settings"),
+        ("misago_core", "0001_initial"),
     ]
 
     operations = []

+ 6 - 8
misago/users/migrations/0004_default_ranks.py

@@ -8,13 +8,13 @@ _ = lambda s: s
 
 
 def create_default_ranks(apps, schema_editor):
-    Rank = apps.get_model('misago_users', 'Rank')
+    Rank = apps.get_model("misago_users", "Rank")
 
     team = Rank.objects.create(
         name=gettext("Forum team"),
         slug=slugify(gettext("Forum team")),
         title=gettext("Team"),
-        css_class='primary',
+        css_class="primary",
         is_tab=True,
         order=0,
     )
@@ -26,7 +26,7 @@ def create_default_ranks(apps, schema_editor):
         order=1,
     )
 
-    Role = apps.get_model('misago_acl', 'Role')
+    Role = apps.get_model("misago_acl", "Role")
 
     team.roles.add(Role.objects.get(name=_("Moderator")))
     team.roles.add(Role.objects.get(name=_("Private threads")))
@@ -39,10 +39,8 @@ def create_default_ranks(apps, schema_editor):
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('misago_users', '0003_bans_version_tracker'),
-        ('misago_acl', '0003_default_roles'),
+        ("misago_users", "0003_bans_version_tracker"),
+        ("misago_acl", "0003_default_roles"),
     ]
 
-    operations = [
-        migrations.RunPython(create_default_ranks),
-    ]
+    operations = [migrations.RunPython(create_default_ranks)]

+ 12 - 17
misago/users/migrations/0005_dj_19_update.py

@@ -6,32 +6,27 @@ import misago.users.models.user
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_users', '0004_default_ranks'),
-    ]
+    dependencies = [("misago_users", "0004_default_ranks")]
 
     operations = [
         migrations.AlterModelManagers(
-            name='user',
-            managers=[
-                ('objects', misago.users.models.user.UserManager()),
-            ],
+            name="user", managers=[("objects", misago.users.models.user.UserManager())]
         ),
         migrations.AlterField(
-            model_name='rank',
-            name='roles',
-            field=models.ManyToManyField(blank=True, to='misago_acl.Role'),
+            model_name="rank",
+            name="roles",
+            field=models.ManyToManyField(blank=True, to="misago_acl.Role"),
         ),
         migrations.AlterField(
-            model_name='user',
-            name='groups',
+            model_name="user",
+            name="groups",
             field=models.ManyToManyField(
                 blank=True,
-                help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.',
-                related_name='user_set',
-                related_query_name='user',
-                to='auth.Group',
-                verbose_name='groups'
+                help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
+                related_name="user_set",
+                related_query_name="user",
+                to="auth.Group",
+                verbose_name="groups",
             ),
         ),
     ]

+ 104 - 114
misago/users/migrations/0006_update_settings.py

@@ -8,169 +8,159 @@ _ = lambda s: s
 
 def update_users_settings(apps, schema_editor):
     migrate_settings_group(
-        apps, {
-            'key': 'users',
-            'name': _("Users"),
-            'description': _(
+        apps,
+        {
+            "key": "users",
+            "name": _("Users"),
+            "description": _(
                 "Those settings control user accounts default behaviour and features availability."
             ),
-            'settings': [
+            "settings": [
                 {
-                    'setting': 'account_activation',
-                    'name': _("New accounts activation"),
-                    'legend': _("New accounts"),
-                    'value': 'none',
-                    'form_field': 'select',
-                    'field_extra': {
-                        'choices': [
-                            ('none', _("No activation required")),
-                            ('user', _("Activation token sent to User")),
-                            ('admin', _("Activation by administrator")),
-                            ('closed', _("Don't allow new registrations")),
-                        ],
+                    "setting": "account_activation",
+                    "name": _("New accounts activation"),
+                    "legend": _("New accounts"),
+                    "value": "none",
+                    "form_field": "select",
+                    "field_extra": {
+                        "choices": [
+                            ("none", _("No activation required")),
+                            ("user", _("Activation token sent to User")),
+                            ("admin", _("Activation by administrator")),
+                            ("closed", _("Don't allow new registrations")),
+                        ]
                     },
-                    'is_public': True,
+                    "is_public": True,
                 },
                 {
-                    'setting': 'username_length_min',
-                    'name': _("Minimum length"),
-                    'description': _("Minimum allowed username length."),
-                    'legend': _("User names"),
-                    'python_type': 'int',
-                    'default_value': 3,
-                    'field_extra': {
-                        'min_value': 2,
-                        'max_value': 20,
-                    },
+                    "setting": "username_length_min",
+                    "name": _("Minimum length"),
+                    "description": _("Minimum allowed username length."),
+                    "legend": _("User names"),
+                    "python_type": "int",
+                    "default_value": 3,
+                    "field_extra": {"min_value": 2, "max_value": 20},
                 },
                 {
-                    'setting': 'username_length_max',
-                    'name': _("Maximum length"),
-                    'description': _("Maximum allowed username length."),
-                    'python_type': 'int',
-                    'default_value': 14,
-                    'field_extra': {
-                        'min_value': 2,
-                        'max_value': 20,
-                    },
+                    "setting": "username_length_max",
+                    "name": _("Maximum length"),
+                    "description": _("Maximum allowed username length."),
+                    "python_type": "int",
+                    "default_value": 14,
+                    "field_extra": {"min_value": 2, "max_value": 20},
                 },
                 {
-                    'setting': 'allow_custom_avatars',
-                    'name': _("Allow custom avatars"),
-                    'legend': _("Avatars"),
-                    'description': _(
+                    "setting": "allow_custom_avatars",
+                    "name": _("Allow custom avatars"),
+                    "legend": _("Avatars"),
+                    "description": _(
                         "Turning this option off will forbid "
                         "forum users from using avatars from "
                         "outside forums. Good for forums "
                         "adressed at young users."
                     ),
-                    'python_type': 'bool',
-                    'value': True,
-                    'form_field': 'yesno',
+                    "python_type": "bool",
+                    "value": True,
+                    "form_field": "yesno",
                 },
                 {
-                    'setting': 'default_avatar',
-                    'name': _("Default avatar"),
-                    'value': 'gravatar',
-                    'form_field': 'select',
-                    'field_extra': {
-                        'choices': [
-                            ('dynamic', _("Individual")),
-                            ('gravatar', _("Gravatar")),
-                            ('gallery', _("Random avatar from gallery")),
-                        ],
+                    "setting": "default_avatar",
+                    "name": _("Default avatar"),
+                    "value": "gravatar",
+                    "form_field": "select",
+                    "field_extra": {
+                        "choices": [
+                            ("dynamic", _("Individual")),
+                            ("gravatar", _("Gravatar")),
+                            ("gallery", _("Random avatar from gallery")),
+                        ]
                     },
                 },
                 {
-                    'setting': 'default_gravatar_fallback',
-                    'name': _("Fallback for default gravatar"),
-                    'description': _(
+                    "setting": "default_gravatar_fallback",
+                    "name": _("Fallback for default gravatar"),
+                    "description": _(
                         "Select which avatar to use when user has no "
                         "gravatar associated with his e-mail address."
                     ),
-                    'value': 'dynamic',
-                    'form_field': 'select',
-                    'field_extra': {
-                        'choices': [
-                            ('dynamic', _("Individual")),
-                            ('gallery', _("Random avatar from gallery")),
-                        ],
+                    "value": "dynamic",
+                    "form_field": "select",
+                    "field_extra": {
+                        "choices": [
+                            ("dynamic", _("Individual")),
+                            ("gallery", _("Random avatar from gallery")),
+                        ]
                     },
                 },
                 {
-                    'setting': 'avatar_upload_limit',
-                    'name': _("Maximum size of uploaded avatar"),
-                    'description': _("Enter maximum allowed file size (in KB) for avatar uploads."),
-                    'python_type': 'int',
-                    'default_value': 1536,
-                    'field_extra': {
-                        'min_value': 0,
-                    },
-                    'is_public': True,
+                    "setting": "avatar_upload_limit",
+                    "name": _("Maximum size of uploaded avatar"),
+                    "description": _(
+                        "Enter maximum allowed file size (in KB) for avatar uploads."
+                    ),
+                    "python_type": "int",
+                    "default_value": 1536,
+                    "field_extra": {"min_value": 0},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'signature_length_max',
-                    'name': _("Maximum length"),
-                    'legend': _("Signatures"),
-                    'description': _("Maximum allowed signature length."),
-                    'python_type': 'int',
-                    'default_value': 256,
-                    'field_extra': {
-                        'min_value': 10,
-                        'max_value': 5000,
-                    },
-                    'is_public': True,
+                    "setting": "signature_length_max",
+                    "name": _("Maximum length"),
+                    "legend": _("Signatures"),
+                    "description": _("Maximum allowed signature length."),
+                    "python_type": "int",
+                    "default_value": 256,
+                    "field_extra": {"min_value": 10, "max_value": 5000},
+                    "is_public": True,
                 },
                 {
-                    'setting': 'subscribe_start',
-                    'name': _("Started threads"),
-                    'legend': _("Default subscriptions settings"),
-                    'value': 'watch_email',
-                    'form_field': 'select',
-                    'field_extra': {
-                        'choices': [
-                            ('no', _("Don't watch")),
-                            ('watch', _("Put on watched threads list")),
+                    "setting": "subscribe_start",
+                    "name": _("Started threads"),
+                    "legend": _("Default subscriptions settings"),
+                    "value": "watch_email",
+                    "form_field": "select",
+                    "field_extra": {
+                        "choices": [
+                            ("no", _("Don't watch")),
+                            ("watch", _("Put on watched threads list")),
                             (
-                                'watch_email', _(
+                                "watch_email",
+                                _(
                                     "Put on watched threads "
                                     "list and e-mail user when "
                                     "somebody replies"
-                                )
+                                ),
                             ),
-                        ],
+                        ]
                     },
                 },
                 {
-                    'setting': 'subscribe_reply',
-                    'name': _("Replied threads"),
-                    'value': 'watch_email',
-                    'form_field': 'select',
-                    'field_extra': {
-                        'choices': [
-                            ('no', _("Don't watch")),
-                            ('watch', _("Put on watched threads list")),
+                    "setting": "subscribe_reply",
+                    "name": _("Replied threads"),
+                    "value": "watch_email",
+                    "form_field": "select",
+                    "field_extra": {
+                        "choices": [
+                            ("no", _("Don't watch")),
+                            ("watch", _("Put on watched threads list")),
                             (
-                                'watch_email', _(
+                                "watch_email",
+                                _(
                                     "Put on watched threads "
                                     "list and e-mail user when "
                                     "somebody replies"
-                                )
+                                ),
                             ),
-                        ],
+                        ]
                     },
                 },
             ],
-        }
+        },
     )
 
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_users', '0005_dj_19_update'),
-    ]
+    dependencies = [("misago_users", "0005_dj_19_update")]
 
-    operations = [
-        migrations.RunPython(update_users_settings),
-    ]
+    operations = [migrations.RunPython(update_users_settings)]

+ 11 - 12
misago/users/migrations/0007_auto_20170219_1639.py

@@ -4,30 +4,29 @@ from django.db import migrations, models
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_users', '0006_update_settings'),
-    ]
+    dependencies = [("misago_users", "0006_update_settings")]
 
     operations = [
         migrations.AlterField(
-            model_name='user',
-            name='limits_private_thread_invites_to',
+            model_name="user",
+            name="limits_private_thread_invites_to",
             field=models.PositiveIntegerField(
-                choices=[(0, 'Everybody'), (1, 'Users I follow'), (2, 'Nobody')], default=0
+                choices=[(0, "Everybody"), (1, "Users I follow"), (2, "Nobody")],
+                default=0,
             ),
         ),
         migrations.AlterField(
-            model_name='user',
-            name='subscribe_to_replied_threads',
+            model_name="user",
+            name="subscribe_to_replied_threads",
             field=models.PositiveIntegerField(
-                choices=[(0, 'No'), (1, 'Notify'), (2, 'Notify with e-mail')], default=0
+                choices=[(0, "No"), (1, "Notify"), (2, "Notify with e-mail")], default=0
             ),
         ),
         migrations.AlterField(
-            model_name='user',
-            name='subscribe_to_started_threads',
+            model_name="user",
+            name="subscribe_to_started_threads",
             field=models.PositiveIntegerField(
-                choices=[(0, 'No'), (1, 'Notify'), (2, 'Notify with e-mail')], default=0
+                choices=[(0, "No"), (1, "Notify"), (2, "Notify with e-mail")], default=0
             ),
         ),
     ]

+ 7 - 9
misago/users/migrations/0008_ban_registration_only.py

@@ -4,23 +4,21 @@ from django.db import migrations, models
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_users', '0007_auto_20170219_1639'),
-    ]
+    dependencies = [("misago_users", "0007_auto_20170219_1639")]
 
     operations = [
         migrations.AddField(
-            model_name='ban',
-            name='registration_only',
+            model_name="ban",
+            name="registration_only",
             field=models.BooleanField(db_index=True, default=False),
         ),
         migrations.AlterField(
-            model_name='ban',
-            name='check_type',
+            model_name="ban",
+            name="check_type",
             field=models.PositiveIntegerField(
-                choices=[(0, 'Username'), (1, 'E-mail address'), (2, 'IP address')],
+                choices=[(0, "Username"), (1, "E-mail address"), (2, "IP address")],
                 db_index=True,
-                default=0
+                default=0,
             ),
         ),
     ]

+ 13 - 7
misago/users/migrations/0009_redo_partial_indexes.py

@@ -5,17 +5,23 @@ import misago.core.pgutils
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_users', '0008_ban_registration_only'),
-    ]
+    dependencies = [("misago_users", "0008_ban_registration_only")]
 
     operations = [
         migrations.AddIndex(
-            model_name='user',
-            index=misago.core.pgutils.PgPartialIndex(fields=['is_staff'], name='misago_user_is_staf_bf68aa_part', where={'is_staff': True}),
+            model_name="user",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["is_staff"],
+                name="misago_user_is_staf_bf68aa_part",
+                where={"is_staff": True},
+            ),
         ),
         migrations.AddIndex(
-            model_name='user',
-            index=misago.core.pgutils.PgPartialIndex(fields=['requires_activation'], name='misago_user_require_05204a_part', where={'requires_activation__gt': 0}),
+            model_name="user",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["requires_activation"],
+                name="misago_user_require_05204a_part",
+                where={"requires_activation__gt": 0},
+            ),
         ),
     ]

+ 3 - 5
misago/users/migrations/0010_user_profile_fields.py

@@ -6,15 +6,13 @@ from django.db import migrations
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_users', '0009_redo_partial_indexes'),
-    ]
+    dependencies = [("misago_users", "0009_redo_partial_indexes")]
 
     operations = [
         HStoreExtension(),
         migrations.AddField(
-            model_name='user',
-            name='profile_fields',
+            model_name="user",
+            name="profile_fields",
             field=django.contrib.postgres.fields.HStoreField(default=dict),
         ),
     ]

+ 9 - 7
misago/users/migrations/0011_auto_20180331_2208.py

@@ -5,18 +5,20 @@ import misago.core.pgutils
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_users', '0010_user_profile_fields'),
-    ]
+    dependencies = [("misago_users", "0010_user_profile_fields")]
 
     operations = [
         migrations.AddField(
-            model_name='user',
-            name='is_deleting_account',
+            model_name="user",
+            name="is_deleting_account",
             field=models.BooleanField(default=False),
         ),
         migrations.AddIndex(
-            model_name='user',
-            index=misago.core.pgutils.PgPartialIndex(fields=['is_deleting_account'], name='misago_user_is_dele_2798b0_part', where={'is_deleting_account': True}),
+            model_name="user",
+            index=misago.core.pgutils.PgPartialIndex(
+                fields=["is_deleting_account"],
+                name="misago_user_is_dele_2798b0_part",
+                where={"is_deleting_account": True},
+            ),
         ),
     ]

+ 36 - 13
misago/users/migrations/0012_audittrail.py

@@ -8,23 +8,46 @@ import django.utils.timezone
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('contenttypes', '0002_remove_content_type_name'),
-        ('misago_users', '0011_auto_20180331_2208'),
+        ("contenttypes", "0002_remove_content_type_name"),
+        ("misago_users", "0011_auto_20180331_2208"),
     ]
 
     operations = [
         migrations.CreateModel(
-            name='AuditTrail',
+            name="AuditTrail",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('object_id', models.PositiveIntegerField()),
-                ('created_on', models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
-                ('ip_address', models.GenericIPAddressField()),
-                ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
-                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                ("object_id", models.PositiveIntegerField()),
+                (
+                    "created_on",
+                    models.DateTimeField(
+                        db_index=True, default=django.utils.timezone.now
+                    ),
+                ),
+                ("ip_address", models.GenericIPAddressField()),
+                (
+                    "content_type",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to="contenttypes.ContentType",
+                    ),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
             ],
-            options={
-                'ordering': ['-pk'],
-            },
-        ),
+            options={"ordering": ["-pk"]},
+        )
     ]

+ 5 - 13
misago/users/migrations/0013_auto_20180609_1523.py

@@ -4,22 +4,14 @@ from django.db import migrations, models
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_users', '0012_audittrail'),
-    ]
+    dependencies = [("misago_users", "0012_audittrail")]
 
     operations = [
-        migrations.RemoveField(
-            model_name='online',
-            name='current_ip',
-        ),
-        migrations.RemoveField(
-            model_name='user',
-            name='last_ip',
-        ),
+        migrations.RemoveField(model_name="online", name="current_ip"),
+        migrations.RemoveField(model_name="user", name="last_ip"),
         migrations.AlterField(
-            model_name='user',
-            name='joined_from_ip',
+            model_name="user",
+            name="joined_from_ip",
             field=models.GenericIPAddressField(blank=True, null=True),
         ),
     ]

+ 58 - 16
misago/users/migrations/0014_datadownload.py

@@ -8,25 +8,67 @@ import misago.users.models.datadownload
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_users', '0013_auto_20180609_1523'),
-    ]
+    dependencies = [("misago_users", "0013_auto_20180609_1523")]
 
     operations = [
         migrations.CreateModel(
-            name='DataDownload',
+            name="DataDownload",
             fields=[
-                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
-                ('status', models.PositiveIntegerField(choices=[(0, 'Pending'), (1, 'Processing'), (2, 'Ready'), (3, 'Expired')], db_index=True, default=0)),
-                ('requester_name', models.CharField(max_length=255)),
-                ('requested_on', models.DateTimeField(default=django.utils.timezone.now)),
-                ('expires_on', models.DateTimeField(default=django.utils.timezone.now)),
-                ('file', models.FileField(blank=True, max_length=255, null=True, upload_to=misago.users.models.datadownload.get_data_upload_to)),
-                ('requester', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
-                ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+                (
+                    "id",
+                    models.AutoField(
+                        auto_created=True,
+                        primary_key=True,
+                        serialize=False,
+                        verbose_name="ID",
+                    ),
+                ),
+                (
+                    "status",
+                    models.PositiveIntegerField(
+                        choices=[
+                            (0, "Pending"),
+                            (1, "Processing"),
+                            (2, "Ready"),
+                            (3, "Expired"),
+                        ],
+                        db_index=True,
+                        default=0,
+                    ),
+                ),
+                ("requester_name", models.CharField(max_length=255)),
+                (
+                    "requested_on",
+                    models.DateTimeField(default=django.utils.timezone.now),
+                ),
+                ("expires_on", models.DateTimeField(default=django.utils.timezone.now)),
+                (
+                    "file",
+                    models.FileField(
+                        blank=True,
+                        max_length=255,
+                        null=True,
+                        upload_to=misago.users.models.datadownload.get_data_upload_to,
+                    ),
+                ),
+                (
+                    "requester",
+                    models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name="+",
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
+                (
+                    "user",
+                    models.ForeignKey(
+                        on_delete=django.db.models.deletion.PROTECT,
+                        to=settings.AUTH_USER_MODEL,
+                    ),
+                ),
             ],
-            options={
-                'ordering': ['-pk'],
-            },
-        ),
+            options={"ordering": ["-pk"]},
+        )
     ]

+ 7 - 7
misago/users/migrations/0015_user_agreements.py

@@ -5,14 +5,14 @@ from django.db import migrations, models
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_users', '0014_datadownload'),
-    ]
+    dependencies = [("misago_users", "0014_datadownload")]
 
     operations = [
         migrations.AddField(
-            model_name='user',
-            name='agreements',
-            field=django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(), default=list, size=None),
-        ),
+            model_name="user",
+            name="agreements",
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.PositiveIntegerField(), default=list, size=None
+            ),
+        )
     ]

+ 3 - 5
misago/users/migrations/0016_cache_version.py

@@ -7,10 +7,8 @@ from misago.cache.operations import StartCacheVersioning
 class Migration(migrations.Migration):
 
     dependencies = [
-        ('misago_users', '0015_user_agreements'),
-        ('misago_cache', '0001_initial'),
+        ("misago_users", "0015_user_agreements"),
+        ("misago_cache", "0001_initial"),
     ]
 
-    operations = [
-        StartCacheVersioning("bans")
-    ]
+    operations = [StartCacheVersioning("bans")]

+ 6 - 14
misago/users/migrations/0017_move_bans_to_cache_version.py

@@ -11,22 +11,14 @@ def populate_cache_version(apps, _):
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-        ('misago_users', '0016_cache_version'),
-    ]
+    dependencies = [("misago_users", "0016_cache_version")]
 
     operations = [
-        migrations.RemoveField(
-            model_name='bancache',
-            name='bans_version',
-        ),
-        migrations.RunPython(
-            populate_cache_version,
-            migrations.RunPython.noop,
-        ),
+        migrations.RemoveField(model_name="bancache", name="bans_version"),
+        migrations.RunPython(populate_cache_version, migrations.RunPython.noop),
         migrations.AddField(
-            model_name='bancache',
-            name='cache_version',
+            model_name="bancache",
+            name="cache_version",
             field=models.CharField(max_length=8),
         ),
-    ]
+    ]

+ 1 - 3
misago/users/models/activityranking.py

@@ -4,8 +4,6 @@ from django.db import models
 
 class ActivityRanking(models.Model):
     user = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        related_name='+',
-        on_delete=models.CASCADE,
+        settings.AUTH_USER_MODEL, related_name="+", on_delete=models.CASCADE
     )
     score = models.PositiveIntegerField(default=0, db_index=True)

+ 2 - 2
misago/users/models/audittrail.py

@@ -12,7 +12,7 @@ class AuditTrail(models.Model):
 
     content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
     object_id = models.PositiveIntegerField()
-    content_object = GenericForeignKey('content_type', 'object_id')
+    content_object = GenericForeignKey("content_type", "object_id")
 
     class Meta:
-        ordering = ['-pk']
+        ordering = ["-pk"]

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

@@ -8,8 +8,7 @@ class AvatarGallery(models.Model):
     image = models.ImageField(max_length=255, upload_to=store.upload_to)
 
     class Meta:
-        ordering = ['gallery', 'pk']
-
+        ordering = ["gallery", "pk"]
 
     @property
     def url(self):

+ 26 - 30
misago/users/models/ban.py

@@ -11,22 +11,13 @@ from misago.users.constants import BANS_CACHE
 
 class BansManager(models.Manager):
     def get_ip_ban(self, ip, registration_only=False):
-        return self.get_ban(
-            ip=ip,
-            registration_only=registration_only,
-        )
+        return self.get_ban(ip=ip, registration_only=registration_only)
 
     def get_username_ban(self, username, registration_only=False):
-        return self.get_ban(
-            username=username,
-            registration_only=registration_only,
-        )
+        return self.get_ban(username=username, registration_only=registration_only)
 
     def get_email_ban(self, email, registration_only=False):
-        return self.get_ban(
-            email=email,
-            registration_only=registration_only,
-        )
+        return self.get_ban(email=email, registration_only=registration_only)
 
     def invalidate_cache(self):
         invalidate_cache(BANS_CACHE)
@@ -52,17 +43,23 @@ class BansManager(models.Manager):
         elif checks:
             queryset = queryset.filter(check_type__in=checks)
 
-        for ban in queryset.order_by('-id').iterator():
+        for ban in queryset.order_by("-id").iterator():
             if ban.is_expired:
                 continue
-            elif (ban.check_type == self.model.USERNAME and username and ban.check_value(username)):
+            elif (
+                ban.check_type == self.model.USERNAME
+                and username
+                and ban.check_value(username)
+            ):
                 return ban
-            elif (ban.check_type == self.model.EMAIL and email and ban.check_value(email)):
+            elif (
+                ban.check_type == self.model.EMAIL and email and ban.check_value(email)
+            ):
                 return ban
             elif ban.check_type == self.model.IP and ip and ban.check_value(ip):
                 return ban
         else:
-            raise Ban.DoesNotExist('specified values are not banned')
+            raise Ban.DoesNotExist("specified values are not banned")
 
 
 class Ban(models.Model):
@@ -71,12 +68,14 @@ class Ban(models.Model):
     IP = 2
 
     CHOICES = [
-        (USERNAME, _('Username')),
-        (EMAIL, _('E-mail address')),
-        (IP, _('IP address')),
+        (USERNAME, _("Username")),
+        (EMAIL, _("E-mail address")),
+        (IP, _("IP address")),
     ]
 
-    check_type = models.PositiveIntegerField(default=USERNAME, choices=CHOICES, db_index=True)
+    check_type = models.PositiveIntegerField(
+        default=USERNAME, choices=CHOICES, db_index=True
+    )
     registration_only = models.BooleanField(default=False, db_index=True)
     banned_value = models.CharField(max_length=255, db_index=True)
     user_message = models.TextField(null=True, blank=True)
@@ -94,6 +93,7 @@ class Ban(models.Model):
 
     def get_serialized_message(self):
         from misago.users.serializers import BanMessageSerializer
+
         return BanMessageSerializer(self).data
 
     @property
@@ -108,9 +108,9 @@ class Ban(models.Model):
             return False
 
     def check_value(self, value):
-        if '*' in self.banned_value:
-            regex = re.escape(self.banned_value).replace('\*', '(.*?)')
-            return re.search('^%s$' % regex, value) is not None
+        if "*" in self.banned_value:
+            regex = re.escape(self.banned_value).replace("\*", "(.*?)")
+            return re.search("^%s$" % regex, value) is not None
         else:
             return self.banned_value == value
 
@@ -122,15 +122,10 @@ class BanCache(models.Model):
     user = models.OneToOneField(
         settings.AUTH_USER_MODEL,
         primary_key=True,
-        related_name='ban_cache',
+        related_name="ban_cache",
         on_delete=models.CASCADE,
     )
-    ban = models.ForeignKey(
-        Ban,
-        null=True,
-        blank=True,
-        on_delete=models.SET_NULL,
-    )
+    ban = models.ForeignKey(Ban, null=True, blank=True, on_delete=models.SET_NULL)
     cache_version = models.CharField(max_length=8)
     user_message = models.TextField(null=True, blank=True)
     staff_message = models.TextField(null=True, blank=True)
@@ -144,6 +139,7 @@ class BanCache(models.Model):
 
     def get_serialized_message(self):
         from misago.users.serializers import BanMessageSerializer
+
         temp_ban = Ban(
             id=1,
             check_type=Ban.USERNAME,

+ 11 - 8
misago/users/models/datadownload.py

@@ -9,8 +9,11 @@ from django.utils.translation import gettext_lazy as _
 
 def get_data_upload_to(instance, filename):
     user_id_hexdigest = md5(str(instance.user_id).encode()).hexdigest()
-    return 'data-downloads/%s/%s/%s.zip' % (
-        user_id_hexdigest, get_random_string(64), instance.user.slug)
+    return "data-downloads/%s/%s/%s.zip" % (
+        user_id_hexdigest,
+        get_random_string(64),
+        instance.user.slug,
+    )
 
 
 class DataDownload(models.Model):
@@ -28,13 +31,11 @@ class DataDownload(models.Model):
 
     user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
     status = models.PositiveIntegerField(
-        default=STATUS_PENDING,
-        choices=STATUS_CHOICES,
-        db_index=True,
+        default=STATUS_PENDING, choices=STATUS_CHOICES, db_index=True
     )
     requester = models.ForeignKey(
         settings.AUTH_USER_MODEL,
-        related_name='+',
+        related_name="+",
         null=True,
         blank=True,
         on_delete=models.SET_NULL,
@@ -42,10 +43,12 @@ class DataDownload(models.Model):
     requester_name = models.CharField(max_length=255)
     requested_on = models.DateTimeField(default=timezone.now)
     expires_on = models.DateTimeField(default=timezone.now)
-    file = models.FileField(upload_to=get_data_upload_to, max_length=255, null=True, blank=True)
+    file = models.FileField(
+        upload_to=get_data_upload_to, max_length=255, null=True, blank=True
+    )
 
     class Meta:
-        ordering = ['-pk']
+        ordering = ["-pk"]
 
     def delete(self, *args, **kwargs):
         if self.file:

+ 1 - 1
misago/users/models/online.py

@@ -7,7 +7,7 @@ class Online(models.Model):
     user = models.OneToOneField(
         settings.AUTH_USER_MODEL,
         primary_key=True,
-        related_name='online_tracker',
+        related_name="online_tracker",
         on_delete=models.CASCADE,
     )
     last_click = models.DateTimeField(default=timezone.now)

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

@@ -13,7 +13,7 @@ class RankManager(models.Manager):
         with transaction.atomic():
             self.filter(is_default=True).update(is_default=False)
             rank.is_default = True
-            rank.save(update_fields=['is_default'])
+            rank.save(update_fields=["is_default"])
 
 
 class Rank(models.Model):
@@ -21,7 +21,7 @@ class Rank(models.Model):
     slug = models.CharField(unique=True, max_length=255)
     description = models.TextField(null=True, blank=True)
     title = models.CharField(max_length=255, null=True, blank=True)
-    roles = models.ManyToManyField('misago_acl.Role', blank=True)
+    roles = models.ManyToManyField("misago_acl.Role", blank=True)
     css_class = models.CharField(max_length=255, null=True, blank=True)
     is_default = models.BooleanField(default=False)
     is_tab = models.BooleanField(default=False)
@@ -30,7 +30,7 @@ class Rank(models.Model):
     objects = RankManager()
 
     class Meta:
-        get_latest_by = 'order'
+        get_latest_by = "order"
 
     def __str__(self):
         return self.name
@@ -49,7 +49,7 @@ class Rank(models.Model):
         return super().delete(*args, **kwargs)
 
     def get_absolute_url(self):
-        return reverse('misago:users-rank', kwargs={'slug': self.slug})
+        return reverse("misago:users-rank", kwargs={"slug": self.slug})
 
     def set_name(self, name):
         self.name = name
@@ -57,6 +57,6 @@ class Rank(models.Model):
 
     def set_order(self):
         try:
-            self.order = Rank.objects.latest('order').order + 1
+            self.order = Rank.objects.latest("order").order + 1
         except Rank.DoesNotExist:
             self.order = 0

+ 47 - 72
misago/users/models/user.py

@@ -39,7 +39,7 @@ class UserManager(BaseUserManager):
         user.set_email(email)
         user.set_password(password)
 
-        if not 'rank' in extra_fields:
+        if not "rank" in extra_fields:
             user.rank = Rank.objects.get_default()
 
         now = timezone.now()
@@ -54,28 +54,28 @@ class UserManager(BaseUserManager):
         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():
             user.roles.add(authenticated_role)
         user.update_acl_key()
-        user.save(update_fields=['acl_key'])
+        user.save(update_fields=["acl_key"])
 
     def create_user(self, username, email=None, password=None, **extra_fields):
-        extra_fields.setdefault('is_staff', False)
-        extra_fields.setdefault('is_superuser', False)
+        extra_fields.setdefault("is_staff", False)
+        extra_fields.setdefault("is_superuser", False)
         return self._create_user(username, email, password, **extra_fields)
 
     def create_superuser(self, username, email, password=None, **extra_fields):
-        extra_fields.setdefault('is_staff', True)
-        extra_fields.setdefault('is_superuser', True)
+        extra_fields.setdefault("is_staff", True)
+        extra_fields.setdefault("is_superuser", True)
 
-        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.')
+        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:
-            if not extra_fields.get('rank'):
+            if not extra_fields.get("rank"):
                 extra_fields["rank"] = Rank.objects.get(name=_("Forum team"))
         except Rank.DoesNotExist:
             pass
@@ -89,7 +89,7 @@ class UserManager(BaseUserManager):
         return self.get(email_hash=hash_email(email))
 
     def get_by_username_or_email(self, login):
-        if '@' in login:
+        if "@" in login:
             return self.get(email_hash=hash_email(login))
         return self.get(slug=slugify(login))
 
@@ -118,7 +118,7 @@ class User(AbstractBaseUser, PermissionsMixin):
         (LIMIT_INVITES_TO_FOLLOWED, _("Users I follow")),
         (LIMIT_INVITES_TO_NOBODY, _("Nobody")),
     ]
-    
+
     # Note that "username" field is purely for shows.
     # When searching users by their names, always use lowercased string
     # and slug field instead that is normalized around DB engines
@@ -134,30 +134,27 @@ class User(AbstractBaseUser, PermissionsMixin):
     email = models.EmailField(max_length=255, db_index=True)
     email_hash = models.CharField(max_length=32, unique=True)
 
-    joined_on = models.DateTimeField(_('joined on'), default=timezone.now)
+    joined_on = models.DateTimeField(_("joined on"), default=timezone.now)
     joined_from_ip = models.GenericIPAddressField(null=True, blank=True)
     is_hiding_presence = models.BooleanField(default=False)
 
     rank = models.ForeignKey(
-        'Rank',
-        null=True,
-        blank=True,
-        on_delete=models.deletion.PROTECT,
+        "Rank", null=True, blank=True, on_delete=models.deletion.PROTECT
     )
     title = models.CharField(max_length=255, null=True, blank=True)
     requires_activation = models.PositiveIntegerField(default=ACTIVATION_NONE)
 
     is_staff = models.BooleanField(
-        _('staff status'),
+        _("staff status"),
         default=False,
-        help_text=_('Designates whether the user can log into admin sites.'),
+        help_text=_("Designates whether the user can log into admin sites."),
     )
 
-    roles = models.ManyToManyField('misago_acl.Role')
+    roles = models.ManyToManyField("misago_acl.Role")
     acl_key = models.CharField(max_length=12, null=True, blank=True)
 
     is_active = models.BooleanField(
-        _('active'),
+        _("active"),
         db_index=True,
         default=True,
         help_text=_(
@@ -170,16 +167,10 @@ class User(AbstractBaseUser, PermissionsMixin):
     is_deleting_account = models.BooleanField(default=False)
 
     avatar_tmp = models.ImageField(
-        max_length=255,
-        upload_to=avatars.store.upload_to,
-        null=True,
-        blank=True,
+        max_length=255, upload_to=avatars.store.upload_to, null=True, blank=True
     )
     avatar_src = models.ImageField(
-        max_length=255,
-        upload_to=avatars.store.upload_to,
-        null=True,
-        blank=True,
+        max_length=255, upload_to=avatars.store.upload_to, null=True, blank=True
     )
     avatar_crop = models.CharField(max_length=255, null=True, blank=True)
     avatars = JSONField(null=True, blank=True)
@@ -198,30 +189,23 @@ class User(AbstractBaseUser, PermissionsMixin):
     following = models.PositiveIntegerField(default=0)
 
     follows = models.ManyToManyField(
-        'self',
-        related_name='followed_by',
-        symmetrical=False,
+        "self", related_name="followed_by", symmetrical=False
     )
     blocks = models.ManyToManyField(
-        'self',
-        related_name='blocked_by',
-        symmetrical=False,
+        "self", related_name="blocked_by", symmetrical=False
     )
 
     limits_private_thread_invites_to = models.PositiveIntegerField(
-        default=LIMIT_INVITES_TO_NONE,
-        choices=LIMIT_INVITES_TO_CHOICES,
+        default=LIMIT_INVITES_TO_NONE, choices=LIMIT_INVITES_TO_CHOICES
     )
     unread_private_threads = models.PositiveIntegerField(default=0)
     sync_unread_private_threads = models.BooleanField(default=False)
 
     subscribe_to_started_threads = models.PositiveIntegerField(
-        default=SUBSCRIPTION_NONE,
-        choices=SUBSCRIPTION_CHOICES,
+        default=SUBSCRIPTION_NONE, choices=SUBSCRIPTION_CHOICES
     )
     subscribe_to_replied_threads = models.PositiveIntegerField(
-        default=SUBSCRIPTION_NONE,
-        choices=SUBSCRIPTION_CHOICES,
+        default=SUBSCRIPTION_NONE, choices=SUBSCRIPTION_CHOICES
     )
 
     threads = models.PositiveIntegerField(default=0)
@@ -232,24 +216,19 @@ class User(AbstractBaseUser, PermissionsMixin):
     profile_fields = HStoreField(default=dict)
     agreements = ArrayField(models.PositiveIntegerField(), default=list)
 
-    USERNAME_FIELD = 'slug'
-    REQUIRED_FIELDS = ['email']
+    USERNAME_FIELD = "slug"
+    REQUIRED_FIELDS = ["email"]
 
     objects = UserManager()
 
     class Meta:
         indexes = [
+            PgPartialIndex(fields=["is_staff"], where={"is_staff": True}),
             PgPartialIndex(
-                fields=['is_staff'],
-                where={'is_staff': True},
+                fields=["requires_activation"], where={"requires_activation__gt": 0}
             ),
             PgPartialIndex(
-                fields=['requires_activation'],
-                where={'requires_activation__gt': 0},
-            ),
-            PgPartialIndex(
-                fields=['is_deleting_account'],
-                where={'is_deleting_account': True},
+                fields=["is_deleting_account"], where={"is_deleting_account": True}
             ),
         ]
 
@@ -262,7 +241,7 @@ class User(AbstractBaseUser, PermissionsMixin):
         return User.objects.select_for_update().get(pk=self.pk)
 
     def delete(self, *args, **kwargs):
-        if kwargs.pop('delete_content', False):
+        if kwargs.pop("delete_content", False):
             self.delete_content()
 
         self.anonymize_data()
@@ -273,12 +252,13 @@ class User(AbstractBaseUser, PermissionsMixin):
 
     def delete_content(self):
         from misago.users.signals import delete_user_content
+
         delete_user_content.send(sender=self)
 
     def mark_for_delete(self):
         self.is_active = False
         self.is_deleting_account = True
-        self.save(update_fields=['is_active', 'is_deleting_account'])
+        self.save(update_fields=["is_active", "is_deleting_account"])
 
     def anonymize_data(self):
         """Replaces username with anonymized one, then send anonymization signal.
@@ -288,8 +268,9 @@ class User(AbstractBaseUser, PermissionsMixin):
         """
         self.username = settings.MISAGO_ANONYMOUS_USERNAME
         self.slug = slugify(self.username)
-        
+
         from misago.users.signals import anonymize_user_data
+
         anonymize_user_data.send(sender=self)
 
     @property
@@ -320,12 +301,7 @@ class User(AbstractBaseUser, PermissionsMixin):
         return is_user_signature_valid(self)
 
     def get_absolute_url(self):
-        return reverse(
-            'misago:user', kwargs={
-                'slug': self.slug,
-                'pk': self.pk,
-            }
-        )
+        return reverse("misago:user", kwargs={"slug": self.slug, "pk": self.pk})
 
     def get_username(self):
         """dirty hack: return real username instead of normalized slug"""
@@ -338,7 +314,7 @@ class User(AbstractBaseUser, PermissionsMixin):
         return self.username
 
     def get_real_name(self):
-        return self.profile_fields.get('real_name')
+        return self.profile_fields.get("real_name")
 
     def set_username(self, new_username, changed_by=None):
         new_username = self.normalize_username(new_username)
@@ -354,6 +330,7 @@ class User(AbstractBaseUser, PermissionsMixin):
                 )
 
                 from misago.users.signals import username_changed
+
                 username_changed.send(sender=self)
 
                 return namechange
@@ -394,12 +371,12 @@ class User(AbstractBaseUser, PermissionsMixin):
     def update_acl_key(self):
         roles_pks = []
         for role in self.get_roles():
-            if role.origin == 'self':
-                roles_pks.append('u%s' % role.pk)
+            if role.origin == "self":
+                roles_pks.append("u%s" % role.pk)
             else:
-                roles_pks.append('%s:%s' % (self.rank.pk, role.pk))
+                roles_pks.append("%s:%s" % (self.rank.pk, role.pk))
 
-        self.acl_key = md5(','.join(roles_pks).encode()).hexdigest()[:12]
+        self.acl_key = md5(",".join(roles_pks).encode()).hexdigest()[:12]
 
     def email_user(self, subject, message, from_email=None, **kwargs):
         """sends an email to this user (for compat with Django)"""
@@ -432,15 +409,13 @@ class User(AbstractBaseUser, PermissionsMixin):
 
 class UsernameChange(models.Model):
     user = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        related_name='namechanges',
-        on_delete=models.CASCADE,
+        settings.AUTH_USER_MODEL, related_name="namechanges", on_delete=models.CASCADE
     )
     changed_by = models.ForeignKey(
         settings.AUTH_USER_MODEL,
         null=True,
         blank=True,
-        related_name='user_renames',
+        related_name="user_renames",
         on_delete=models.SET_NULL,
     )
     changed_by_username = models.CharField(max_length=30)
@@ -457,7 +432,7 @@ class UsernameChange(models.Model):
 
 
 class AnonymousUser(DjangoAnonymousUser):
-    acl_key = 'anonymous'
+    acl_key = "anonymous"
 
     @property
     def acl_cache(self):

+ 9 - 9
misago/users/namechanges.py

@@ -13,15 +13,15 @@ def get_username_options(settings, 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,
+        "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']
+    name_changes_allowed = user_acl["name_changes_allowed"]
     if not name_changes_allowed:
         return 0
 
@@ -33,19 +33,19 @@ def get_left_namechanges(user, user_acl):
 
 
 def get_next_available_namechange(user, user_acl, changes_left):
-    name_changes_expire = user_acl['name_changes_expire']
+    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']
+    name_changes_expire = user_acl["name_changes_expire"]
     queryset = user.namechanges.filter(changed_by=user)
-    if user_acl['name_changes_expire']:
+    if user_acl["name_changes_expire"]:
         cutoff = timezone.now() - timedelta(days=name_changes_expire)
         return queryset.filter(changed_on__gte=cutoff)
     return queryset

+ 3 - 3
misago/users/online/tracker.py

@@ -19,18 +19,18 @@ def start_tracking(request, user):
 def update_tracker(request, tracker):
     tracker.last_click = timezone.now()
 
-    tracker.save(update_fields=['last_click'])
+    tracker.save(update_fields=["last_click"])
 
 
 def stop_tracking(request, tracker):
     user = tracker.user
     user.last_login = tracker.last_click
-    user.save(update_fields=['last_login'])
+    user.save(update_fields=["last_login"])
 
     tracker.delete()
 
 
 def clear_tracking(request):
     if isinstance(request, Request):
-        request = request._request  # Fugly unwrap restframework's request 
+        request = request._request  # Fugly unwrap restframework's request
     request._misago_online_tracker = None

+ 27 - 26
misago/users/online/utils.py

@@ -9,7 +9,6 @@ from misago.users.models import BanCache, Online
 ACTIVITY_CUTOFF = timedelta(minutes=2)
 
 
-
 def make_users_status_aware(request, users, fetch_state=False):
     users_dict = {}
     for user in users:
@@ -31,47 +30,49 @@ def make_users_status_aware(request, users, fetch_state=False):
 
 def get_user_status(request, user):
     user_status = {
-        'is_banned': False,
-        'is_hidden': user.is_hiding_presence,
-        'is_online_hidden': False,
-        'is_offline_hidden': False,
-        'is_online': False,
-        'is_offline': False,
-        'banned_until': None,
-        'last_click': user.last_login or user.joined_on,
+        "is_banned": False,
+        "is_hidden": user.is_hiding_presence,
+        "is_online_hidden": False,
+        "is_offline_hidden": False,
+        "is_online": False,
+        "is_offline": False,
+        "banned_until": None,
+        "last_click": user.last_login or user.joined_on,
     }
 
     user_ban = get_user_ban(user, request.cache_versions)
     if user_ban:
-        user_status['is_banned'] = True
-        user_status['banned_until'] = user_ban.expires_on
+        user_status["is_banned"] = True
+        user_status["banned_until"] = user_ban.expires_on
 
     try:
         online_tracker = user.online_tracker
-        is_hidden = user.is_hiding_presence and not request.user_acl['can_see_hidden_users']
+        is_hidden = (
+            user.is_hiding_presence and not request.user_acl["can_see_hidden_users"]
+        )
 
         if online_tracker and not is_hidden:
             if online_tracker.last_click >= timezone.now() - ACTIVITY_CUTOFF:
-                user_status['is_online'] = True
-                user_status['last_click'] = online_tracker.last_click
+                user_status["is_online"] = True
+                user_status["last_click"] = online_tracker.last_click
     except Online.DoesNotExist:
         pass
 
-    if user_status['is_hidden']:
-        if request.user_acl['can_see_hidden_users']:
-            user_status['is_hidden'] = False
-            if user_status['is_online']:
-                user_status['is_online_hidden'] = True
-                user_status['is_online'] = False
+    if user_status["is_hidden"]:
+        if request.user_acl["can_see_hidden_users"]:
+            user_status["is_hidden"] = False
+            if user_status["is_online"]:
+                user_status["is_online_hidden"] = True
+                user_status["is_online"] = False
             else:
-                user_status['is_offline_hidden'] = True
-                user_status['is_offline'] = False
+                user_status["is_offline_hidden"] = True
+                user_status["is_offline"] = False
         else:
-            user_status['is_hidden'] = True
+            user_status["is_hidden"] = True
     else:
-        if user_status['is_online']:
-            user_status['is_online'] = True
+        if user_status["is_online"]:
+            user_status["is_online"] = True
         else:
-            user_status['is_offline'] = True
+            user_status["is_offline"] = True
 
     return user_status

+ 3 - 3
misago/users/pages.py

@@ -1,6 +1,6 @@
 from misago.core.page import Page
 
 
-usercp = Page('usercp')
-users_list = Page('users list')
-user_profile = Page('user profile')
+usercp = Page("usercp")
+users_list = Page("users list")
+user_profile = Page("user profile")

+ 10 - 10
misago/users/permissions/account.py

@@ -20,7 +20,7 @@ class PermissionsForm(forms.Form):
             "zero to make all changes count."
         ),
         min_value=0,
-        initial=0
+        initial=0,
     )
     can_have_signature = YesNoSwitch(label=_("Can have signature"))
     allow_signature_links = YesNoSwitch(label=_("Can put links in signature"))
@@ -30,12 +30,12 @@ class PermissionsForm(forms.Form):
         help_text=_(
             "Controls whether or not users can put quote, code, "
             "spoiler blocks and horizontal lines in signatures."
-        )
+        ),
     )
 
 
 def change_permissions_form(role):
-    if isinstance(role, Role) and role.special_role != 'anonymous':
+    if isinstance(role, Role) and role.special_role != "anonymous":
         return PermissionsForm
     else:
         return None
@@ -43,12 +43,12 @@ def change_permissions_form(role):
 
 def build_acl(acl, roles, key_name):
     new_acl = {
-        'name_changes_allowed': 0,
-        'name_changes_expire': 0,
-        'can_have_signature': 0,
-        'allow_signature_links': 0,
-        'allow_signature_images': 0,
-        'allow_signature_blocks': 0,
+        "name_changes_allowed": 0,
+        "name_changes_expire": 0,
+        "can_have_signature": 0,
+        "allow_signature_links": 0,
+        "allow_signature_images": 0,
+        "allow_signature_blocks": 0,
     }
     new_acl.update(acl)
 
@@ -61,5 +61,5 @@ def build_acl(acl, roles, key_name):
         can_have_signature=algebra.greater,
         allow_signature_links=algebra.greater,
         allow_signature_images=algebra.greater,
-        allow_signature_blocks=algebra.greater
+        allow_signature_blocks=algebra.greater,
     )

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

@@ -2,20 +2,15 @@ from django.core.exceptions import PermissionDenied
 from django.utils.translation import gettext_lazy as _
 
 
-__all__ = [
-    'authenticated_only',
-    'anonymous_only',
-]
+__all__ = ["authenticated_only", "anonymous_only"]
 
 
 def authenticated_only(f):
     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.")
-            )
+        else:
+            raise PermissionDenied(_("You have to sig in to perform this action."))
 
     return perm_decorator
 
@@ -25,8 +20,6 @@ def anonymous_only(f):
         if user_acl["is_anonymous"]:
             return f(user_acl, target)
         else:
-            raise PermissionDenied(
-                _("Only guests can perform this action.")
-            )
+            raise PermissionDenied(_("Only guests can perform this action."))
 
     return perm_decorator

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

@@ -14,10 +14,10 @@ from misago.conf import settings
 
 
 __all__ = [
-    'allow_delete_user',
-    'can_delete_user',
-    'allow_delete_own_account',
-    'can_delete_own_account',
+    "allow_delete_user",
+    "can_delete_user",
+    "allow_delete_own_account",
+    "can_delete_own_account",
 ]
 
 
@@ -39,7 +39,7 @@ class PermissionsForm(forms.Form):
 
 
 def change_permissions_form(role):
-    if isinstance(role, Role) and role.special_role != 'anonymous':
+    if isinstance(role, Role) and role.special_role != "anonymous":
         return PermissionsForm
     else:
         return None
@@ -47,8 +47,8 @@ def change_permissions_form(role):
 
 def build_acl(acl, roles, key_name):
     new_acl = {
-        'can_delete_users_newer_than': 0,
-        'can_delete_users_with_less_posts_than': 0,
+        "can_delete_users_newer_than": 0,
+        "can_delete_users_with_less_posts_than": 0,
     }
     new_acl.update(acl)
 
@@ -62,9 +62,9 @@ def build_acl(acl, roles, key_name):
 
 
 def add_acl_to_user(user_acl, target):
-    target.acl['can_delete'] = can_delete_user(user_acl, target)
-    if target.acl['can_delete']:
-        target.acl['can_moderate'] = True
+    target.acl["can_delete"] = can_delete_user(user_acl, target)
+    if target.acl["can_delete"]:
+        target.acl["can_moderate"] = True
 
 
 def register_with(registry):
@@ -72,8 +72,8 @@ def register_with(registry):
 
 
 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']
+    newer_than = user_acl["can_delete_users_newer_than"]
+    less_posts_than = user_acl["can_delete_users_with_less_posts_than"]
     if not newer_than and not less_posts_than:
         raise PermissionDenied(_("You can't delete users."))
 
@@ -89,7 +89,7 @@ def allow_delete_user(user_acl, target):
                 "You can't delete users that are members for more than %(days)s days.",
                 newer_than,
             )
-            raise PermissionDenied(message % {'days': newer_than})
+            raise PermissionDenied(message % {"days": newer_than})
     if less_posts_than:
         if target.posts > less_posts_than:
             message = ngettext(
@@ -97,7 +97,7 @@ def allow_delete_user(user_acl, target):
                 "You can't delete users that made more than %(posts)s posts.",
                 less_posts_than,
             )
-            raise PermissionDenied(message % {'posts': less_posts_than})
+            raise PermissionDenied(message % {"posts": less_posts_than})
 
 
 can_delete_user = return_boolean(allow_delete_user)

+ 43 - 44
misago/users/permissions/moderation.py

@@ -15,18 +15,18 @@ from misago.users.bans import get_user_ban
 
 
 __all__ = [
-    'allow_rename_user',
-    'can_rename_user',
-    'allow_moderate_avatar',
-    'can_moderate_avatar',
-    'allow_moderate_signature',
-    'can_moderate_signature',
-    'allow_edit_profile_details',
-    'can_edit_profile_details',
-    'allow_ban_user',
-    'can_ban_user',
-    'allow_lift_ban',
-    'can_lift_ban',
+    "allow_rename_user",
+    "can_rename_user",
+    "allow_moderate_avatar",
+    "can_moderate_avatar",
+    "allow_moderate_signature",
+    "can_moderate_signature",
+    "allow_edit_profile_details",
+    "can_edit_profile_details",
+    "allow_ban_user",
+    "can_ban_user",
+    "allow_lift_ban",
+    "can_lift_ban",
 ]
 
 
@@ -54,7 +54,7 @@ class PermissionsForm(forms.Form):
 
 
 def change_permissions_form(role):
-    if isinstance(role, Role) and role.special_role != 'anonymous':
+    if isinstance(role, Role) and role.special_role != "anonymous":
         return PermissionsForm
     else:
         return None
@@ -62,14 +62,14 @@ def change_permissions_form(role):
 
 def build_acl(acl, roles, key_name):
     new_acl = {
-        'can_rename_users': 0,
-        'can_moderate_avatars': 0,
-        'can_moderate_signatures': 0,
-        'can_moderate_profile_details': 0,
-        'can_ban_users': 0,
-        'max_ban_length': 2,
-        'can_lift_bans': 0,
-        'max_lifted_ban_length': 2,
+        "can_rename_users": 0,
+        "can_moderate_avatars": 0,
+        "can_moderate_signatures": 0,
+        "can_moderate_profile_details": 0,
+        "can_ban_users": 0,
+        "max_ban_length": 2,
+        "can_lift_bans": 0,
+        "max_lifted_ban_length": 2,
     }
     new_acl.update(acl)
 
@@ -89,23 +89,19 @@ def build_acl(acl, roles, key_name):
 
 
 def add_acl_to_user(user_acl, target):
-    target.acl['can_rename'] = can_rename_user(user_acl, target)
-    target.acl['can_moderate_avatar'] = can_moderate_avatar(user_acl, target)
-    target.acl['can_moderate_signature'] = can_moderate_signature(user_acl, target)
-    target.acl['can_edit_profile_details'] = can_edit_profile_details(user_acl, target)
-    target.acl['can_ban'] = can_ban_user(user_acl, target)
-    target.acl['max_ban_length'] = user_acl['max_ban_length']
-    target.acl['can_lift_ban'] = can_lift_ban(user_acl, target)
-
-    mod_permissions = [
-        'can_rename',
-        'can_moderate_avatar',
-        'can_moderate_signature',
-    ]
+    target.acl["can_rename"] = can_rename_user(user_acl, target)
+    target.acl["can_moderate_avatar"] = can_moderate_avatar(user_acl, target)
+    target.acl["can_moderate_signature"] = can_moderate_signature(user_acl, target)
+    target.acl["can_edit_profile_details"] = can_edit_profile_details(user_acl, target)
+    target.acl["can_ban"] = can_ban_user(user_acl, target)
+    target.acl["max_ban_length"] = user_acl["max_ban_length"]
+    target.acl["can_lift_ban"] = can_lift_ban(user_acl, target)
+
+    mod_permissions = ["can_rename", "can_moderate_avatar", "can_moderate_signature"]
 
     for permission in mod_permissions:
         if target.acl[permission]:
-            target.acl['can_moderate'] = True
+            target.acl["can_moderate"] = True
             break
 
 
@@ -114,7 +110,7 @@ def register_with(registry):
 
 
 def allow_rename_user(user_acl, target):
-    if not user_acl['can_rename_users']:
+    if not user_acl["can_rename_users"]:
         raise PermissionDenied(_("You can't rename users."))
     if not user_acl["is_superuser"] and (target.is_staff or target.is_superuser):
         raise PermissionDenied(_("You can't rename administrators."))
@@ -124,7 +120,7 @@ can_rename_user = return_boolean(allow_rename_user)
 
 
 def allow_moderate_avatar(user_acl, target):
-    if not user_acl['can_moderate_avatars']:
+    if not user_acl["can_moderate_avatars"]:
         raise PermissionDenied(_("You can't moderate avatars."))
     if not user_acl["is_superuser"] and (target.is_staff or target.is_superuser):
         raise PermissionDenied(_("You can't moderate administrators avatars."))
@@ -134,7 +130,7 @@ can_moderate_avatar = return_boolean(allow_moderate_avatar)
 
 
 def allow_moderate_signature(user_acl, target):
-    if not user_acl['can_moderate_signatures']:
+    if not user_acl["can_moderate_signatures"]:
         raise PermissionDenied(_("You can't moderate signatures."))
     if not user_acl["is_superuser"] and (target.is_staff or target.is_superuser):
         message = _("You can't moderate administrators signatures.")
@@ -147,7 +143,10 @@ can_moderate_signature = return_boolean(allow_moderate_signature)
 def allow_edit_profile_details(user_acl, target):
     if user_acl["is_anonymous"]:
         raise PermissionDenied(_("You have to sign in to edit profile details."))
-    if user_acl["user_id"] != target.id and not user_acl['can_moderate_profile_details']:
+    if (
+        user_acl["user_id"] != target.id
+        and not user_acl["can_moderate_profile_details"]
+    ):
         raise PermissionDenied(_("You can't edit other users details."))
     if not user_acl["is_superuser"] and (target.is_staff or target.is_superuser):
         message = _("You can't edit administrators details.")
@@ -158,7 +157,7 @@ can_edit_profile_details = return_boolean(allow_edit_profile_details)
 
 
 def allow_ban_user(user_acl, target):
-    if not user_acl['can_ban_users']:
+    if not user_acl["can_ban_users"]:
         raise PermissionDenied(_("You can't ban users."))
     if target.is_staff or target.is_superuser:
         raise PermissionDenied(_("You can't ban administrators."))
@@ -168,19 +167,19 @@ can_ban_user = return_boolean(allow_ban_user)
 
 
 def allow_lift_ban(user_acl, target):
-    if not user_acl['can_lift_bans']:
+    if not user_acl["can_lift_bans"]:
         raise PermissionDenied(_("You can't lift bans."))
     ban = get_user_ban(target, user_acl["cache_versions"])
     if not ban:
         raise PermissionDenied(_("This user is not banned."))
-    if user_acl['max_lifted_ban_length']:
-        expiration_limit = timedelta(days=user_acl['max_lifted_ban_length'])
+    if user_acl["max_lifted_ban_length"]:
+        expiration_limit = timedelta(days=user_acl["max_lifted_ban_length"])
         lift_cutoff = (timezone.now() + expiration_limit).date()
         if not ban.valid_until:
             raise PermissionDenied(_("You can't lift permanent bans."))
         elif ban.valid_until > lift_cutoff:
             message = _("You can't lift bans that expire after %(expiration)s.")
-            raise PermissionDenied(message % {'expiration': format_date(lift_cutoff)})
+            raise PermissionDenied(message % {"expiration": format_date(lift_cutoff)})
 
 
 can_lift_ban = return_boolean(allow_lift_ban)

+ 32 - 28
misago/users/permissions/profiles.py

@@ -12,14 +12,14 @@ from .decorators import authenticated_only
 
 
 __all__ = [
-    'allow_browse_users_list',
-    'can_browse_users_list',
-    'allow_follow_user',
-    'can_follow_user',
-    'allow_block_user',
-    'can_block_user',
-    'allow_see_ban_details',
-    'can_see_ban_details',
+    "allow_browse_users_list",
+    "can_browse_users_list",
+    "allow_follow_user",
+    "can_follow_user",
+    "allow_block_user",
+    "can_block_user",
+    "allow_see_ban_details",
+    "can_see_ban_details",
 ]
 
 CAN_BROWSE_USERS_LIST = YesNoSwitch(label=_("Can browse users list"), initial=1)
@@ -27,7 +27,9 @@ CAN_SEARCH_USERS = YesNoSwitch(label=_("Can search user profiles"), initial=1)
 CAN_SEE_USER_NAME_HISTORY = YesNoSwitch(label=_("Can see other members name history"))
 CAN_SEE_DETAILS = YesNoSwitch(
     label=_("Can see members bans details"),
-    help_text=_("Allows users with this permission to see user and staff ban messages.")
+    help_text=_(
+        "Allows users with this permission to see user and staff ban messages."
+    ),
 )
 
 
@@ -49,12 +51,14 @@ class PermissionsForm(LimitedPermissionsForm):
     can_see_ban_details = CAN_SEE_DETAILS
     can_see_users_emails = YesNoSwitch(label=_("Can see members e-mails"))
     can_see_users_ips = YesNoSwitch(label=_("Can see members IPs"))
-    can_see_hidden_users = YesNoSwitch(label=_("Can see members that hide their presence"))
+    can_see_hidden_users = YesNoSwitch(
+        label=_("Can see members that hide their presence")
+    )
 
 
 def change_permissions_form(role):
     if isinstance(role, Role):
-        if role.special_role == 'anonymous':
+        if role.special_role == "anonymous":
             return LimitedPermissionsForm
         else:
             return PermissionsForm
@@ -64,15 +68,15 @@ def change_permissions_form(role):
 
 def build_acl(acl, roles, key_name):
     new_acl = {
-        'can_browse_users_list': 0,
-        'can_search_users': 0,
-        'can_follow_users': 0,
-        'can_be_blocked': 1,
-        'can_see_users_name_history': 0,
-        'can_see_ban_details': 0,
-        'can_see_users_emails': 0,
-        'can_see_users_ips': 0,
-        'can_see_hidden_users': 0,
+        "can_browse_users_list": 0,
+        "can_search_users": 0,
+        "can_follow_users": 0,
+        "can_be_blocked": 1,
+        "can_see_users_name_history": 0,
+        "can_see_ban_details": 0,
+        "can_see_users_emails": 0,
+        "can_see_users_ips": 0,
+        "can_see_hidden_users": 0,
     }
     new_acl.update(acl)
 
@@ -93,15 +97,15 @@ def build_acl(acl, roles, key_name):
 
 
 def add_acl_to_user(user_acl, target):
-    target.acl['can_have_attitude'] = False
-    target.acl['can_follow'] = can_follow_user(user_acl, target)
-    target.acl['can_block'] = can_block_user(user_acl, target)
+    target.acl["can_have_attitude"] = False
+    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")
 
     for permission in mod_permissions:
         if target.acl[permission]:
-            target.acl['can_have_attitude'] = True
+            target.acl["can_have_attitude"] = True
             break
 
 
@@ -110,7 +114,7 @@ def register_with(registry):
 
 
 def allow_browse_users_list(user_acl):
-    if not user_acl['can_browse_users_list']:
+    if not user_acl["can_browse_users_list"]:
         raise PermissionDenied(_("You can't browse users list."))
 
 
@@ -119,7 +123,7 @@ can_browse_users_list = return_boolean(allow_browse_users_list)
 
 @authenticated_only
 def allow_follow_user(user_acl, target):
-    if not user_acl['can_follow_users']:
+    if not user_acl["can_follow_users"]:
         raise PermissionDenied(_("You can't follow other users."))
     if user_acl["user_id"] == target.id:
         raise PermissionDenied(_("You can't add yourself to followed."))
@@ -142,7 +146,7 @@ can_block_user = return_boolean(allow_block_user)
 
 @authenticated_only
 def allow_see_ban_details(user_acl, target):
-    if not user_acl['can_see_ban_details']:
+    if not user_acl["can_see_ban_details"]:
         raise PermissionDenied(_("You can't see users bans details."))
 
 

+ 27 - 30
misago/users/profilefields/__init__.py

@@ -12,7 +12,7 @@ from .basefields import *
 from .serializers import serialize_profilefields_data
 
 
-logger = logging.getLogger('misago.users.ProfileFields')
+logger = logging.getLogger("misago.users.ProfileFields")
 
 
 class ProfileFields(object):
@@ -28,7 +28,7 @@ class ProfileFields(object):
         fieldnames = {}
 
         for group in self.fields_groups:
-            for field_path in group['fields']:
+            for field_path in group["fields"]:
                 field = import_string(field_path)
                 field._field_path = field_path
 
@@ -37,17 +37,19 @@ class ProfileFields(object):
                         "%s profile field has been specified twice" % field._field_path
                     )
 
-                if not getattr(field, 'fieldname', None):
+                if not getattr(field, "fieldname", None):
                     raise ValueError(
-                        "%s profile field has to specify fieldname attribute" % field._field_path
+                        "%s profile field has to specify fieldname attribute"
+                        % field._field_path
                     )
 
                 if field.fieldname in fieldnames:
                     raise ValueError(
                         (
                             '%s profile field defines fieldname "%s" '
-                            'that is already in use by the %s'
-                        ) % (
+                            "that is already in use by the %s"
+                        )
+                        % (
                             field._field_path,
                             field.fieldname,
                             fieldnames[field.fieldname],
@@ -70,16 +72,13 @@ class ProfileFields(object):
 
         groups = []
         for group in self.fields_groups:
-            group_dict = {
-                'name': _(group['name']),
-                'fields': [],
-            }
+            group_dict = {"name": _(group["name"]), "fields": []}
 
-            for field_path in group['fields']:
+            for field_path in group["fields"]:
                 field = self.fields_dict[field_path]
-                group_dict['fields'].append(field)
+                group_dict["fields"].append(field)
 
-            if group_dict['fields']:
+            if group_dict["fields"]:
                 groups.append(group_dict)
         return groups
 
@@ -101,17 +100,14 @@ class ProfileFields(object):
 
         form._profile_fields_groups = []
         for group in self.fields_groups:
-            group_dict = {
-                'name': _(group['name']),
-                'fields': [],
-            }
+            group_dict = {"name": _(group["name"]), "fields": []}
 
-            for field_path in group['fields']:
+            for field_path in group["fields"]:
                 field = self.fields_dict[field_path]
                 if field.fieldname in form._profile_fields:
-                    group_dict['fields'].append(field.fieldname)
+                    group_dict["fields"].append(field.fieldname)
 
-            if group_dict['fields']:
+            if group_dict["fields"]:
                 form._profile_fields_groups.append(group_dict)
 
     def clean_form(self, request, user, form, cleaned_data):
@@ -121,7 +117,8 @@ class ProfileFields(object):
 
             try:
                 cleaned_data[field.fieldname] = field.clean(
-                    request, user, cleaned_data[field.fieldname])
+                    request, user, cleaned_data[field.fieldname]
+                )
             except ValidationError as e:
                 form.add_error(field.fieldname, e)
 
@@ -146,21 +143,21 @@ class ProfileFields(object):
         if request.user == user:
             log_message = "%s edited own profile fields" % user.username
         else:
-            log_message = "%s edited %s's (#%s) profile fields" % (request.user, user.username, user.pk)
+            log_message = "%s edited %s's (#%s) profile fields" % (
+                request.user,
+                user.username,
+                user.pk,
+            )
 
         logger.info(
             log_message,
             extra={
-                'absolute_url': request.build_absolute_uri(
+                "absolute_url": request.build_absolute_uri(
                     reverse(
-                        'misago:user-details',
-                        kwargs={
-                            'slug': user.slug,
-                            'pk': user.pk,
-                        },
+                        "misago:user-details", kwargs={"slug": user.slug, "pk": user.pk}
                     )
-                ),
-            }
+                )
+            },
         )
 
     def search_users(self, criteria, queryset):

+ 26 - 57
misago/users/profilefields/basefields.py

@@ -6,11 +6,11 @@ from misago.core.utils import format_plaintext_for_html
 
 
 __all__ = [
-    'ProfileField',
-    'TextProfileField',
-    'UrlProfileField',
-    'TextareaProfileField',
-    'ChoiceProfileField',
+    "ProfileField",
+    "TextProfileField",
+    "UrlProfileField",
+    "TextareaProfileField",
+    "ChoiceProfileField",
 ]
 
 
@@ -18,6 +18,7 @@ class ProfileField(object):
     """
     Basic profile field
     """
+
     fieldname = None
     label = None
     help_text = None
@@ -42,11 +43,11 @@ class ProfileField(object):
 
     def get_form_field_json(self, request, user):
         return {
-            'fieldname': self.fieldname,
-            'label': self.get_label(user),
-            'help_text': self.get_help_text(user),
-            'initial': user.profile_fields.get(self.fieldname, ''),
-            'input': self.get_input_json(request, user),
+            "fieldname": self.fieldname,
+            "label": self.get_label(user),
+            "help_text": self.get_help_text(user),
+            "initial": user.profile_fields.get(self.fieldname, ""),
+            "input": self.get_input_json(request, user),
         }
 
     def get_input_json(self, request, user):
@@ -56,7 +57,7 @@ class ProfileField(object):
         return data
 
     def get_display_data(self, request, user):
-        value = user.profile_fields.get(self.fieldname, '')
+        value = user.profile_fields.get(self.fieldname, "")
         if not self.readonly and not len(value):
             return None
 
@@ -64,25 +65,18 @@ class ProfileField(object):
         if not data:
             return None
 
-        data.update({
-            'fieldname': self.fieldname,
-            'name': str(self.get_label(user)),
-        })
+        data.update({"fieldname": self.fieldname, "name": str(self.get_label(user))})
 
         return data
 
     def get_value_display_data(self, request, user, value):
-        return {
-            'text': value
-        }
+        return {"text": value}
 
     def search_users(self, criteria):
         if self.readonly:
             return None
 
-        return Q(**{
-            'profile_fields__%s__contains' % self.fieldname: criteria
-        })
+        return Q(**{"profile_fields__%s__contains" % self.fieldname: criteria})
 
 
 class ChoiceProfileField(ProfileField):
@@ -109,35 +103,23 @@ class ChoiceProfileField(ProfileField):
     def get_input_json(self, request, user):
         choices = []
         for key, choice in self.get_choices():
-            choices.append({
-                'value': key,
-                'label': choice,
-            })
+            choices.append({"value": key, "label": choice})
 
-        return {
-            'type': 'select',
-            'choices': choices,
-        }
+        return {"type": "select", "choices": choices}
 
     def get_value_display_data(self, request, user, value):
         for key, name in self.get_choices():
             if key == value:
-                return {
-                    'text': str(name),
-                }
+                return {"text": str(name)}
         return None
 
     def search_users(self, criteria):
         """custom search implementation for choice fields"""
-        q_obj = Q(**{
-            'profile_fields__%s__contains' % self.fieldname: criteria
-        })
+        q_obj = Q(**{"profile_fields__%s__contains" % self.fieldname: criteria})
 
         for key, choice in self.get_choices():
             if key and criteria.lower() in str(choice).lower():
-                q_obj = q_obj | Q(**{
-                    'profile_fields__%s' % self.fieldname: key
-                })
+                q_obj = q_obj | Q(**{"profile_fields__%s" % self.fieldname: key})
 
         return q_obj
 
@@ -154,9 +136,7 @@ class TextProfileField(ProfileField):
         )
 
     def get_input_json(self, request, user):
-        return {
-            'type': 'text',
-        }
+        return {"type": "text"}
 
 
 class TextareaProfileField(ProfileField):
@@ -166,29 +146,21 @@ class TextareaProfileField(ProfileField):
             help_text=self.get_help_text(user),
             initial=user.profile_fields.get(self.fieldname),
             max_length=500,
-            widget=forms.Textarea(
-                attrs={'rows': 4},
-            ),
+            widget=forms.Textarea(attrs={"rows": 4}),
             disabled=self.readonly,
             required=False,
         )
 
     def get_input_json(self, request, user):
-        return {
-            'type': 'textarea',
-        }
+        return {"type": "textarea"}
 
     def get_value_display_data(self, request, user, value):
-        return {
-            'html': html.linebreaks(html.escape(value)),
-        }
+        return {"html": html.linebreaks(html.escape(value))}
 
 
 class UrlifiedTextareaProfileField(TextareaProfileField):
     def get_value_display_data(self, request, user, value):
-        return {
-            'html': format_plaintext_for_html(value),
-        }
+        return {"html": format_plaintext_for_html(value)}
 
 
 class UrlProfileField(TextProfileField):
@@ -203,7 +175,4 @@ class UrlProfileField(TextProfileField):
         )
 
     def get_value_display_data(self, request, user, value):
-        return {
-            'text': value,
-            'url': value,
-        }
+        return {"text": value, "url": value}

+ 21 - 28
misago/users/profilefields/default.py

@@ -7,44 +7,44 @@ from . import basefields
 
 
 class BioField(basefields.UrlifiedTextareaProfileField):
-    fieldname = 'bio'
+    fieldname = "bio"
     label = _("Bio")
 
 
 class RealNameField(basefields.TextProfileField):
-    fieldname = 'real_name'
+    fieldname = "real_name"
     label = _("Real name")
 
 
 class LocationField(basefields.TextProfileField):
-    fieldname = 'location'
+    fieldname = "location"
     label = _("Location")
 
 
 class GenderField(basefields.ChoiceProfileField):
-    fieldname = 'gender'
+    fieldname = "gender"
     label = _("Gender")
 
     choices = (
-        ('', _('Not specified')),
-        ('secret', _('Not telling')),
-        ('female', _('Female')),
-        ('male', _('Male')),
+        ("", _("Not specified")),
+        ("secret", _("Not telling")),
+        ("female", _("Female")),
+        ("male", _("Male")),
     )
 
 
 class WebsiteField(basefields.UrlProfileField):
-    fieldname = 'website'
+    fieldname = "website"
     label = _("Website")
     help_text = _(
-        'If you own website in the internet you wish to share on your profile '
-        'you may enter its address here. Remember to for it to be valid http '
+        "If you own website in the internet you wish to share on your profile "
+        "you may enter its address here. Remember to for it to be valid http "
         'address starting with either "http://" or "https://".'
     )
 
 
 class SkypeIdField(basefields.TextProfileField):
-    fieldname = 'skype'
+    fieldname = "skype"
     label = _("Skype ID")
     help_text = _(
         "Entering your Skype ID in this field may invite other users to contact you over "
@@ -53,43 +53,36 @@ class SkypeIdField(basefields.TextProfileField):
 
 
 class TwitterHandleField(basefields.TextProfileField):
-    fieldname = 'twitter'
+    fieldname = "twitter"
     label = _("Twitter handle")
 
     def get_help_text(self, user):
         return _(
-            'If you own Twitter account, here you may enter your Twitter handle for other users '
+            "If you own Twitter account, here you may enter your Twitter handle for other users "
             'to find you. Starting your handle with "@" sign is optional. Either "@%(slug)s" or '
             '"%(slug)s" are valid values.'
-        ) % {
-            'slug': user.slug
-        }
+        ) % {"slug": user.slug}
 
     def get_value_display_data(self, request, user, value):
-        return {
-            'text': '@%s' % value,
-            'url': 'https://twitter.com/%s' % value,
-        }
+        return {"text": "@%s" % value, "url": "https://twitter.com/%s" % value}
 
     def clean(self, request, user, data):
-        data = data.lstrip('@')
-        if data and not re.search('^[A-Za-z0-9_]+$', data):
+        data = data.lstrip("@")
+        if data and not re.search("^[A-Za-z0-9_]+$", data):
             raise ValidationError(gettext("This is not a valid twitter handle."))
         return data
 
 
 class JoinIpField(basefields.TextProfileField):
-    fieldname = 'join_ip'
+    fieldname = "join_ip"
     label = _("Join IP")
     readonly = True
 
     def get_value_display_data(self, request, user, value):
-        if not request.user_acl.get('can_see_users_ips'):
+        if not request.user_acl.get("can_see_users_ips"):
             return None
 
         if not user.joined_from_ip:
             return None
 
-        return {
-            'text': user.joined_from_ip
-        }
+        return {"text": user.joined_from_ip}

+ 4 - 11
misago/users/profilefields/serializers.py

@@ -2,29 +2,22 @@ from misago.users.permissions import can_edit_profile_details
 
 
 def serialize_profilefields_data(request, profilefields, user):
-    data = {
-        'id': user.pk,
-        'groups': [],
-        'edit': False,
-    }
+    data = {"id": user.pk, "groups": [], "edit": False}
 
     can_edit = can_edit_profile_details(request.user_acl, user)
     has_editable_fields = False
 
     for group in profilefields.get_fields_groups():
         group_fields = []
-        for field in group['fields']:
+        for field in group["fields"]:
             display_data = field.get_display_data(request, user)
             if display_data:
                 group_fields.append(display_data)
         if can_edit and field.is_editable(request, user):
             has_editable_fields = True
         if group_fields:
-            data['groups'].append({
-                'name': group['name'],
-                'fields': group_fields
-            })
+            data["groups"].append({"name": group["name"], "fields": group_fields})
 
-    data['edit'] = can_edit and has_editable_fields
+    data["edit"] = can_edit and has_editable_fields
 
     return data

+ 16 - 16
misago/users/registration.py

@@ -10,13 +10,13 @@ def send_welcome_email(request, user):
     settings = request.settings
 
     mail_subject = _("Welcome on %(forum_name)s forums!")
-    mail_subject = mail_subject % {'forum_name': settings.forum_name}
+    mail_subject = mail_subject % {"forum_name": settings.forum_name}
 
     if not user.requires_activation:
         mail_user(
             user,
             mail_subject,
-            'misago/emails/register/complete',
+            "misago/emails/register/complete",
             context={"settings": settings},
         )
         return
@@ -29,13 +29,13 @@ def send_welcome_email(request, user):
     mail_user(
         user,
         mail_subject,
-        'misago/emails/register/inactive',
+        "misago/emails/register/inactive",
         context={
-            'activation_token': activation_token,
-            'activation_by_admin': activation_by_admin,
-            'activation_by_user': activation_by_user,
-            'settings': settings,
-        }
+            "activation_token": activation_token,
+            "activation_by_admin": activation_by_admin,
+            "activation_by_user": activation_by_user,
+            "settings": settings,
+        },
     )
 
 
@@ -48,18 +48,18 @@ def save_user_agreements(user, form):
         agreement = Agreement.objects.get(id=agreement_id)
         save_user_agreement_acceptance(user, agreement)
 
-    user.save(update_fields=['agreements'])
+    user.save(update_fields=["agreements"])
 
 
 def get_registration_result_json(user):
-    activation_method = 'active'
+    activation_method = "active"
     if user.requires_activation_by_admin:
-        activation_method = 'admin'
+        activation_method = "admin"
     elif user.requires_activation_by_user:
-        activation_method = 'user'
+        activation_method = "user"
 
     return {
-        'activation': activation_method,
-        'email': user.email,
-        'username': user.username,
-    }
+        "activation": activation_method,
+        "email": user.email,
+        "username": user.username,
+    }

+ 13 - 11
misago/users/search.py

@@ -16,34 +16,36 @@ UserModel = get_user_model()
 
 class SearchUsers(SearchProvider):
     name = gettext_lazy("Users")
-    icon = 'people'
-    url = 'users'
+    icon = "people"
+    url = "users"
 
     def allow_search(self):
-        if not self.request.user_acl['can_search_users']:
+        if not self.request.user_acl["can_search_users"]:
             raise PermissionDenied(_("You don't have permission to search users."))
 
     def search(self, query, page=1):
         if query:
-            results = search_users(search_disabled=self.request.user.is_staff, username=query)
+            results = search_users(
+                search_disabled=self.request.user.is_staff, username=query
+            )
         else:
             results = []
 
         return {
-            'results': UserCardSerializer(results, many=True).data,
-            'count': len(results),
+            "results": UserCardSerializer(results, many=True).data,
+            "count": len(results),
         }
 
 
 def search_users(**filters):
-    queryset = UserModel.objects.order_by('slug').select_related(
-        'rank', 'ban_cache', 'online_tracker'
+    queryset = UserModel.objects.order_by("slug").select_related(
+        "rank", "ban_cache", "online_tracker"
     )
 
-    if not filters.get('search_disabled', False):
+    if not filters.get("search_disabled", False):
         queryset = queryset.filter(is_active=True)
 
-    username = filters.get('username').lower()
+    username = filters.get("username").lower()
 
     results = []
 
@@ -51,7 +53,7 @@ def search_users(**filters):
     results += list(queryset.filter(slug__startswith=username)[:HEAD_RESULTS])
     results += list(
         queryset.filter(slug__contains=username).exclude(
-            pk__in=[r.pk for r in results],
+            pk__in=[r.pk for r in results]
         )[:TAIL_RESULTS]
     )
 

+ 38 - 29
misago/users/serializers/auth.py

@@ -9,10 +9,7 @@ from .user import UserSerializer
 
 UserModel = get_user_model()
 
-__all__ = [
-    'AuthenticatedUserSerializer',
-    'AnonymousUserSerializer',
-]
+__all__ = ["AuthenticatedUserSerializer", "AnonymousUserSerializer"]
 
 
 class AuthFlags(object):
@@ -31,14 +28,14 @@ class AuthenticatedUserSerializer(UserSerializer, AuthFlags):
     class Meta:
         model = UserModel
         fields = UserSerializer.Meta.fields + [
-            'has_usable_password',
-            'is_hiding_presence',
-            'limits_private_thread_invites_to',
-            'unread_private_threads',
-            'subscribe_to_started_threads',
-            'subscribe_to_replied_threads',
-            'is_authenticated',
-            'is_anonymous',
+            "has_usable_password",
+            "is_hiding_presence",
+            "limits_private_thread_invites_to",
+            "unread_private_threads",
+            "subscribe_to_started_threads",
+            "subscribe_to_replied_threads",
+            "is_authenticated",
+            "is_anonymous",
         ]
 
     def get_acl(self, obj):
@@ -52,27 +49,39 @@ class AuthenticatedUserSerializer(UserSerializer, AuthFlags):
 
     def get_api(self, obj):
         return {
-            'avatar': reverse('misago:api:user-avatar', kwargs={'pk': obj.pk}),
-            'data_downloads': reverse('misago:api:user-data-downloads', kwargs={'pk': obj.pk}),
-            'details': reverse('misago:api:user-details', kwargs={'pk': obj.pk}),
-            'change_email': reverse('misago:api:user-change-email', kwargs={'pk': obj.pk}),
-            'change_password': reverse('misago:api:user-change-password', kwargs={'pk': obj.pk}),
-            'edit_details': reverse('misago:api:user-edit-details', kwargs={'pk': obj.pk}),
-            'options': reverse('misago:api:user-forum-options', kwargs={'pk': obj.pk}),
-            'request_data_download': reverse('misago:api:user-request-data-download', kwargs={'pk': obj.pk}),
-            'username': reverse('misago:api:user-username', kwargs={'pk': obj.pk}),
-            'delete': reverse('misago:api:user-delete-own-account', kwargs={'pk': obj.pk}),
+            "avatar": reverse("misago:api:user-avatar", kwargs={"pk": obj.pk}),
+            "data_downloads": reverse(
+                "misago:api:user-data-downloads", kwargs={"pk": obj.pk}
+            ),
+            "details": reverse("misago:api:user-details", kwargs={"pk": obj.pk}),
+            "change_email": reverse(
+                "misago:api:user-change-email", kwargs={"pk": obj.pk}
+            ),
+            "change_password": reverse(
+                "misago:api:user-change-password", kwargs={"pk": obj.pk}
+            ),
+            "edit_details": reverse(
+                "misago:api:user-edit-details", kwargs={"pk": obj.pk}
+            ),
+            "options": reverse("misago:api:user-forum-options", kwargs={"pk": obj.pk}),
+            "request_data_download": reverse(
+                "misago:api:user-request-data-download", kwargs={"pk": obj.pk}
+            ),
+            "username": reverse("misago:api:user-username", kwargs={"pk": obj.pk}),
+            "delete": reverse(
+                "misago:api:user-delete-own-account", kwargs={"pk": obj.pk}
+            ),
         }
 
 
 AuthenticatedUserSerializer = AuthenticatedUserSerializer.exclude_fields(
-    'is_avatar_locked',
-    'is_blocked',
-    'is_followed',
-    'is_signature_locked',
-    'meta',
-    'signature',
-    'status',
+    "is_avatar_locked",
+    "is_blocked",
+    "is_followed",
+    "is_signature_locked",
+    "meta",
+    "signature",
+    "status",
 )
 
 

+ 4 - 17
misago/users/serializers/ban.py

@@ -6,18 +6,12 @@ from misago.core.utils import format_plaintext_for_html
 from misago.users.models import Ban
 
 
-__all__ = [
-    'BanMessageSerializer',
-    'BanDetailsSerializer',
-]
+__all__ = ["BanMessageSerializer", "BanDetailsSerializer"]
 
 
 def serialize_message(message):
     if message:
-        return {
-            'plain': message,
-            'html': format_plaintext_for_html(message),
-        }
+        return {"plain": message, "html": format_plaintext_for_html(message)}
     else:
         return None
 
@@ -27,10 +21,7 @@ class BanMessageSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Ban
-        fields = [
-            'message',
-            'expires_on',
-        ]
+        fields = ["message", "expires_on"]
 
     def get_message(self, obj):
         if obj.user_message:
@@ -49,11 +40,7 @@ class BanDetailsSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Ban
-        fields = [
-            'user_message',
-            'staff_message',
-            'expires_on',
-        ]
+        fields = ["user_message", "staff_message", "expires_on"]
 
     def get_user_message(self, obj):
         return serialize_message(obj.user_message)

+ 2 - 8
misago/users/serializers/datadownload.py

@@ -3,16 +3,10 @@ from rest_framework import serializers
 from misago.users.models import DataDownload
 
 
-__all__ = ['DataDownloadSerializer']
+__all__ = ["DataDownloadSerializer"]
 
 
 class DataDownloadSerializer(serializers.ModelSerializer):
     class Meta:
         model = DataDownload
-        fields = [
-            'id',
-            'status',
-            'requested_on',
-            'expires_on',
-            'file',
-        ]
+        fields = ["id", "status", "requested_on", "expires_on", "file"]

+ 10 - 12
misago/users/serializers/moderation.py

@@ -8,19 +8,16 @@ from misago.conf import settings
 
 UserModel = get_user_model()
 
-__all__ = [
-    'ModerateAvatarSerializer',
-    'ModerateSignatureSerializer',
-]
+__all__ = ["ModerateAvatarSerializer", "ModerateSignatureSerializer"]
 
 
 class ModerateAvatarSerializer(serializers.ModelSerializer):
     class Meta:
         model = UserModel
         fields = [
-            'is_avatar_locked',
-            'avatar_lock_user_message',
-            'avatar_lock_staff_message',
+            "is_avatar_locked",
+            "avatar_lock_user_message",
+            "avatar_lock_staff_message",
         ]
 
 
@@ -28,10 +25,10 @@ class ModerateSignatureSerializer(serializers.ModelSerializer):
     class Meta:
         model = UserModel
         fields = [
-            'signature',
-            'is_signature_locked',
-            'signature_lock_user_message',
-            'signature_lock_staff_message',
+            "signature",
+            "is_signature_locked",
+            "signature_lock_user_message",
+            "signature_lock_staff_message",
         ]
 
     def validate_signature(self, value):
@@ -42,7 +39,8 @@ class ModerateSignatureSerializer(serializers.ModelSerializer):
                     "Signature can't be longer than %(limit)s character.",
                     "Signature can't be longer than %(limit)s characters.",
                     length_limit,
-                ) % {'limit': length_limit}
+                )
+                % {"limit": length_limit}
             )
 
         return value

+ 31 - 33
misago/users/serializers/options.py

@@ -12,12 +12,12 @@ from misago.users.validators import validate_email, validate_username
 UserModel = get_user_model()
 
 __all__ = [
-    'ForumOptionsSerializer',
-    'EditSignatureSerializer',
-    'ChangeUsernameSerializer',
-    'ChangePasswordSerializer',
-    'ChangeEmailSerializer',
-    'DeleteOwnAccountSerializer',
+    "ForumOptionsSerializer",
+    "EditSignatureSerializer",
+    "ChangeUsernameSerializer",
+    "ChangePasswordSerializer",
+    "ChangeEmailSerializer",
+    "DeleteOwnAccountSerializer",
 ]
 
 
@@ -25,30 +25,26 @@ class ForumOptionsSerializer(serializers.ModelSerializer):
     class Meta:
         model = UserModel
         fields = [
-            'is_hiding_presence', 'limits_private_thread_invites_to',
-            'subscribe_to_started_threads', 'subscribe_to_replied_threads'
+            "is_hiding_presence",
+            "limits_private_thread_invites_to",
+            "subscribe_to_started_threads",
+            "subscribe_to_replied_threads",
         ]
         extra_kwargs = {
-            'limits_private_thread_invites_to': {
-                'required': True
-            },
-            'subscribe_to_started_threads': {
-                'required': True
-            },
-            'subscribe_to_replied_threads': {
-                'required': True
-            },
+            "limits_private_thread_invites_to": {"required": True},
+            "subscribe_to_started_threads": {"required": True},
+            "subscribe_to_replied_threads": {"required": True},
         }
 
 
 class EditSignatureSerializer(serializers.ModelSerializer):
     class Meta:
         model = UserModel
-        fields = ['signature']
+        fields = ["signature"]
 
     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."))
 
         return data
@@ -58,23 +54,23 @@ class ChangeUsernameSerializer(serializers.Serializer):
     username = serializers.CharField(max_length=200, required=False, allow_blank=True)
 
     def validate(self, data):
-        username = data.get('username')
+        username = data.get("username")
         if not username:
             raise serializers.ValidationError(_("Enter new username."))
 
-        user = self.context['user']
+        user = self.context["user"]
         if username == user.username:
             raise serializers.ValidationError(_("New username is same as current one."))
 
-        settings = self.context['settings']
+        settings = self.context["settings"]
         validate_username(settings, username)
 
         return data
 
     def change_username(self, changed_by):
-        user = self.context['user']
-        user.set_username(self.validated_data['username'], changed_by=changed_by)
-        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):
@@ -82,12 +78,12 @@ class ChangePasswordSerializer(serializers.Serializer):
     new_password = serializers.CharField(max_length=200, trim_whitespace=False)
 
     def validate_password(self, value):
-        if not self.context['user'].check_password(value):
+        if not self.context["user"].check_password(value):
             raise serializers.ValidationError(_("Entered password is invalid."))
         return value
 
     def validate_new_password(self, value):
-        validate_password(value, user=self.context['user'])
+        validate_password(value, user=self.context["user"])
         return value
 
 
@@ -96,15 +92,17 @@ class ChangeEmailSerializer(serializers.Serializer):
     new_email = serializers.CharField(max_length=200)
 
     def validate_password(self, value):
-        if not self.context['user'].check_password(value):
+        if not self.context["user"].check_password(value):
             raise serializers.ValidationError(_("Entered password is invalid."))
         return value
 
     def validate_new_email(self, value):
         if not value:
-            raise serializers.ValidationError(_("You have to enter new e-mail address."))
+            raise serializers.ValidationError(
+                _("You have to enter new e-mail address.")
+            )
 
-        if value.lower() == self.context['user'].email.lower():
+        if value.lower() == self.context["user"].email.lower():
             raise serializers.ValidationError(_("New e-mail is same as current one."))
 
         validate_email(value)
@@ -116,7 +114,7 @@ class DeleteOwnAccountSerializer(serializers.Serializer):
     password = serializers.CharField(max_length=200, trim_whitespace=False)
 
     def validate_password(self, value):
-        if not self.context['user'].check_password(value):
+        if not self.context["user"].check_password(value):
             raise serializers.ValidationError(_("Entered password is invalid."))
         return value
 
@@ -125,9 +123,9 @@ class DeleteOwnAccountSerializer(serializers.Serializer):
         Deleting user account can be costful, so just mark account for deletion, deactivate it
         and sign user out.
         """
-        profile = self.context['user']
+        profile = self.context["user"]
         allow_delete_own_account(request.user, profile)
-        
+
         logout(request)
         clear_tracking(request)
 

+ 11 - 11
misago/users/serializers/rank.py

@@ -4,7 +4,7 @@ from misago.core.utils import format_plaintext_for_html
 from misago.users.models import Rank
 
 
-__all__ = ['RankSerializer']
+__all__ = ["RankSerializer"]
 
 
 class RankSerializer(serializers.ModelSerializer):
@@ -14,22 +14,22 @@ class RankSerializer(serializers.ModelSerializer):
     class Meta:
         model = Rank
         fields = [
-            'id',
-            'name',
-            'slug',
-            'description',
-            'title',
-            'css_class',
-            'is_default',
-            'is_tab',
-            'url',
+            "id",
+            "name",
+            "slug",
+            "description",
+            "title",
+            "css_class",
+            "is_default",
+            "is_tab",
+            "url",
         ]
 
     def get_description(self, obj):
         if obj.description:
             return format_plaintext_for_html(obj.description)
         else:
-            return ''
+            return ""
 
     def get_url(self, obj):
         return obj.get_absolute_url()

+ 58 - 114
misago/users/serializers/user.py

@@ -10,7 +10,7 @@ from . import RankSerializer
 
 UserModel = get_user_model()
 
-__all__ = ['StatusSerializer', 'UserSerializer', 'UserCardSerializer']
+__all__ = ["StatusSerializer", "UserSerializer", "UserCardSerializer"]
 
 
 class StatusSerializer(serializers.Serializer):
@@ -43,59 +43,57 @@ class UserSerializer(serializers.ModelSerializer, MutableFields):
     class Meta:
         model = UserModel
         fields = [
-            'id',
-            'username',
-            'slug',
-            'email',
-            'joined_on',
-            'rank',
-            'title',
-            'avatars',
-            'is_avatar_locked',
-            'signature',
-            'is_signature_locked',
-            'followers',
-            'following',
-            'threads',
-            'posts',
-            'acl',
-            'is_followed',
-            'is_blocked',
-
-            'meta',
-            'real_name',
-            'status',
-
-            'api',
-            'url',
+            "id",
+            "username",
+            "slug",
+            "email",
+            "joined_on",
+            "rank",
+            "title",
+            "avatars",
+            "is_avatar_locked",
+            "signature",
+            "is_signature_locked",
+            "followers",
+            "following",
+            "threads",
+            "posts",
+            "acl",
+            "is_followed",
+            "is_blocked",
+            "meta",
+            "real_name",
+            "status",
+            "api",
+            "url",
         ]
 
     def get_acl(self, obj):
         return obj.acl
 
     def get_email(self, obj):
-        request = self.context['request']
-        if (obj == request.user or request.user_acl['can_see_users_emails']):
+        request = self.context["request"]
+        if obj == request.user or request.user_acl["can_see_users_emails"]:
             return obj.email
         else:
             return None
 
     def get_is_followed(self, obj):
-        request = self.context['request']
-        if obj.acl['can_follow']:
+        request = self.context["request"]
+        if obj.acl["can_follow"]:
             return request.user.is_following(obj)
         else:
             return False
 
     def get_is_blocked(self, obj):
-        request = self.context['request']
-        if obj.acl['can_block']:
+        request = self.context["request"]
+        if obj.acl["can_block"]:
             return request.user.is_blocking(obj)
         else:
             return False
 
     def get_meta(self, obj):
-        return {'score': obj.score}
+        return {"score": obj.score}
 
     def get_real_name(self, obj):
         return obj.get_real_name()
@@ -114,78 +112,24 @@ class UserSerializer(serializers.ModelSerializer, MutableFields):
 
     def get_api(self, obj):
         return {
-            'index': reverse(
-                'misago:api:user-detail',
-                kwargs={
-                    'pk': obj.pk
-                }
+            "index": reverse("misago:api:user-detail", kwargs={"pk": obj.pk}),
+            "follow": reverse("misago:api:user-follow", kwargs={"pk": obj.pk}),
+            "ban": reverse("misago:api:user-ban", kwargs={"pk": obj.pk}),
+            "details": reverse("misago:api:user-details", kwargs={"pk": obj.pk}),
+            "edit_details": reverse(
+                "misago:api:user-edit-details", kwargs={"pk": obj.pk}
             ),
-            'follow': reverse(
-                'misago:api:user-follow',
-                kwargs={
-                    'pk': obj.pk
-                }
+            "moderate_avatar": reverse(
+                "misago:api:user-moderate-avatar", kwargs={"pk": obj.pk}
             ),
-            'ban': reverse(
-                'misago:api:user-ban',
-                kwargs={
-                    'pk': obj.pk
-                }
-            ),
-            'details': reverse(
-                'misago:api:user-details',
-                kwargs={
-                    'pk': obj.pk,
-                }
-            ),
-            'edit_details': reverse(
-                'misago:api:user-edit-details',
-                kwargs={
-                    'pk': obj.pk,
-                }
-            ),
-            'moderate_avatar': reverse(
-                'misago:api:user-moderate-avatar',
-                kwargs={
-                    'pk': obj.pk
-                }
-            ),
-            'moderate_username': reverse(
-                'misago:api:user-moderate-username',
-                kwargs={
-                    'pk': obj.pk
-                }
-            ),
-            'delete': reverse(
-                'misago:api:user-delete',
-                kwargs={
-                    'pk': obj.pk
-                }
-            ),
-            'followers': reverse(
-                'misago:api:user-followers',
-                kwargs={
-                    'pk': obj.pk
-                }
-            ),
-            'follows': reverse(
-                'misago:api:user-follows',
-                kwargs={
-                    'pk': obj.pk
-                }
-            ),
-            'threads': reverse(
-                'misago:api:user-threads',
-                kwargs={
-                    'pk': obj.pk
-                }
-            ),
-            'posts': reverse(
-                'misago:api:user-posts',
-                kwargs={
-                    'pk': obj.pk
-                }
+            "moderate_username": reverse(
+                "misago:api:user-moderate-username", kwargs={"pk": obj.pk}
             ),
+            "delete": reverse("misago:api:user-delete", kwargs={"pk": obj.pk}),
+            "followers": reverse("misago:api:user-followers", kwargs={"pk": obj.pk}),
+            "follows": reverse("misago:api:user-follows", kwargs={"pk": obj.pk}),
+            "threads": reverse("misago:api:user-threads", kwargs={"pk": obj.pk}),
+            "posts": reverse("misago:api:user-posts", kwargs={"pk": obj.pk}),
         }
 
     def get_url(self, obj):
@@ -193,16 +137,16 @@ class UserSerializer(serializers.ModelSerializer, MutableFields):
 
 
 UserCardSerializer = UserSerializer.subset_fields(
-    'id',
-    'username',
-    'joined_on',
-    'rank',
-    'title',
-    'avatars',
-    'followers',
-    'threads',
-    'posts',
-    'real_name',
-    'status',
-    'url',
+    "id",
+    "username",
+    "joined_on",
+    "rank",
+    "title",
+    "avatars",
+    "followers",
+    "threads",
+    "posts",
+    "real_name",
+    "status",
+    "url",
 )

+ 9 - 9
misago/users/serializers/usernamechange.py

@@ -5,9 +5,9 @@ from misago.users.models import UsernameChange
 from .user import UserSerializer as BaseUserSerializer
 
 
-__all__ = ['UsernameChangeSerializer']
+__all__ = ["UsernameChangeSerializer"]
 
-UserSerializer = BaseUserSerializer.subset_fields('id', 'username', 'avatars', 'url')
+UserSerializer = BaseUserSerializer.subset_fields("id", "username", "avatars", "url")
 
 
 class UsernameChangeSerializer(serializers.ModelSerializer):
@@ -17,11 +17,11 @@ class UsernameChangeSerializer(serializers.ModelSerializer):
     class Meta:
         model = UsernameChange
         fields = [
-            'id',
-            'user',
-            'changed_by',
-            'changed_by_username',
-            'changed_on',
-            'new_username',
-            'old_username',
+            "id",
+            "user",
+            "changed_by",
+            "changed_by_username",
+            "changed_on",
+            "new_username",
+            "old_username",
         ]

+ 5 - 5
misago/users/setupnewuser.py

@@ -5,7 +5,7 @@ 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
     )
@@ -15,15 +15,15 @@ def setup_new_user(settings, user):
 
 
 SUBSCRIPTION_CHOICES = {
-    'no': User.SUBSCRIPTION_NONE,
-    'watch': User.SUBSCRIPTION_NOTIFY,
-    'watch_email': User.SUBSCRIPTION_ALL,
+    "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

+ 28 - 21
misago/users/signals.py

@@ -25,53 +25,60 @@ username_changed = Signal()
 
 @receiver(archive_user_data)
 def archive_user_details(sender, archive=None, **kwargs):
-    archive.add_dict('details', OrderedDict([
-        (_('Username'), sender.username),
-        (_('E-mail'), sender.email),
-        (_('Joined on'), sender.joined_on),
-        (_('Joined from ip'), sender.joined_from_ip or 'unavailable'),
-    ]))
+    archive.add_dict(
+        "details",
+        OrderedDict(
+            [
+                (_("Username"), sender.username),
+                (_("E-mail"), sender.email),
+                (_("Joined on"), sender.joined_on),
+                (_("Joined from ip"), sender.joined_from_ip or "unavailable"),
+            ]
+        ),
+    )
 
 
 @receiver(archive_user_data)
 def archive_user_profile_fields(sender, archive=None, **kwargs):
     clean_profile_fields = OrderedDict()
     for profile_fields_group in profilefields.get_fields_groups():
-        for profile_field in profile_fields_group['fields']:
+        for profile_field in profile_fields_group["fields"]:
             if sender.profile_fields.get(profile_field.fieldname):
                 field_value = sender.profile_fields[profile_field.fieldname]
                 clean_profile_fields[str(profile_field.label)] = field_value
-                
+
     if clean_profile_fields:
-        archive.add_dict('profile_fields', clean_profile_fields)
+        archive.add_dict("profile_fields", clean_profile_fields)
 
 
 @receiver(archive_user_data)
 def archive_user_avatar(sender, archive=None, **kwargs):
-    archive.add_model_file(sender.avatar_tmp, directory='avatar', prefix='tmp')
-    archive.add_model_file(sender.avatar_src, directory='avatar', prefix='src')
+    archive.add_model_file(sender.avatar_tmp, directory="avatar", prefix="tmp")
+    archive.add_model_file(sender.avatar_src, directory="avatar", prefix="src")
     for avatar in sender.avatar_set.iterator():
-        archive.add_model_file(avatar.image, directory='avatar', prefix=avatar.size)
+        archive.add_model_file(avatar.image, directory="avatar", prefix=avatar.size)
 
 
 @receiver(archive_user_data)
 def archive_user_audit_trail(sender, archive=None, **kwargs):
-    queryset = sender.audittrail_set.order_by('id')
+    queryset = sender.audittrail_set.order_by("id")
     for audit_trail in chunk_queryset(queryset):
-        item_name = audit_trail.created_on.strftime('%H%M%S-audit-trail')
+        item_name = audit_trail.created_on.strftime("%H%M%S-audit-trail")
         archive.add_text(item_name, audit_trail.ip_address, date=audit_trail.created_on)
 
 
 @receiver(archive_user_data)
 def archive_user_name_history(sender, archive=None, **kwargs):
-    for name_change in sender.namechanges.order_by('id').iterator():
-        item_name = name_change.changed_on.strftime('%H%M%S-name-change')
+    for name_change in sender.namechanges.order_by("id").iterator():
+        item_name = name_change.changed_on.strftime("%H%M%S-name-change")
         archive.add_dict(
             item_name,
-            OrderedDict([
-                (_("New username"), name_change.new_username),
-                (_("Old username"), name_change.old_username),
-            ]),
+            OrderedDict(
+                [
+                    (_("New username"), name_change.new_username),
+                    (_("Old username"), name_change.old_username),
+                ]
+            ),
             date=name_change.changed_on,
         )
 
@@ -86,7 +93,7 @@ def remove_old_registrations_ips(sender, **kwargs):
     datetime_cutoff = timezone.now() - timedelta(days=settings.MISAGO_IP_STORE_TIME)
     ip_is_too_new = Q(joined_on__gt=datetime_cutoff)
     ip_is_already_removed = Q(joined_from_ip__isnull=True)
-    
+
     queryset = UserModel.objects.exclude(ip_is_too_new | ip_is_already_removed)
     queryset.update(joined_from_ip=None)
 

+ 2 - 2
misago/users/signatures.py

@@ -8,8 +8,8 @@ def set_user_signature(request, user, user_acl, signature):
         user.signature_parsed = signature_flavour(request, user, user_acl, signature)
         user.signature_checksum = make_signature_checksum(user.signature_parsed, user)
     else:
-        user.signature_parsed = ''
-        user.signature_checksum = ''
+        user.signature_parsed = ""
+        user.signature_checksum = ""
 
 
 def is_user_signature_valid(user):

+ 80 - 80
misago/users/social/backendsnames.py

@@ -4,84 +4,84 @@ so we are using this file for overrides whenever name is different than `provide
 """
 
 BACKENDS_NAMES = {
-    'angel': "AngelList",
-    'aol': "AOL",
-    'arcgis': "ArcGIS",
-    'azuread-oauth2': "Azure Active Directory",
-    'azuread-b2c-oauth2': "Azure Active Directory B2C",
-    'azuread-tenant-oauth2': "Azure Active Directory tenant",
-    'battlenet-oauth2': "Blizzard Battle.net",
-    'belgiumeid': "Belgium EID",
-    'bitbucket-oauth2': "Bitbucket",
-    'bungie': "Bungie.net",
-    'chatwork': "ChatWork",
-    'classlink': "ClassLink",
-    'digitalocean': "DigitalOcean",
-    'douban-oauth2': "Douban",
-    'dropbox-oauth2': "Dropbox",
-    'echosign': "Adobe Sign",
-    'eveonline': "EVE Online",
-    'evernote-sandbox': "Evernote (sandbox)",
-    'facebook-app': "Facebook",
-    'google-appengine': "Google App Engine",
-    'github': "GitHub",
-    'github-org': "GitHub Organization",
-    'github-team': "GitHub Team",
-    'github-enterprise': "GitHub Enterprise",
-    'github-enterprise-org': "GitHub Enterprise Organization",
-    'github-enterprise-team': "GitHub Enterprise Team",
-    'gitlab': "GitLab",
-    'goclio': "Clio.com",
-    'goclioeu': "Clio.eu",
-    'google': "Google",
-    'google-oauth': "Google",
-    'google-oauth2': "Google",
-    'google-plus': "Google Plus",
-    'google-openidconnect': "Google",
-    'itembase-sandbox': "Itembase (sandbox)",
-    'justgiving': "JustGiving",
-    'khanacademy-oauth1': "Khan Academy",
-    'lastfm': "Last.fm",
-    'line': "LINE",
-    'linkedin': "LinkedIn",
-    'live': "Live Connect",
-    'livejournal': "LiveJournal",
-    'loginradius': "LoginRadius",
-    'mailchimp': "MailChimp",
-    'mailru-oauth2': "Mail.ru",
-    'mapmyfitness': "MapMyFitness",
-    'mediawiki': "MediaWiki",
-    'mendeley-oauth2': "Mendeley",
-    'microsoft-graph': "Microsoft",
-    'mineid': "MineID",
-    'nationbuilder': "NationBuilder",
-    'naver': "NAVER",
-    'actionid-openid': "NGP VAN ActionID",
-    'nk': "nk.pl",
-    'odnoklassniki-oauth2': "OK.RU",
-    'odnoklassniki-app': "OK.RU",
-    'openshift': "OpenShift",
-    'openstreetmap': "PpenStreetMap",
-    'orcid': "ORCID",
-    'professionali': "Professionali.ru",
-    'qq': "QQ",
-    'rdio-oauth1': "Pandora",
-    'rdio-oauth2': "Pandora",
-    'salesforce-oauth2': "Salesforce",
-    'salesforce-oauth2-sandbox': "Salesforce (sandbox)",
-    'saml': "SAML",
-    'shimmering': "Shimmering Verify",
-    'stackoverflow': "StackExchange",
-    'stocktwits': "StockTwits",
-    'opensuse': "Open SUSE OpenId",
-    'datagouv': "uData",
-    'vimeo-oauth2': "Vimeo",
-    'vk': "VK",
-    'weixinapp': "Weixin",
-    'yahoo-oauth': "Yahoo",
-    'yahoo-oauth2': "Yahoo",
-    'yammer-staging': "Yammer (staging)",
-    'yandex-openid': "Yandex",
-    'yandex-oauth2': "Yandex",
-    'yaru': "Yandex",
+    "angel": "AngelList",
+    "aol": "AOL",
+    "arcgis": "ArcGIS",
+    "azuread-oauth2": "Azure Active Directory",
+    "azuread-b2c-oauth2": "Azure Active Directory B2C",
+    "azuread-tenant-oauth2": "Azure Active Directory tenant",
+    "battlenet-oauth2": "Blizzard Battle.net",
+    "belgiumeid": "Belgium EID",
+    "bitbucket-oauth2": "Bitbucket",
+    "bungie": "Bungie.net",
+    "chatwork": "ChatWork",
+    "classlink": "ClassLink",
+    "digitalocean": "DigitalOcean",
+    "douban-oauth2": "Douban",
+    "dropbox-oauth2": "Dropbox",
+    "echosign": "Adobe Sign",
+    "eveonline": "EVE Online",
+    "evernote-sandbox": "Evernote (sandbox)",
+    "facebook-app": "Facebook",
+    "google-appengine": "Google App Engine",
+    "github": "GitHub",
+    "github-org": "GitHub Organization",
+    "github-team": "GitHub Team",
+    "github-enterprise": "GitHub Enterprise",
+    "github-enterprise-org": "GitHub Enterprise Organization",
+    "github-enterprise-team": "GitHub Enterprise Team",
+    "gitlab": "GitLab",
+    "goclio": "Clio.com",
+    "goclioeu": "Clio.eu",
+    "google": "Google",
+    "google-oauth": "Google",
+    "google-oauth2": "Google",
+    "google-plus": "Google Plus",
+    "google-openidconnect": "Google",
+    "itembase-sandbox": "Itembase (sandbox)",
+    "justgiving": "JustGiving",
+    "khanacademy-oauth1": "Khan Academy",
+    "lastfm": "Last.fm",
+    "line": "LINE",
+    "linkedin": "LinkedIn",
+    "live": "Live Connect",
+    "livejournal": "LiveJournal",
+    "loginradius": "LoginRadius",
+    "mailchimp": "MailChimp",
+    "mailru-oauth2": "Mail.ru",
+    "mapmyfitness": "MapMyFitness",
+    "mediawiki": "MediaWiki",
+    "mendeley-oauth2": "Mendeley",
+    "microsoft-graph": "Microsoft",
+    "mineid": "MineID",
+    "nationbuilder": "NationBuilder",
+    "naver": "NAVER",
+    "actionid-openid": "NGP VAN ActionID",
+    "nk": "nk.pl",
+    "odnoklassniki-oauth2": "OK.RU",
+    "odnoklassniki-app": "OK.RU",
+    "openshift": "OpenShift",
+    "openstreetmap": "PpenStreetMap",
+    "orcid": "ORCID",
+    "professionali": "Professionali.ru",
+    "qq": "QQ",
+    "rdio-oauth1": "Pandora",
+    "rdio-oauth2": "Pandora",
+    "salesforce-oauth2": "Salesforce",
+    "salesforce-oauth2-sandbox": "Salesforce (sandbox)",
+    "saml": "SAML",
+    "shimmering": "Shimmering Verify",
+    "stackoverflow": "StackExchange",
+    "stocktwits": "StockTwits",
+    "opensuse": "Open SUSE OpenId",
+    "datagouv": "uData",
+    "vimeo-oauth2": "Vimeo",
+    "vk": "VK",
+    "weixinapp": "Weixin",
+    "yahoo-oauth": "Yahoo",
+    "yahoo-oauth2": "Yahoo",
+    "yammer-staging": "Yammer (staging)",
+    "yandex-openid": "Yandex",
+    "yandex-oauth2": "Yandex",
+    "yaru": "Yandex",
 }

+ 64 - 70
misago/users/social/pipeline.py

@@ -15,11 +15,16 @@ from misago.users.bans import get_request_ip_ban, get_user_ban
 from misago.users.forms.register import SocialAuthRegisterForm
 from misago.users.models import Ban
 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 (
-    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
@@ -32,13 +37,11 @@ def validate_ip_not_banned(strategy, details, backend, user=None, *args, **kwarg
     """Pipeline step that interrupts pipeline if found user is non-staff and IP banned"""
     if not user or user.is_staff:
         return None
-    
+
     ban = get_request_ip_ban(strategy.request)
     if ban:
         hydrated_ban = Ban(
-            check_type=Ban.IP,
-            user_message=ban['message'],
-            expires_on=ban['expires_on'],
+            check_type=Ban.IP, user_message=ban["message"], expires_on=ban["expires_on"]
         )
         raise SocialAuthBanned(backend, hydrated_ban)
 
@@ -60,7 +63,7 @@ def associate_by_email(strategy, details, backend, user=None, *args, **kwargs):
     if user:
         return None
 
-    email = details.get('email')
+    email = details.get("email")
     if not email:
         return None
 
@@ -77,7 +80,8 @@ def associate_by_email(strategy, details, backend, user=None, *args, **kwargs):
             _(
                 "The e-mail address associated with your %(backend)s account is "
                 "not available for use on this site."
-            ) % {'backend': backend_name}
+            )
+            % {"backend": backend_name},
         )
 
     if user.requires_activation_by_admin:
@@ -86,10 +90,11 @@ def associate_by_email(strategy, details, backend, user=None, *args, **kwargs):
             _(
                 "Your account has to be activated by site administrator before you will be able to "
                 "sign in with %(backend)s."
-            ) % {'backend': backend_name}
+            )
+            % {"backend": backend_name},
         )
 
-    return {'user': user, 'is_new': False}
+    return {"user": user, "is_new": False}
 
 
 def get_username(strategy, details, backend, user=None, *args, **kwargs):
@@ -99,15 +104,12 @@ def get_username(strategy, details, backend, user=None, *args, **kwargs):
 
     settings = strategy.request.settings
 
-    username = perpare_username(details.get('username', ''))
-    full_name = perpare_username(details.get('full_name', ''))
-    first_name = perpare_username(details.get('first_name', ''))
-    last_name = perpare_username(details.get('last_name', ''))
+    username = perpare_username(details.get("username", ""))
+    full_name = perpare_username(details.get("full_name", ""))
+    first_name = perpare_username(details.get("first_name", ""))
+    last_name = perpare_username(details.get("last_name", ""))
 
-    names_to_try = [
-        username,
-        first_name,
-    ]
+    names_to_try = [username, first_name]
 
     if username:
         names_to_try.append(username)
@@ -129,7 +131,7 @@ def get_username(strategy, details, backend, user=None, *args, **kwargs):
     for name in filter(bool, names_to_try):
         try:
             validate_username(settings, name)
-            return {'clean_username': name}
+            return {"clean_username": name}
         except ValidationError:
             pass
 
@@ -138,40 +140,34 @@ def create_user(strategy, details, backend, user=None, *args, **kwargs):
     """Aggressively attempt to register and sign in new user"""
     if user:
         return None
-    
+
     request = strategy.request
     settings = request.settings
 
-    email = details.get('email')
-    username = kwargs.get('clean_username')
-    
+    email = details.get("email")
+    username = kwargs.get("clean_username")
+
     if not email or not username:
         return None
 
     try:
         validate_email(email)
-        validate_new_registration(request, {
-            'email': email,
-            'username': username,
-        })
+        validate_new_registration(request, {"email": email, "username": username})
     except ValidationError:
         return None
 
     activation_kwargs = {}
-    if settings.account_activation == 'admin':
-        activation_kwargs = {'requires_activation': UserModel.ACTIVATION_ADMIN}
+    if settings.account_activation == "admin":
+        activation_kwargs = {"requires_activation": UserModel.ACTIVATION_ADMIN}
 
     new_user = UserModel.objects.create_user(
-        username,
-        email,
-        joined_from_ip=request.user_ip, 
-        **activation_kwargs
+        username, email, joined_from_ip=request.user_ip, **activation_kwargs
     )
 
     setup_new_user(settings, new_user)
     send_welcome_email(request, new_user)
 
-    return {'user': new_user, 'is_new': True}
+    return {"user": new_user, "is_new": True}
 
 
 @partial
@@ -184,68 +180,68 @@ def create_user_with_form(strategy, details, backend, user=None, *args, **kwargs
     settings = request.settings
     backend_name = get_social_auth_backend_name(backend.name)
 
-    if request.method == 'POST':
+    if request.method == "POST":
         try:
             request_data = json.loads(request.body)
         except (TypeError, ValueError):
             request_data = request.POST.copy()
-            
+
         form = SocialAuthRegisterForm(
-            request_data,
-            request=request,
-            agreements=Agreement.objects.get_agreements(),
+            request_data, request=request, agreements=Agreement.objects.get_agreements()
         )
-        
+
         if not form.is_valid():
             return JsonResponse(form.errors, status=400)
 
-        email_verified = form.cleaned_data['email'] == details.get('email')
+        email_verified = form.cleaned_data["email"] == details.get("email")
 
         activation_kwargs = {}
-        if settings.account_activation == 'admin':
-            activation_kwargs = {'requires_activation': UserModel.ACTIVATION_ADMIN}
-        elif settings.account_activation == 'user' and not email_verified:
-            activation_kwargs = {'requires_activation': UserModel.ACTIVATION_USER}
+        if settings.account_activation == "admin":
+            activation_kwargs = {"requires_activation": UserModel.ACTIVATION_ADMIN}
+        elif settings.account_activation == "user" and not email_verified:
+            activation_kwargs = {"requires_activation": UserModel.ACTIVATION_USER}
 
         try:
             new_user = UserModel.objects.create_user(
-                form.cleaned_data['username'],
-                form.cleaned_data['email'],
+                form.cleaned_data["username"],
+                form.cleaned_data["email"],
                 joined_from_ip=request.user_ip,
                 **activation_kwargs
             )
             setup_new_user(settings, new_user)
         except IntegrityError:
-            return JsonResponse({'__all__': _("Please try resubmitting the form.")}, status=400)
+            return JsonResponse(
+                {"__all__": _("Please try resubmitting the form.")}, status=400
+            )
 
         save_user_agreements(new_user, form)
         send_welcome_email(request, new_user)
 
-        return {'user': new_user, 'is_new': True}
+        return {"user": new_user, "is_new": True}
 
-    request.frontend_context['SOCIAL_AUTH'] = {
-        'backend_name': backend_name,
-        'step': 'register',
-        'email': details.get('email'),
-        'username': kwargs.get('clean_username'),
-        'url': reverse('social:complete', kwargs={'backend': backend.name}),
+    request.frontend_context["SOCIAL_AUTH"] = {
+        "backend_name": backend_name,
+        "step": "register",
+        "email": details.get("email"),
+        "username": kwargs.get("clean_username"),
+        "url": reverse("social:complete", kwargs={"backend": backend.name}),
     }
 
-    return render(request, 'misago/socialauth.html', {
-        'backend_name': backend_name,
-    })
+    return render(request, "misago/socialauth.html", {"backend_name": backend_name})
 
 
 @partial
-def require_activation(strategy, details, backend, user=None, is_new=False, *args, **kwargs):
+def require_activation(
+    strategy, details, backend, user=None, is_new=False, *args, **kwargs
+):
     if not user:
         # Social auth pipeline has entered corrupted state
         # Remove partial auth state and redirect user to beginning
-        partial_token = strategy.session.get('partial_pipeline_token')
+        partial_token = strategy.session.get("partial_pipeline_token")
         if partial_token:
             strategy.clean_partial_pipeline(partial_token)
         return None
-        
+
     if not user.requires_activation:
         return None
 
@@ -253,17 +249,15 @@ def require_activation(strategy, details, backend, user=None, is_new=False, *arg
     backend_name = get_social_auth_backend_name(backend.name)
 
     response_data = get_registration_result_json(user)
-    response_data.update({'step': 'done',  'backend_name': backend_name})
+    response_data.update({"step": "done", "backend_name": backend_name})
 
-    if request.method == 'POST':
+    if request.method == "POST":
         # we are carrying on from requestration request
         return JsonResponse(response_data)
 
-    request.frontend_context['SOCIAL_AUTH'] = response_data
-    request.frontend_context['SOCIAL_AUTH'].update({
-        'url': reverse('social:complete', kwargs={'backend': backend.name}),
-    })
+    request.frontend_context["SOCIAL_AUTH"] = response_data
+    request.frontend_context["SOCIAL_AUTH"].update(
+        {"url": reverse("social:complete", kwargs={"backend": backend.name})}
+    )
 
-    return render(request, 'misago/socialauth.html', {
-        'backend_name': backend_name,
-    })
+    return render(request, "misago/socialauth.html", {"backend_name": backend_name})

+ 9 - 7
misago/users/social/utils.py

@@ -12,12 +12,14 @@ def get_enabled_social_auth_sites_list():
     providers_list = []
     for backend_id in social_auth_backends:
         backend_name = get_social_auth_backend_name(backend_id)
-            
-        providers_list.append({
-            'id': backend_id,
-            'name': backend_name,
-            'url': reverse('social:begin', kwargs={'backend': backend_id}),
-        })
+
+        providers_list.append(
+            {
+                "id": backend_id,
+                "name": backend_name,
+                "url": reverse("social:begin", kwargs={"backend": backend_id}),
+            }
+        )
     return providers_list
 
 
@@ -30,4 +32,4 @@ def get_social_auth_backend_name(backend_id):
 
 
 def perpare_username(username):
-    return ''.join(filter(str.isalnum, unidecode(username)))
+    return "".join(filter(str.isalnum, unidecode(username)))

+ 3 - 3
misago/users/templatetags/misago_avatars.py

@@ -4,10 +4,10 @@ from django import template
 register = template.Library()
 
 
-@register.filter(name='avatar')
+@register.filter(name="avatar")
 def avatar(user, size=200):
     found_avatar = user.avatars[0]
     for user_avatar in user.avatars:
-        if user_avatar['size'] >= size:
+        if user_avatar["size"] >= size:
             found_avatar = user_avatar
-    return found_avatar['url']
+    return found_avatar["url"]

+ 17 - 42
misago/users/tests/test_activation_views.py

@@ -13,29 +13,22 @@ User = get_user_model()
 class ActivationViewsTests(TestCase):
     def test_request_view_returns_200(self):
         """request new activation link view returns 200"""
-        response = self.client.get(reverse('misago:request-activation'))
+        response = self.client.get(reverse("misago:request-activation"))
         self.assertEqual(response.status_code, 200)
 
     def test_view_activate_banned(self):
         """activate banned user shows error"""
-        test_user = create_test_user(
-            'Bob', 'bob@test.com', requires_activation=1
-        )
+        test_user = create_test_user("Bob", "bob@test.com", requires_activation=1)
         Ban.objects.create(
-            check_type=Ban.USERNAME,
-            banned_value='bob',
-            user_message='Nope!',
+            check_type=Ban.USERNAME, banned_value="bob", user_message="Nope!"
         )
 
         activation_token = make_activation_token(test_user)
 
         response = self.client.get(
             reverse(
-                'misago:activate-by-token',
-                kwargs={
-                    'pk': test_user.pk,
-                    'token': activation_token,
-                }
+                "misago:activate-by-token",
+                kwargs={"pk": test_user.pk, "token": activation_token},
             )
         )
         self.assertContains(response, encode_json_html("<p>Nope!</p>"), status_code=403)
@@ -45,19 +38,14 @@ class ActivationViewsTests(TestCase):
 
     def test_view_activate_invalid_token(self):
         """activate with invalid token shows error"""
-        test_user = create_test_user(
-            'Bob', 'bob@test.com', requires_activation=1
-        )
+        test_user = create_test_user("Bob", "bob@test.com", requires_activation=1)
 
         activation_token = make_activation_token(test_user)
 
         response = self.client.get(
             reverse(
-                'misago:activate-by-token',
-                kwargs={
-                    'pk': test_user.pk,
-                    'token': activation_token + 'acd',
-                }
+                "misago:activate-by-token",
+                kwargs={"pk": test_user.pk, "token": activation_token + "acd"},
             )
         )
         self.assertEqual(response.status_code, 400)
@@ -67,36 +55,28 @@ class ActivationViewsTests(TestCase):
 
     def test_view_activate_disabled(self):
         """activate disabled user shows error"""
-        test_user = create_test_user(
-            'Bob', 'bob@test.com', is_active=False
-        )
+        test_user = create_test_user("Bob", "bob@test.com", is_active=False)
 
         activation_token = make_activation_token(test_user)
 
         response = self.client.get(
             reverse(
-                'misago:activate-by-token',
-                kwargs={
-                    'pk': test_user.pk,
-                    'token': activation_token,
-                }
+                "misago:activate-by-token",
+                kwargs={"pk": test_user.pk, "token": activation_token},
             )
         )
         self.assertEqual(response.status_code, 404)
 
     def test_view_activate_active(self):
         """activate active user shows error"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
 
         activation_token = make_activation_token(test_user)
 
         response = self.client.get(
             reverse(
-                'misago:activate-by-token',
-                kwargs={
-                    'pk': test_user.pk,
-                    'token': activation_token,
-                }
+                "misago:activate-by-token",
+                kwargs={"pk": test_user.pk, "token": activation_token},
             )
         )
         self.assertEqual(response.status_code, 200)
@@ -106,19 +86,14 @@ class ActivationViewsTests(TestCase):
 
     def test_view_activate_inactive(self):
         """activate inactive user passess"""
-        test_user = create_test_user(
-            'Bob', 'bob@test.com', requires_activation=1
-        )
+        test_user = create_test_user("Bob", "bob@test.com", requires_activation=1)
 
         activation_token = make_activation_token(test_user)
 
         response = self.client.get(
             reverse(
-                'misago:activate-by-token',
-                kwargs={
-                    'pk': test_user.pk,
-                    'token': activation_token,
-                }
+                "misago:activate-by-token",
+                kwargs={"pk": test_user.pk, "token": activation_token},
             )
         )
         self.assertEqual(response.status_code, 200)

+ 22 - 16
misago/users/tests/test_activepostersranking.py

@@ -6,7 +6,9 @@ from django.utils import timezone
 from misago.categories.models import Category
 from misago.threads.testutils import post_thread
 from misago.users.activepostersranking import (
-    build_active_posters_ranking, get_active_posters_ranking)
+    build_active_posters_ranking,
+    get_active_posters_ranking,
+)
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -17,18 +19,20 @@ class TestActivePostersRanking(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.category = Category.objects.get(slug='first-category')
+        self.category = Category.objects.get(slug="first-category")
 
     def test_get_active_posters_ranking(self):
         """get_active_posters_ranking returns list of active posters"""
         # no posts, empty tanking
         empty_ranking = get_active_posters_ranking()
 
-        self.assertEqual(empty_ranking['users'], [])
-        self.assertEqual(empty_ranking['users_count'], 0)
+        self.assertEqual(empty_ranking["users"], [])
+        self.assertEqual(empty_ranking["users_count"], 0)
 
         # other user that will be posting
-        other_user = UserModel.objects.create_user("OtherUser", "other@user.com", "pass123")
+        other_user = UserModel.objects.create_user(
+            "OtherUser", "other@user.com", "pass123"
+        )
 
         # lurker user that won't post anything
         UserModel.objects.create_user("Lurker", "lurker@user.com", "pass123")
@@ -47,8 +51,8 @@ class TestActivePostersRanking(AuthenticatedUserTestCase):
         build_active_posters_ranking()
         ranking = get_active_posters_ranking()
 
-        self.assertEqual(ranking['users'], [other_user])
-        self.assertEqual(ranking['users_count'], 1)
+        self.assertEqual(ranking["users"], [other_user])
+        self.assertEqual(ranking["users_count"], 1)
 
         # two users in ranking
         post_thread(self.category, poster=self.user)
@@ -57,14 +61,16 @@ class TestActivePostersRanking(AuthenticatedUserTestCase):
         build_active_posters_ranking()
         ranking = get_active_posters_ranking()
 
-        self.assertEqual(ranking['users'], [self.user, other_user])
-        self.assertEqual(ranking['users_count'], 2)
+        self.assertEqual(ranking["users"], [self.user, other_user])
+        self.assertEqual(ranking["users_count"], 2)
 
-        self.assertEqual(ranking['users'][0].score, 2)
-        self.assertEqual(ranking['users'][1].score, 1)
+        self.assertEqual(ranking["users"][0].score, 2)
+        self.assertEqual(ranking["users"][1].score, 1)
 
         # disabled users are not ranked
-        disabled = UserModel.objects.create_user("DisabledUser", "disabled@user.com", "pass123")
+        disabled = UserModel.objects.create_user(
+            "DisabledUser", "disabled@user.com", "pass123"
+        )
 
         disabled.is_active = False
         disabled.save()
@@ -79,8 +85,8 @@ class TestActivePostersRanking(AuthenticatedUserTestCase):
         build_active_posters_ranking()
         ranking = get_active_posters_ranking()
 
-        self.assertEqual(ranking['users'], [self.user, other_user])
-        self.assertEqual(ranking['users_count'], 2)
+        self.assertEqual(ranking["users"], [self.user, other_user])
+        self.assertEqual(ranking["users_count"], 2)
 
-        self.assertEqual(ranking['users'][0].score, 2)
-        self.assertEqual(ranking['users'][1].score, 1)
+        self.assertEqual(ranking["users"][0].score, 2)
+        self.assertEqual(ranking["users"][1].score, 1)

+ 7 - 7
misago/users/tests/test_audittrail.py

@@ -11,7 +11,7 @@ from misago.users.testutils import UserTestCase
 
 UserModel = get_user_model()
 
-USER_IP = '13.41.51.41'
+USER_IP = "13.41.51.41"
 
 
 class MockRequest(object):
@@ -25,7 +25,7 @@ class CreateAuditTrailTests(UserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.obj = UserModel.objects.create_user('BobBoberson', 'bob@example.com')
+        self.obj = UserModel.objects.create_user("BobBoberson", "bob@example.com")
 
     def test_create_audit_require_model(self):
         """create_audit_trail requires model instance"""
@@ -88,7 +88,7 @@ class CreateAuditTrailTests(UserTestCase):
 
         audit_trail = user.audittrail_set.all()[0]
         audit_trail.delete()
-        
+
         UserModel.objects.get(id=user.id)
         UserModel.objects.get(id=self.obj.id)
 
@@ -97,7 +97,7 @@ class CreateUserAuditTrailTests(UserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.obj = UserModel.objects.create_user('BobBoberson', 'bob@example.com')
+        self.obj = UserModel.objects.create_user("BobBoberson", "bob@example.com")
 
     def test_create_user_audit_require_model(self):
         """create_user_audit_trail requires model instance"""
@@ -154,7 +154,7 @@ class CreateUserAuditTrailTests(UserTestCase):
 
         audit_trail = user.audittrail_set.all()[0]
         audit_trail.delete()
-        
+
         UserModel.objects.get(id=user.id)
         UserModel.objects.get(id=self.obj.id)
 
@@ -163,8 +163,8 @@ class RemoveOldAuditTrailsTest(UserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.obj = UserModel.objects.create_user('BobBoberson', 'bob@example.com')
-        
+        self.obj = UserModel.objects.create_user("BobBoberson", "bob@example.com")
+
     def test_recent_audit_trail_is_kept(self):
         """remove_old_ips keeps recent audit trails"""
         user = self.get_authenticated_user()

+ 218 - 300
misago/users/tests/test_auth_api.py

@@ -13,332 +13,280 @@ class GatewayTests(TestCase):
     def test_api_invalid_credentials(self):
         """login api returns 400 on invalid POST"""
         response = self.client.post(
-            '/api/auth/', data={
-                'username': 'nope',
-                'password': 'nope',
-            }
+            "/api/auth/", data={"username": "nope", "password": "nope"}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': "invalid_login",
-            "detail": "Login or password is incorrect.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"code": "invalid_login", "detail": "Login or password is incorrect."},
+        )
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertIsNone(user_json['id'])
+        self.assertIsNone(user_json["id"])
 
     def test_login(self):
         """api signs user in"""
-        user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
         response = self.client.post(
-            '/api/auth/',
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            "/api/auth/", data={"username": "Bob", "password": "Pass.123"}
         )
 
         self.assertEqual(response.status_code, 200)
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertEqual(user_json['id'], user.id)
-        self.assertEqual(user_json['username'], user.username)
+        self.assertEqual(user_json["id"], user.id)
+        self.assertEqual(user_json["username"], user.username)
 
     def test_login_whitespaces_password(self):
         """api signs user in with password left untouched"""
-        user = UserModel.objects.create_user('Bob', 'bob@test.com', ' Pass.123 ')
+        user = UserModel.objects.create_user("Bob", "bob@test.com", " Pass.123 ")
 
         response = self.client.post(
-            '/api/auth/',
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            "/api/auth/", data={"username": "Bob", "password": "Pass.123"}
         )
 
         self.assertEqual(response.status_code, 400)
 
         response = self.client.post(
-            '/api/auth/',
-            data={
-                'username': 'Bob',
-                'password': ' Pass.123 ',
-            },
+            "/api/auth/", data={"username": "Bob", "password": " Pass.123 "}
         )
 
         self.assertEqual(response.status_code, 200)
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertEqual(user_json['id'], user.id)
-        self.assertEqual(user_json['username'], user.username)
+        self.assertEqual(user_json["id"], user.id)
+        self.assertEqual(user_json["username"], user.username)
 
     def test_submit_empty(self):
         """login api errors for no body"""
-        response = self.client.post('/api/auth/')
+        response = self.client.post("/api/auth/")
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': "empty_data",
-            "detail": "Fill out both fields.",
-        })
+        self.assertEqual(
+            response.json(), {"code": "empty_data", "detail": "Fill out both fields."}
+        )
 
     def test_submit_invalid(self):
         """login api errors for invalid data"""
         response = self.client.post(
-            '/api/auth/',
-            'false',
-            content_type="application/json",
+            "/api/auth/", "false", content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "non_field_errors": [
+                    "Invalid data. Expected a dictionary, but got bool."
+                ]
+            },
+        )
 
     def test_login_not_usable_password(self):
         """login api fails to sign user with not-usable password in"""
-        UserModel.objects.create_user('Bob', 'bob@test.com')
+        UserModel.objects.create_user("Bob", "bob@test.com")
 
         response = self.client.post(
-            '/api/auth/',
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            "/api/auth/", data={"username": "Bob", "password": "Pass.123"}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': 'invalid_login',
-            'detail': 'Login or password is incorrect.',
-        })
+        self.assertEqual(
+            response.json(),
+            {"code": "invalid_login", "detail": "Login or password is incorrect."},
+        )
 
     def test_login_banned(self):
         """login api fails to sign banned user in"""
-        UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
         ban = Ban.objects.create(
             check_type=Ban.USERNAME,
-            banned_value='bob',
-            user_message='You are tragically banned.',
+            banned_value="bob",
+            user_message="You are tragically banned.",
         )
 
         response = self.client.post(
-            '/api/auth/',
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            "/api/auth/", data={"username": "Bob", "password": "Pass.123"}
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
-        self.assertEqual(response_json['code'], 'banned')
-        self.assertEqual(response_json['detail']['message']['plain'], ban.user_message)
+        self.assertEqual(response_json["code"], "banned")
+        self.assertEqual(response_json["detail"]["message"]["plain"], ban.user_message)
         self.assertEqual(
-            response_json['detail']['message']['html'], '<p>%s</p>' % ban.user_message
+            response_json["detail"]["message"]["html"], "<p>%s</p>" % ban.user_message
         )
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertIsNone(user_json['id'])
+        self.assertIsNone(user_json["id"])
 
     def test_login_banned_staff(self):
         """login api signs banned staff member in"""
-        user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
         user.is_staff = True
         user.save()
 
         Ban.objects.create(
             check_type=Ban.USERNAME,
-            banned_value='bob',
-            user_message='You are tragically banned.',
+            banned_value="bob",
+            user_message="You are tragically banned.",
         )
 
         response = self.client.post(
-            '/api/auth/',
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            "/api/auth/", data={"username": "Bob", "password": "Pass.123"}
         )
         self.assertEqual(response.status_code, 200)
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertEqual(user_json['id'], user.id)
-        self.assertEqual(user_json['username'], user.username)
+        self.assertEqual(user_json["id"], user.id)
+        self.assertEqual(user_json["username"], user.username)
 
     def test_login_ban_registration_only(self):
         """login api ignores registration-only bans"""
-        user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
         Ban.objects.create(
-            check_type=Ban.USERNAME,
-            banned_value='bob',
-            registration_only=True,
+            check_type=Ban.USERNAME, banned_value="bob", registration_only=True
         )
 
         response = self.client.post(
-            '/api/auth/',
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            "/api/auth/", data={"username": "Bob", "password": "Pass.123"}
         )
         self.assertEqual(response.status_code, 200)
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertEqual(user_json['id'], user.id)
-        self.assertEqual(user_json['username'], user.username)
+        self.assertEqual(user_json["id"], user.id)
+        self.assertEqual(user_json["username"], user.username)
 
     def test_login_inactive_admin(self):
         """login api fails to sign admin-activated user in"""
-        UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123', requires_activation=1)
+        UserModel.objects.create_user(
+            "Bob", "bob@test.com", "Pass.123", requires_activation=1
+        )
 
         response = self.client.post(
-            '/api/auth/',
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            "/api/auth/", data={"username": "Bob", "password": "Pass.123"}
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
-        self.assertEqual(response_json['code'], 'inactive_user')
+        self.assertEqual(response_json["code"], "inactive_user")
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertIsNone(user_json['id'])
+        self.assertIsNone(user_json["id"])
 
     def test_login_inactive_user(self):
         """login api fails to sign user-activated user in"""
-        UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123', requires_activation=2)
+        UserModel.objects.create_user(
+            "Bob", "bob@test.com", "Pass.123", requires_activation=2
+        )
 
         response = self.client.post(
-            '/api/auth/',
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            "/api/auth/", data={"username": "Bob", "password": "Pass.123"}
         )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
-        self.assertEqual(response_json['code'], 'inactive_admin')
+        self.assertEqual(response_json["code"], "inactive_admin")
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertIsNone(user_json['id'])
+        self.assertIsNone(user_json["id"])
 
     def test_login_disabled_user(self):
         """its impossible to sign in to disabled account"""
-        user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123', is_active=False)
+        user = UserModel.objects.create_user(
+            "Bob", "bob@test.com", "Pass.123", is_active=False
+        )
 
         user.is_staff = True
         user.save()
 
         response = self.client.post(
-            '/api/auth/',
-            data={
-                'username': 'Bob',
-                'password': 'Pass.123',
-            },
+            "/api/auth/", data={"username": "Bob", "password": "Pass.123"}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': 'invalid_login',
-            'detail': 'Login or password is incorrect.',
-        })
+        self.assertEqual(
+            response.json(),
+            {"code": "invalid_login", "detail": "Login or password is incorrect."},
+        )
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertIsNone(user_json['id'])
+        self.assertIsNone(user_json["id"])
 
 
 class UserCredentialsTests(TestCase):
     def test_edge_returns_response(self):
         """api edge has no showstoppers"""
-        response = self.client.get('/api/auth/criteria/')
+        response = self.client.get("/api/auth/criteria/")
         self.assertEqual(response.status_code, 200)
 
 
 class SendActivationApiTests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        self.user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
         self.user.requires_activation = 1
         self.user.save()
 
-        self.link = '/api/auth/send-activation/'
+        self.link = "/api/auth/send-activation/"
 
     def test_submit_valid(self):
         """request activation link api sends reset link mail"""
-        response = self.client.post(
-            self.link,
-            data={
-                'email': self.user.email,
-            },
-        )
+        response = self.client.post(self.link, data={"email": self.user.email})
         self.assertEqual(response.status_code, 200)
 
-        self.assertIn('Activate Bob', mail.outbox[0].subject)
+        self.assertIn("Activate Bob", mail.outbox[0].subject)
 
     def test_submit_banned(self):
         """request activation link api passes for banned users"""
         Ban.objects.create(
             check_type=Ban.USERNAME,
             banned_value=self.user.username,
-            user_message='Nope!',
+            user_message="Nope!",
         )
 
-        response = self.client.post(
-            self.link,
-            data={
-                'email': self.user.email,
-            },
-        )
+        response = self.client.post(self.link, data={"email": self.user.email})
         self.assertEqual(response.status_code, 200)
 
-        self.assertIn('Activate Bob', mail.outbox[0].subject)
+        self.assertIn("Activate Bob", mail.outbox[0].subject)
 
     def test_submit_disabled(self):
         """request activation link api fails disabled users"""
         self.user.is_active = False
         self.user.save()
 
-        response = self.client.post(
-            self.link,
-            data={
-                'email': self.user.email,
-            },
-        )
+        response = self.client.post(self.link, data={"email": self.user.email})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': 'not_found',
-            'detail': 'No user with this e-mail exists.',
-        })
+        self.assertEqual(
+            response.json(),
+            {"code": "not_found", "detail": "No user with this e-mail exists."},
+        )
 
         self.assertTrue(not mail.outbox)
 
@@ -346,38 +294,33 @@ class SendActivationApiTests(TestCase):
         """request activation link api errors for no body"""
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': 'empty_email',
-            'detail': 'Enter e-mail address.',
-        })
+        self.assertEqual(
+            response.json(), {"code": "empty_email", "detail": "Enter e-mail address."}
+        )
 
         self.assertTrue(not mail.outbox)
 
     def test_submit_invalid_data(self):
         """login api errors for invalid data"""
-        response = self.client.post(
-            self.link,
-            'false',
-            content_type="application/json",
-        )
+        response = self.client.post(self.link, "false", content_type="application/json")
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "non_field_errors": [
+                    "Invalid data. Expected a dictionary, but got bool."
+                ]
+            },
+        )
 
     def test_submit_invalid_email(self):
         """request activation link api errors for invalid email"""
-        response = self.client.post(
-            self.link,
-            data={
-                'email': 'fake@mail.com',
-            },
-        )
+        response = self.client.post(self.link, data={"email": "fake@mail.com"})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': 'not_found',
-            'detail': 'No user with this e-mail exists.',
-        })
+        self.assertEqual(
+            response.json(),
+            {"code": "not_found", "detail": "No user with this e-mail exists."},
+        )
 
         self.assertTrue(not mail.outbox)
 
@@ -386,34 +329,30 @@ class SendActivationApiTests(TestCase):
         self.user.requires_activation = 0
         self.user.save()
 
-        response = self.client.post(
-            self.link,
-            data={
-                'email': self.user.email,
+        response = self.client.post(self.link, data={"email": self.user.email})
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json(),
+            {
+                "code": "already_active",
+                "detail": "Bob, your account is already active.",
             },
         )
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': 'already_active',
-            'detail': 'Bob, your account is already active.',
-        })
 
     def test_submit_inactive_user(self):
         """request activation link api errors for admin-activated users"""
         self.user.requires_activation = 2
         self.user.save()
 
-        response = self.client.post(
-            self.link,
-            data={
-                'email': self.user.email,
+        response = self.client.post(self.link, data={"email": self.user.email})
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json(),
+            {
+                "code": "inactive_admin",
+                "detail": "Bob, only administrator may activate your account.",
             },
         )
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': 'inactive_admin',
-            'detail': 'Bob, only administrator may activate your account.',
-        })
 
         self.assertTrue(not mail.outbox)
 
@@ -421,11 +360,7 @@ class SendActivationApiTests(TestCase):
         self.user.requires_activation = 1
         self.user.save()
 
-        response = self.client.post(
-            self.link, data={
-                'email': self.user.email,
-            }
-        )
+        response = self.client.post(self.link, data={"email": self.user.email})
         self.assertEqual(response.status_code, 200)
 
         self.assertTrue(mail.outbox)
@@ -433,56 +368,41 @@ class SendActivationApiTests(TestCase):
 
 class SendPasswordFormApiTests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        self.user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
-        self.link = '/api/auth/send-password-form/'
+        self.link = "/api/auth/send-password-form/"
 
     def test_submit_valid(self):
         """request change password form link api sends reset link mail"""
-        response = self.client.post(
-            self.link,
-            data={
-                'email': self.user.email,
-            },
-        )
+        response = self.client.post(self.link, data={"email": self.user.email})
         self.assertEqual(response.status_code, 200)
 
-        self.assertIn('Change Bob password', mail.outbox[0].subject)
+        self.assertIn("Change Bob password", mail.outbox[0].subject)
 
     def test_submit_banned(self):
         """request change password form link api sends reset link mail"""
         Ban.objects.create(
             check_type=Ban.USERNAME,
             banned_value=self.user.username,
-            user_message='Nope!',
+            user_message="Nope!",
         )
 
-        response = self.client.post(
-            self.link,
-            data={
-                'email': self.user.email,
-            },
-        )
+        response = self.client.post(self.link, data={"email": self.user.email})
         self.assertEqual(response.status_code, 200)
 
-        self.assertIn('Change Bob password', mail.outbox[0].subject)
+        self.assertIn("Change Bob password", mail.outbox[0].subject)
 
     def test_submit_disabled(self):
         """request change password form api fails disabled users"""
         self.user.is_active = False
         self.user.save()
 
-        response = self.client.post(
-            self.link,
-            data={
-                'email': self.user.email,
-            },
-        )
+        response = self.client.post(self.link, data={"email": self.user.email})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': 'not_found',
-            'detail': 'No user with this e-mail exists.',
-        })
+        self.assertEqual(
+            response.json(),
+            {"code": "not_found", "detail": "No user with this e-mail exists."},
+        )
 
         self.assertTrue(not mail.outbox)
 
@@ -490,149 +410,142 @@ class SendPasswordFormApiTests(TestCase):
         """request change password form link api errors for no body"""
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': 'empty_email',
-            'detail': 'Enter e-mail address.',
-        })
+        self.assertEqual(
+            response.json(), {"code": "empty_email", "detail": "Enter e-mail address."}
+        )
 
         self.assertTrue(not mail.outbox)
 
     def test_submit_invalid(self):
         """request change password form link api errors for invalid email"""
-        response = self.client.post(
-            self.link,
-            data={
-                'email': 'fake@mail.com',
-            },
-        )
+        response = self.client.post(self.link, data={"email": "fake@mail.com"})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': 'not_found',
-            'detail': 'No user with this e-mail exists.',
-        })
+        self.assertEqual(
+            response.json(),
+            {"code": "not_found", "detail": "No user with this e-mail exists."},
+        )
 
         self.assertTrue(not mail.outbox)
 
     def test_submit_invalid_data(self):
         """login api errors for invalid data"""
-        response = self.client.post(
-            self.link,
-            'false',
-            content_type="application/json",
-        )
+        response = self.client.post(self.link, "false", content_type="application/json")
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "non_field_errors": [
+                    "Invalid data. Expected a dictionary, but got bool."
+                ]
+            },
+        )
 
     def test_submit_inactive_user(self):
         """request change password form link api errors for inactive users"""
         self.user.requires_activation = 1
         self.user.save()
 
-        response = self.client.post(
-            self.link,
-            data={
-                'email': self.user.email,
+        response = self.client.post(self.link, data={"email": self.user.email})
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json(),
+            {
+                "code": "inactive_user",
+                "detail": (
+                    "You have to activate your account before you "
+                    "will be able to request new password."
+                ),
             },
         )
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': 'inactive_user',
-            'detail': (
-                'You have to activate your account before you '
-                'will be able to request new password.'
-            )
-        })
 
         self.user.requires_activation = 2
         self.user.save()
 
-        response = self.client.post(
-            self.link,
-            data={
-                'email': self.user.email,
+        response = self.client.post(self.link, data={"email": self.user.email})
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(
+            response.json(),
+            {
+                "code": "inactive_admin",
+                "detail": (
+                    "Administrator has to activate your account before you "
+                    "will be able to request new password."
+                ),
             },
         )
-        self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'code': 'inactive_admin',
-            'detail': (
-                'Administrator has to activate your account before you '
-                'will be able to request new password.'
-            )
-        })
 
         self.assertTrue(not mail.outbox)
 
 
 class ChangePasswordApiTests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        self.user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
-        self.link = '/api/auth/change-password/%s/%s/'
+        self.link = "/api/auth/change-password/%s/%s/"
 
     def test_submit_valid(self):
         """submit change password form api changes password"""
         response = self.client.post(
             self.link % (self.user.pk, make_password_change_token(self.user)),
-            data={
-                'password': 'n3wp4ss!',
-            },
+            data={"password": "n3wp4ss!"},
         )
         self.assertEqual(response.status_code, 200)
 
         user = UserModel.objects.get(id=self.user.pk)
-        self.assertTrue(user.check_password('n3wp4ss!'))
+        self.assertTrue(user.check_password("n3wp4ss!"))
 
     def test_submit_with_whitespaces(self):
         """submit change password form api changes password with whitespaces"""
         response = self.client.post(
             self.link % (self.user.pk, make_password_change_token(self.user)),
-            data={
-                'password': ' n3wp4ss! ',
-            },
+            data={"password": " n3wp4ss! "},
         )
         self.assertEqual(response.status_code, 200)
 
         user = UserModel.objects.get(id=self.user.pk)
-        self.assertTrue(user.check_password(' n3wp4ss! '))
+        self.assertTrue(user.check_password(" n3wp4ss! "))
 
     def test_submit_invalid_data(self):
         """login api errors for invalid data"""
         response = self.client.post(
             self.link % (self.user.pk, make_password_change_token(self.user)),
-            'false',
+            "false",
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'non_field_errors': ['Invalid data. Expected a dictionary, but got bool.']
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "non_field_errors": [
+                    "Invalid data. Expected a dictionary, but got bool."
+                ]
+            },
+        )
 
     def test_invalid_token_link(self):
         """api errors on invalid user id link"""
-        response = self.client.post(self.link % (self.user.pk, 'asda7ad89sa7d9s789as'))
+        response = self.client.post(self.link % (self.user.pk, "asda7ad89sa7d9s789as"))
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'detail': 'Form link is invalid. Please try again.'
-        })
+        self.assertEqual(
+            response.json(), {"detail": "Form link is invalid. Please try again."}
+        )
 
     def test_banned_user_link(self):
         """request errors because user is banned"""
         Ban.objects.create(
             check_type=Ban.USERNAME,
             banned_value=self.user.username,
-            user_message='Nope!',
+            user_message="Nope!",
         )
 
         response = self.client.post(
             self.link % (self.user.pk, make_password_change_token(self.user))
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'detail': 'Your link has expired. Please request new one.'
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Your link has expired. Please request new one."},
+        )
 
     def test_inactive_user(self):
         """change password api errors for inactive users"""
@@ -643,9 +556,10 @@ class ChangePasswordApiTests(TestCase):
             self.link % (self.user.pk, make_password_change_token(self.user))
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'detail': 'Your link has expired. Please request new one.'
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Your link has expired. Please request new one."},
+        )
 
         self.user.requires_activation = 2
         self.user.save()
@@ -654,9 +568,10 @@ class ChangePasswordApiTests(TestCase):
             self.link % (self.user.pk, make_password_change_token(self.user))
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'detail': 'Your link has expired. Please request new one.'
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Your link has expired. Please request new one."},
+        )
 
     def test_disabled_user(self):
         """change password api errors for disabled users"""
@@ -667,9 +582,9 @@ class ChangePasswordApiTests(TestCase):
             self.link % (self.user.pk, make_password_change_token(self.user))
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'detail': 'Form link is invalid. Please try again.'
-        })
+        self.assertEqual(
+            response.json(), {"detail": "Form link is invalid. Please try again."}
+        )
 
     def test_submit_empty(self):
         """change password api errors for empty body"""
@@ -677,6 +592,9 @@ class ChangePasswordApiTests(TestCase):
             self.link % (self.user.pk, make_password_change_token(self.user))
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'detail': "This password is too short. It must contain at least 7 characters."
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "This password is too short. It must contain at least 7 characters."
+            },
+        )

+ 12 - 22
misago/users/tests/test_auth_backend.py

@@ -11,15 +11,15 @@ backend = MisagoBackend()
 
 class MisagoBackendTests(TestCase):
     def setUp(self):
-        self.password = 'Pass.123'
-        self.user = UserModel.objects.create_user('BobBoberson', 'bob@test.com', self.password)
+        self.password = "Pass.123"
+        self.user = UserModel.objects.create_user(
+            "BobBoberson", "bob@test.com", self.password
+        )
 
     def test_authenticate_username(self):
         """auth authenticates with username"""
         user = backend.authenticate(
-            None,
-            username=self.user.username,
-            password=self.password,
+            None, username=self.user.username, password=self.password
         )
 
         self.assertEqual(user, self.user)
@@ -27,9 +27,7 @@ class MisagoBackendTests(TestCase):
     def test_authenticate_email(self):
         """auth authenticates with email instead of username"""
         user = backend.authenticate(
-            None,
-            username=self.user.email,
-            password=self.password,
+            None, username=self.user.email, password=self.password
         )
 
         self.assertEqual(user, self.user)
@@ -40,7 +38,7 @@ class MisagoBackendTests(TestCase):
             None,
             username=self.user.username,
             password=self.password,
-            email=self.user.email
+            email=self.user.email,
         )
 
         self.assertEqual(user, self.user)
@@ -49,9 +47,9 @@ class MisagoBackendTests(TestCase):
         """auth authenticates with email and invalid username"""
         user = backend.authenticate(
             None,
-            username='skipped-username',
+            username="skipped-username",
             password=self.password,
-            email=self.user.email
+            email=self.user.email,
         )
 
         self.assertEqual(user, self.user)
@@ -59,20 +57,14 @@ class MisagoBackendTests(TestCase):
     def test_authenticate_invalid_credential(self):
         """auth handles invalid credentials"""
         user = backend.authenticate(
-            None,
-            username='InvalidCredential',
-            password=self.password,
+            None, username="InvalidCredential", password=self.password
         )
 
         self.assertIsNone(user)
 
     def test_authenticate_invalid_password(self):
         """auth validates password"""
-        user = backend.authenticate(
-            None,
-            username=self.user.email,
-            password='Invalid',
-        )
+        user = backend.authenticate(None, username=self.user.email, password="Invalid")
 
         self.assertIsNone(user)
 
@@ -82,9 +74,7 @@ class MisagoBackendTests(TestCase):
         self.user.save()
 
         user = backend.authenticate(
-            None,
-            username=self.user.email,
-            password=self.password,
+            None, username=self.user.email, password=self.password
         )
 
         self.assertIsNone(user)

+ 24 - 40
misago/users/tests/test_auth_views.py

@@ -5,98 +5,82 @@ from django.urls import reverse
 class AuthViewsTests(TestCase):
     def test_auth_views_return_302(self):
         """auth views should always return redirect"""
-        response = self.client.get(reverse('misago:login'))
+        response = self.client.get(reverse("misago:login"))
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.post(reverse('misago:login'))
+        response = self.client.post(reverse("misago:login"))
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:logout'))
+        response = self.client.get(reverse("misago:logout"))
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.post(reverse('misago:logout'))
+        response = self.client.post(reverse("misago:logout"))
         self.assertEqual(response.status_code, 302)
 
     def test_login_view_redirect_to(self):
         """login view respects redirect_to POST"""
         # valid redirect
         response = self.client.post(
-            reverse('misago:login'),
-            data={
-                'redirect_to': '/redirect/',
-            },
+            reverse("misago:login"), data={"redirect_to": "/redirect/"}
         )
 
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response['location'], '/redirect/?ref=login')
+        self.assertEqual(response["location"], "/redirect/?ref=login")
 
         # invalid redirect (redirects to other site)
         response = self.client.post(
-            reverse('misago:login'),
-            data={
-                'redirect_to': 'http://somewhereelse.com/page.html',
-            },
+            reverse("misago:login"),
+            data={"redirect_to": "http://somewhereelse.com/page.html"},
         )
 
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response['location'], '/')
+        self.assertEqual(response["location"], "/")
 
         # invalid redirect (link name)
         response = self.client.post(
-            reverse('misago:login'),
-            data={
-                'redirect_to': 'misago:users',
-            },
+            reverse("misago:login"), data={"redirect_to": "misago:users"}
         )
 
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response['location'], '/')
+        self.assertEqual(response["location"], "/")
 
         # invalid redirect (non url)
         response = self.client.post(
-            reverse('misago:login'),
-            data={
-                'redirect_to': 'canada goose not url!',
-            },
+            reverse("misago:login"), data={"redirect_to": "canada goose not url!"}
         )
 
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response['location'], '/')
+        self.assertEqual(response["location"], "/")
 
         # invalid redirect (unicode)
         response = self.client.post(
-            reverse('misago:login'),
-            data={
-                'redirect_to': 'łelcome!',
-            },
+            reverse("misago:login"), data={"redirect_to": "łelcome!"}
         )
 
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response['location'], '/')
+        self.assertEqual(response["location"], "/")
 
     def test_logout_view(self):
         """logout view logs user out on post"""
         response = self.client.post(
-            '/api/auth/',
-            data={
-                'username': 'nope',
-                'password': 'nope',
-            },
+            "/api/auth/", data={"username": "nope", "password": "nope"}
         )
 
-        self.assertContains(response, "Login or password is incorrect.", status_code=400)
+        self.assertContains(
+            response, "Login or password is incorrect.", status_code=400
+        )
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertIsNone(user_json['id'])
+        self.assertIsNone(user_json["id"])
 
-        response = self.client.post(reverse('misago:logout'))
+        response = self.client.post(reverse("misago:logout"))
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertIsNone(user_json['id'])
+        self.assertIsNone(user_json["id"])

+ 33 - 30
misago/users/tests/test_avatars.py

@@ -9,7 +9,14 @@ from django.test import TestCase
 from django.utils.crypto import get_random_string
 
 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
 
 User = get_user_model()
@@ -18,7 +25,7 @@ User = get_user_model()
 class AvatarsStoreTests(TestCase):
     def test_store(self):
         """store successfully stores and deletes avatar"""
-        user = User.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        user = User.objects.create_user("Bob", "bob@bob.com", "pass123")
 
         test_image = Image.new("RGBA", (100, 100), 0)
         store.store_new_avatar(user, test_image)
@@ -40,8 +47,8 @@ class AvatarsStoreTests(TestCase):
         self.assertEqual(len(user.avatars), len(avatars_dict))
 
         for avatar in user.avatars:
-            self.assertIn(avatar['size'], settings.MISAGO_AVATARS_SIZES)
-            self.assertEqual(avatar['url'], avatars_dict[avatar['size']].url)
+            self.assertIn(avatar["size"], settings.MISAGO_AVATARS_SIZES)
+            self.assertEqual(avatar["url"], avatars_dict[avatar["size"]].url)
 
         # another avatar change deleted old avatars
         store.store_new_avatar(user, test_image)
@@ -68,8 +75,8 @@ class AvatarsStoreTests(TestCase):
         # asserts that user.avatars cache was updated
         self.assertEqual(len(user.avatars), len(settings.MISAGO_AVATARS_SIZES))
         for avatar in user.avatars:
-            self.assertIn(avatar['size'], settings.MISAGO_AVATARS_SIZES)
-            self.assertEqual(avatar['url'], new_avatars_dict[avatar['size']].url)
+            self.assertIn(avatar["size"], settings.MISAGO_AVATARS_SIZES)
+            self.assertEqual(avatar["url"], new_avatars_dict[avatar["size"]].url)
 
         # delete avatar
         store.delete_avatar(user)
@@ -85,7 +92,7 @@ class AvatarsStoreTests(TestCase):
 
 class AvatarSetterTests(TestCase):
     def setUp(self):
-        self.user = User.objects.create_user('Bob', 'kontakt@rpiton.com', 'pass123')
+        self.user = User.objects.create_user("Bob", "kontakt@rpiton.com", "pass123")
 
         self.user.avatars = None
         self.user.save()
@@ -138,7 +145,7 @@ class AvatarSetterTests(TestCase):
         gallery.load_avatar_galleries()
 
         self.assertNoAvatarIsSet()
-        test_avatar = AvatarGallery.objects.order_by('id').last()
+        test_avatar = AvatarGallery.objects.order_by("id").last()
         gallery.set_avatar(self.user, test_avatar)
         self.assertAvatarWasSet()
 
@@ -151,33 +158,37 @@ class AvatarSetterTests(TestCase):
     def test_default_avatar_gravatar(self):
         """default gravatar gets set"""
         self.assertNoAvatarIsSet()
-        set_default_avatar(self.user, 'gravatar', 'dynamic')
+        set_default_avatar(self.user, "gravatar", "dynamic")
         self.assertAvatarWasSet()
 
     def test_default_avatar_gravatar_fallback_dynamic(self):
         """default gravatar fails but fallback dynamic works"""
-        gibberish_email = '%s@%s.%s' % (
-            get_random_string(6), get_random_string(6), get_random_string(3)
+        gibberish_email = "%s@%s.%s" % (
+            get_random_string(6),
+            get_random_string(6),
+            get_random_string(3),
         )
 
         self.user.set_email(gibberish_email)
         self.user.save()
 
         self.assertNoAvatarIsSet()
-        set_default_avatar(self.user, 'gravatar', 'dynamic')
+        set_default_avatar(self.user, "gravatar", "dynamic")
         self.assertAvatarWasSet()
 
     def test_default_avatar_gravatar_fallback_empty_gallery(self):
         """default both gravatar and fallback fail set"""
-        gibberish_email = '%s@%s.%s' % (
-            get_random_string(6), get_random_string(6), get_random_string(3)
+        gibberish_email = "%s@%s.%s" % (
+            get_random_string(6),
+            get_random_string(6),
+            get_random_string(3),
         )
         self.user.set_email(gibberish_email)
         self.user.save()
 
         self.assertNoAvatarIsSet()
         self.user.save()
-        set_default_avatar(self.user, 'gravatar', 'gallery')
+        set_default_avatar(self.user, "gravatar", "gallery")
         self.assertAvatarWasSet()
 
 
@@ -197,21 +208,13 @@ class UploadedAvatarTests(TestCase):
         with self.assertRaises(ValidationError):
             uploaded.clean_crop(image, {})
         with self.assertRaises(ValidationError):
-            uploaded.clean_crop(image, {'offset': {'x': 'ugabuga'}})
+            uploaded.clean_crop(image, {"offset": {"x": "ugabuga"}})
 
         with self.assertRaises(ValidationError):
-            uploaded.clean_crop(image, {'offset': {
-                'x': 0,
-                'y': 0,
-            },
-                                        'zoom': -2})
+            uploaded.clean_crop(image, {"offset": {"x": 0, "y": 0}, "zoom": -2})
 
         with self.assertRaises(ValidationError):
-            uploaded.clean_crop(image, {'offset': {
-                'x': 0,
-                'y': 0,
-            },
-                                        'zoom': 2})
+            uploaded.clean_crop(image, {"offset": {"x": 0, "y": 0}, "zoom": 2})
 
     def test_uploaded_image_size_validation(self):
         """uploaded image size is validated"""
@@ -226,18 +229,18 @@ class UploadedAvatarTests(TestCase):
 
     def test_uploaded_image_extension_validation(self):
         """uploaded image extension is validated"""
-        for invalid_extension in ('.txt', '.zip', '.py', '.tiff'):
+        for invalid_extension in (".txt", ".zip", ".py", ".tiff"):
             with self.assertRaises(ValidationError):
-                image = MockAvatarFile(name='test%s' % invalid_extension)
+                image = MockAvatarFile(name="test%s" % invalid_extension)
                 uploaded.validate_extension(image)
 
         for valid_extension in uploaded.ALLOWED_EXTENSIONS:
-            image = MockAvatarFile(name='test%s' % valid_extension)
+            image = MockAvatarFile(name="test%s" % valid_extension)
             uploaded.validate_extension(image)
 
     def test_uploaded_image_mime_validation(self):
         """uploaded image mime type is validated"""
-        image = MockAvatarFile(mime='fake/mime')
+        image = MockAvatarFile(mime="fake/mime")
         with self.assertRaises(ValidationError):
             uploaded.validate_mime(image)
 

+ 12 - 33
misago/users/tests/test_avatarserver_views.py

@@ -10,21 +10,12 @@ UserModel = get_user_model()
 
 class AvatarServerTests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'Pass123')
+        self.user = UserModel.objects.create_user("Bob", "bob@bob.com", "Pass123")
 
         self.user.avatars = [
-            {
-                'size': 200,
-                'url': '/media/avatars/avatar-200.png',
-            },
-            {
-                'size': 100,
-                'url': '/media/avatars/avatar-100.png',
-            },
-            {
-                'size': 50,
-                'url': '/media/avatars/avatar-50.png',
-            },
+            {"size": 200, "url": "/media/avatars/avatar-200.png"},
+            {"size": 100, "url": "/media/avatars/avatar-100.png"},
+            {"size": 50, "url": "/media/avatars/avatar-50.png"},
         ]
 
         self.user.save()
@@ -32,50 +23,38 @@ class AvatarServerTests(TestCase):
     def test_get_user_avatar_exact_size(self):
         """avatar server resolved valid avatar url for user"""
         avatar_url = reverse(
-            'misago:user-avatar',
-            kwargs={
-                'pk': self.user.pk,
-                'size': 100,
-            },
+            "misago:user-avatar", kwargs={"pk": self.user.pk, "size": 100}
         )
 
         response = self.client.get(avatar_url)
 
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response['location'], self.user.avatars[1]['url'])
+        self.assertEqual(response["location"], self.user.avatars[1]["url"])
 
     def test_get_user_avatar_inexact_size(self):
         """avatar server resolved valid avatar fallback for user"""
         avatar_url = reverse(
-            'misago:user-avatar',
-            kwargs={
-                'pk': self.user.pk,
-                'size': 150,
-            },
+            "misago:user-avatar", kwargs={"pk": self.user.pk, "size": 150}
         )
 
         response = self.client.get(avatar_url)
 
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response['location'], self.user.avatars[0]['url'])
+        self.assertEqual(response["location"], self.user.avatars[0]["url"])
 
     def test_get_notfound_user_avatar(self):
         """avatar server handles deleted user avatar requests"""
         avatar_url = reverse(
-            'misago:user-avatar',
-            kwargs={
-                'pk': self.user.pk + 1,
-                'size': 150,
-            },
+            "misago:user-avatar", kwargs={"pk": self.user.pk + 1, "size": 150}
         )
         response = self.client.get(avatar_url)
 
         self.assertEqual(response.status_code, 302)
-        self.assertTrue(response['location'].endswith(settings.MISAGO_BLANK_AVATAR))
+        self.assertTrue(response["location"].endswith(settings.MISAGO_BLANK_AVATAR))
 
     def test_blank_avatar_serving(self):
         """avatar server handles blank avatar requests"""
-        response = self.client.get(reverse('misago:blank-avatar'))
+        response = self.client.get(reverse("misago:blank-avatar"))
 
         self.assertEqual(response.status_code, 302)
-        self.assertTrue(response['location'].endswith(settings.MISAGO_BLANK_AVATAR))
+        self.assertTrue(response["location"].endswith(settings.MISAGO_BLANK_AVATAR))

+ 27 - 25
misago/users/tests/test_ban_model.py

@@ -5,36 +5,38 @@ from misago.users.models import Ban
 
 class BansManagerTests(TestCase):
     def setUp(self):
-        Ban.objects.bulk_create([
-            Ban(check_type=Ban.USERNAME, banned_value='bob'),
-            Ban(check_type=Ban.EMAIL, banned_value='bob@test.com'),
-            Ban(check_type=Ban.IP, banned_value='127.0.0.1'),
-        ])
+        Ban.objects.bulk_create(
+            [
+                Ban(check_type=Ban.USERNAME, banned_value="bob"),
+                Ban(check_type=Ban.EMAIL, banned_value="bob@test.com"),
+                Ban(check_type=Ban.IP, banned_value="127.0.0.1"),
+            ]
+        )
 
     def test_get_ban_for_banned_name(self):
         """get_ban finds ban for given username"""
-        self.assertIsNotNone(Ban.objects.get_ban(username='Bob'))
+        self.assertIsNotNone(Ban.objects.get_ban(username="Bob"))
         with self.assertRaises(Ban.DoesNotExist):
-            Ban.objects.get_ban(username='Jeb')
+            Ban.objects.get_ban(username="Jeb")
 
     def test_get_ban_for_banned_email(self):
         """get_ban finds ban for given email"""
-        self.assertIsNotNone(Ban.objects.get_ban(email='bob@test.com'))
+        self.assertIsNotNone(Ban.objects.get_ban(email="bob@test.com"))
         with self.assertRaises(Ban.DoesNotExist):
-            Ban.objects.get_ban(email='jeb@test.com')
+            Ban.objects.get_ban(email="jeb@test.com")
 
     def test_get_ban_for_banned_ip(self):
         """get_ban finds ban for given ip"""
-        self.assertIsNotNone(Ban.objects.get_ban(ip='127.0.0.1'))
+        self.assertIsNotNone(Ban.objects.get_ban(ip="127.0.0.1"))
         with self.assertRaises(Ban.DoesNotExist):
-            Ban.objects.get_ban(ip='42.0.0.1')
+            Ban.objects.get_ban(ip="42.0.0.1")
 
     def test_get_ban_for_all_bans(self):
         """get_ban finds ban for given values"""
-        valid_kwargs = {'username': 'bob', 'ip': '42.51.52.51'}
+        valid_kwargs = {"username": "bob", "ip": "42.51.52.51"}
         self.assertIsNotNone(Ban.objects.get_ban(**valid_kwargs))
 
-        invalid_kwargs = {'username': 'bsob', 'ip': '42.51.52.51'}
+        invalid_kwargs = {"username": "bsob", "ip": "42.51.52.51"}
         with self.assertRaises(Ban.DoesNotExist):
             Ban.objects.get_ban(**invalid_kwargs)
 
@@ -42,28 +44,28 @@ class BansManagerTests(TestCase):
 class BanTests(TestCase):
     def test_check_value_literal(self):
         """ban correctly tests given values"""
-        test_ban = Ban(banned_value='bob')
+        test_ban = Ban(banned_value="bob")
 
-        self.assertTrue(test_ban.check_value('bob'))
-        self.assertFalse(test_ban.check_value('bobby'))
+        self.assertTrue(test_ban.check_value("bob"))
+        self.assertFalse(test_ban.check_value("bobby"))
 
     def test_check_value_starts_with(self):
         """ban correctly tests given values"""
-        test_ban = Ban(banned_value='bob*')
+        test_ban = Ban(banned_value="bob*")
 
-        self.assertTrue(test_ban.check_value('bob'))
-        self.assertTrue(test_ban.check_value('bobby'))
+        self.assertTrue(test_ban.check_value("bob"))
+        self.assertTrue(test_ban.check_value("bobby"))
 
     def test_check_value_middle_match(self):
         """ban correctly tests given values"""
-        test_ban = Ban(banned_value='b*b')
+        test_ban = Ban(banned_value="b*b")
 
-        self.assertTrue(test_ban.check_value('bob'))
-        self.assertFalse(test_ban.check_value('bobby'))
+        self.assertTrue(test_ban.check_value("bob"))
+        self.assertFalse(test_ban.check_value("bobby"))
 
     def test_check_value_ends_witch(self):
         """ban correctly tests given values"""
-        test_ban = Ban(banned_value='*bob')
+        test_ban = Ban(banned_value="*bob")
 
-        self.assertTrue(test_ban.check_value('lebob'))
-        self.assertFalse(test_ban.check_value('bobby'))
+        self.assertTrue(test_ban.check_value("lebob"))
+        self.assertFalse(test_ban.check_value("bobby"))

+ 42 - 61
misago/users/tests/test_banadmin_views.py

@@ -9,17 +9,17 @@ from misago.users.models import Ban
 class BanAdminViewsTests(AdminTestCase):
     def test_link_registered(self):
         """admin nav contains bans link"""
-        response = self.client.get(reverse('misago:admin:users:accounts:index'))
+        response = self.client.get(reverse("misago:admin:users:accounts:index"))
 
-        response = self.client.get(response['location'])
-        self.assertContains(response, reverse('misago:admin:users:bans:index'))
+        response = self.client.get(response["location"])
+        self.assertContains(response, reverse("misago:admin:users:bans:index"))
 
     def test_list_view(self):
         """bans list view returns 200"""
-        response = self.client.get(reverse('misago:admin:users:bans:index'))
+        response = self.client.get(reverse("misago:admin:users:bans:index"))
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertEqual(response.status_code, 200)
 
     def test_mass_delete(self):
@@ -28,13 +28,13 @@ class BanAdminViewsTests(AdminTestCase):
 
         for i in range(10):
             response = self.client.post(
-                reverse('misago:admin:users:bans:new'),
+                reverse("misago:admin:users:bans:new"),
                 data={
-                    'check_type': '1',
-                    'banned_value': '%stest@test.com' % i,
-                    'user_message': 'Lorem ipsum dolor met',
-                    'staff_message': 'Sit amet elit',
-                    'expires_on': test_date.isoformat(),
+                    "check_type": "1",
+                    "banned_value": "%stest@test.com" % i,
+                    "user_message": "Lorem ipsum dolor met",
+                    "staff_message": "Sit amet elit",
+                    "expires_on": test_date.isoformat(),
                 },
             )
             self.assertEqual(response.status_code, 302)
@@ -46,99 +46,80 @@ class BanAdminViewsTests(AdminTestCase):
             bans_pks.append(ban.pk)
 
         response = self.client.post(
-            reverse('misago:admin:users:bans:index'),
-            data={
-                'action': 'delete',
-                'selected_items': bans_pks,
-            },
+            reverse("misago:admin:users:bans:index"),
+            data={"action": "delete", "selected_items": bans_pks},
         )
         self.assertEqual(response.status_code, 302)
         self.assertEqual(Ban.objects.count(), 0)
 
     def test_new_view(self):
         """new ban view has no showstoppers"""
-        response = self.client.get(reverse('misago:admin:users:bans:new'))
+        response = self.client.get(reverse("misago:admin:users:bans:new"))
         self.assertEqual(response.status_code, 200)
 
         test_date = datetime.now() + timedelta(days=180)
 
         response = self.client.post(
-            reverse('misago:admin:users:bans:new'),
+            reverse("misago:admin:users:bans:new"),
             data={
-                'check_type': '1',
-                'banned_value': 'test@test.com',
-                'user_message': 'Lorem ipsum dolor met',
-                'staff_message': 'Sit amet elit',
-                'expires_on': test_date.isoformat(),
+                "check_type": "1",
+                "banned_value": "test@test.com",
+                "user_message": "Lorem ipsum dolor met",
+                "staff_message": "Sit amet elit",
+                "expires_on": test_date.isoformat(),
             },
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:users:bans:index'))
-        response = self.client.get(response['location'])
+        response = self.client.get(reverse("misago:admin:users:bans:index"))
+        response = self.client.get(response["location"])
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'test@test.com')
+        self.assertContains(response, "test@test.com")
 
     def test_edit_view(self):
         """edit ban view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:users:bans:new'),
-            data={
-                'check_type': '0',
-                'banned_value': 'Admin',
-            },
+            reverse("misago:admin:users:bans:new"),
+            data={"check_type": "0", "banned_value": "Admin"},
         )
 
-        test_ban = Ban.objects.get(banned_value='admin')
-        form_link = reverse(
-            'misago:admin:users:bans:edit',
-            kwargs={
-                'pk': test_ban.pk,
-            },
-        )
+        test_ban = Ban.objects.get(banned_value="admin")
+        form_link = reverse("misago:admin:users:bans:edit", kwargs={"pk": test_ban.pk})
 
         response = self.client.post(
             form_link,
             data={
-                'check_type': '1',
-                'banned_value': 'test@test.com',
-                'user_message': 'Lorem ipsum dolor met',
-                'staff_message': 'Sit amet elit',
-                'expires_on': '',
+                "check_type": "1",
+                "banned_value": "test@test.com",
+                "user_message": "Lorem ipsum dolor met",
+                "staff_message": "Sit amet elit",
+                "expires_on": "",
             },
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:users:bans:index'))
-        response = self.client.get(response['location'])
+        response = self.client.get(reverse("misago:admin:users:bans:index"))
+        response = self.client.get(response["location"])
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'test@test.com')
+        self.assertContains(response, "test@test.com")
 
     def test_delete_view(self):
         """delete ban view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:users:bans:new'),
-            data={
-                'check_type': '0',
-                'banned_value': 'TestBan',
-            },
+            reverse("misago:admin:users:bans:new"),
+            data={"check_type": "0", "banned_value": "TestBan"},
         )
 
-        test_ban = Ban.objects.get(banned_value='testban')
+        test_ban = Ban.objects.get(banned_value="testban")
 
         response = self.client.post(
-            reverse(
-                'misago:admin:users:bans:delete',
-                kwargs={
-                    'pk': test_ban.pk,
-                },
-            )
+            reverse("misago:admin:users:bans:delete", kwargs={"pk": test_ban.pk})
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:users:bans:index'))
-        self.client.get(response['location'])
-        response = self.client.get(response['location'])
+        response = self.client.get(reverse("misago:admin:users:bans:index"))
+        self.client.get(response["location"])
+        response = self.client.get(response["location"])
 
         self.assertEqual(response.status_code, 200)
         self.assertNotContains(response, test_ban.banned_value)

+ 70 - 80
misago/users/tests/test_bans.py

@@ -6,7 +6,14 @@ from django.utils import timezone
 
 from misago.conftest import get_cache_versions
 from misago.users.bans import (
-    ban_ip, ban_user, get_email_ban, get_ip_ban, get_request_ip_ban, get_user_ban, get_username_ban)
+    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
 
@@ -18,119 +25,108 @@ cache_versions = get_cache_versions()
 class GetBanTests(TestCase):
     def test_get_username_ban(self):
         """get_username_ban returns valid ban"""
-        nonexistent_ban = get_username_ban('nonexistent')
+        nonexistent_ban = get_username_ban("nonexistent")
         self.assertIsNone(nonexistent_ban)
 
         Ban.objects.create(
-            banned_value='expired',
-            expires_on=timezone.now() - timedelta(days=7),
+            banned_value="expired", expires_on=timezone.now() - timedelta(days=7)
         )
 
-        expired_ban = get_username_ban('expired')
+        expired_ban = get_username_ban("expired")
         self.assertIsNone(expired_ban)
 
-        Ban.objects.create(
-            banned_value='wrongtype',
-            check_type=Ban.EMAIL,
-        )
+        Ban.objects.create(banned_value="wrongtype", check_type=Ban.EMAIL)
 
-        wrong_type_ban = get_username_ban('wrongtype')
+        wrong_type_ban = get_username_ban("wrongtype")
         self.assertIsNone(wrong_type_ban)
 
         valid_ban = Ban.objects.create(
-            banned_value='admi*',
-            expires_on=timezone.now() + timedelta(days=7),
+            banned_value="admi*", expires_on=timezone.now() + timedelta(days=7)
         )
-        self.assertEqual(get_username_ban('admiral').pk, valid_ban.pk)
+        self.assertEqual(get_username_ban("admiral").pk, valid_ban.pk)
 
         registration_ban = Ban.objects.create(
-            banned_value='bob*',
+            banned_value="bob*",
             expires_on=timezone.now() + timedelta(days=7),
             registration_only=True,
         )
-        self.assertIsNone(get_username_ban('boberson'))
-        self.assertEqual(get_username_ban('boberson', True).pk, registration_ban.pk)
+        self.assertIsNone(get_username_ban("boberson"))
+        self.assertEqual(get_username_ban("boberson", True).pk, registration_ban.pk)
 
     def test_get_email_ban(self):
         """get_email_ban returns valid ban"""
-        nonexistent_ban = get_email_ban('non@existent.com')
+        nonexistent_ban = get_email_ban("non@existent.com")
         self.assertIsNone(nonexistent_ban)
 
         Ban.objects.create(
-            banned_value='ex@pired.com',
+            banned_value="ex@pired.com",
             check_type=Ban.EMAIL,
             expires_on=timezone.now() - timedelta(days=7),
         )
 
-        expired_ban = get_email_ban('ex@pired.com')
+        expired_ban = get_email_ban("ex@pired.com")
         self.assertIsNone(expired_ban)
 
-        Ban.objects.create(
-            banned_value='wrong@type.com',
-            check_type=Ban.IP,
-        )
+        Ban.objects.create(banned_value="wrong@type.com", check_type=Ban.IP)
 
-        wrong_type_ban = get_email_ban('wrong@type.com')
+        wrong_type_ban = get_email_ban("wrong@type.com")
         self.assertIsNone(wrong_type_ban)
 
         valid_ban = Ban.objects.create(
-            banned_value='*.ru',
+            banned_value="*.ru",
             check_type=Ban.EMAIL,
             expires_on=timezone.now() + timedelta(days=7),
         )
-        self.assertEqual(get_email_ban('banned@mail.ru').pk, valid_ban.pk)
+        self.assertEqual(get_email_ban("banned@mail.ru").pk, valid_ban.pk)
 
         registration_ban = Ban.objects.create(
-            banned_value='*.ua',
+            banned_value="*.ua",
             check_type=Ban.EMAIL,
             expires_on=timezone.now() + timedelta(days=7),
             registration_only=True,
         )
-        self.assertIsNone(get_email_ban('banned@mail.ua'))
-        self.assertEqual(get_email_ban('banned@mail.ua', True).pk, registration_ban.pk)
+        self.assertIsNone(get_email_ban("banned@mail.ua"))
+        self.assertEqual(get_email_ban("banned@mail.ua", True).pk, registration_ban.pk)
 
     def test_get_ip_ban(self):
         """get_ip_ban returns valid ban"""
-        nonexistent_ban = get_ip_ban('123.0.0.1')
+        nonexistent_ban = get_ip_ban("123.0.0.1")
         self.assertIsNone(nonexistent_ban)
 
         Ban.objects.create(
-            banned_value='124.0.0.1',
+            banned_value="124.0.0.1",
             check_type=Ban.IP,
             expires_on=timezone.now() - timedelta(days=7),
         )
 
-        expired_ban = get_ip_ban('124.0.0.1')
+        expired_ban = get_ip_ban("124.0.0.1")
         self.assertIsNone(expired_ban)
 
-        Ban.objects.create(
-            banned_value='wrongtype',
-            check_type=Ban.EMAIL,
-        )
+        Ban.objects.create(banned_value="wrongtype", check_type=Ban.EMAIL)
 
-        wrong_type_ban = get_ip_ban('wrongtype')
+        wrong_type_ban = get_ip_ban("wrongtype")
         self.assertIsNone(wrong_type_ban)
 
         valid_ban = Ban.objects.create(
-            banned_value='125.0.0.*',
+            banned_value="125.0.0.*",
             check_type=Ban.IP,
             expires_on=timezone.now() + timedelta(days=7),
         )
-        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)
 
         registration_ban = Ban.objects.create(
-            banned_value='188.*',
+            banned_value="188.*",
             check_type=Ban.IP,
             expires_on=timezone.now() + timedelta(days=7),
             registration_only=True,
         )
-        self.assertIsNone(get_ip_ban('188.12.12.41'))
-        self.assertEqual(get_ip_ban('188.12.12.41', True).pk, registration_ban.pk)
+        self.assertIsNone(get_ip_ban("188.12.12.41"))
+        self.assertEqual(get_ip_ban("188.12.12.41", True).pk, registration_ban.pk)
 
 
 class UserBansTests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user('Bob', 'bob@boberson.com', 'pass123')
+        self.user = UserModel.objects.create_user("Bob", "bob@boberson.com", "pass123")
 
     def test_no_ban(self):
         """user is not caught by ban"""
@@ -140,37 +136,34 @@ class UserBansTests(TestCase):
     def test_permanent_ban(self):
         """user is caught by permanent ban"""
         Ban.objects.create(
-            banned_value='bob',
-            user_message='User reason',
-            staff_message='Staff reason',
+            banned_value="bob", user_message="User reason", staff_message="Staff reason"
         )
 
         user_ban = get_user_ban(self.user, cache_versions)
         self.assertIsNotNone(user_ban)
-        self.assertEqual(user_ban.user_message, 'User reason')
-        self.assertEqual(user_ban.staff_message, 'Staff reason')
+        self.assertEqual(user_ban.user_message, "User reason")
+        self.assertEqual(user_ban.staff_message, "Staff reason")
         self.assertTrue(self.user.ban_cache.is_banned)
 
     def test_temporary_ban(self):
         """user is caught by temporary ban"""
         Ban.objects.create(
-            banned_value='bo*',
-            user_message='User reason',
-            staff_message='Staff reason',
+            banned_value="bo*",
+            user_message="User reason",
+            staff_message="Staff reason",
             expires_on=timezone.now() + timedelta(days=7),
         )
 
         user_ban = get_user_ban(self.user, cache_versions)
         self.assertIsNotNone(user_ban)
-        self.assertEqual(user_ban.user_message, 'User reason')
-        self.assertEqual(user_ban.staff_message, 'Staff reason')
+        self.assertEqual(user_ban.user_message, "User reason")
+        self.assertEqual(user_ban.staff_message, "Staff reason")
         self.assertTrue(self.user.ban_cache.is_banned)
 
     def test_expired_ban(self):
         """user is not caught by expired ban"""
         Ban.objects.create(
-            banned_value='bo*',
-            expires_on=timezone.now() - timedelta(days=7),
+            banned_value="bo*", expires_on=timezone.now() - timedelta(days=7)
         )
 
         self.assertIsNone(get_user_ban(self.user, cache_versions))
@@ -179,8 +172,7 @@ class UserBansTests(TestCase):
     def test_expired_non_flagged_ban(self):
         """user is not caught by expired but checked ban"""
         Ban.objects.create(
-            banned_value='bo*',
-            expires_on=timezone.now() - timedelta(days=7),
+            banned_value="bo*", expires_on=timezone.now() - timedelta(days=7)
         )
         Ban.objects.update(is_checked=True)
 
@@ -190,7 +182,7 @@ class UserBansTests(TestCase):
 
 class MockRequest(object):
     def __init__(self):
-        self.user_ip = '127.0.0.1'
+        self.user_ip = "127.0.0.1"
         self.session = {}
         self.cache_versions = cache_versions
 
@@ -204,15 +196,13 @@ class RequestIPBansTests(TestCase):
     def test_permanent_ban(self):
         """ip is caught by permanent ban"""
         Ban.objects.create(
-            check_type=Ban.IP,
-            banned_value='127.0.0.1',
-            user_message='User reason',
+            check_type=Ban.IP, banned_value="127.0.0.1", user_message="User reason"
         )
 
         ip_ban = get_request_ip_ban(MockRequest())
-        self.assertTrue(ip_ban['is_banned'])
-        self.assertEqual(ip_ban['ip'], '127.0.0.1')
-        self.assertEqual(ip_ban['message'], 'User reason')
+        self.assertTrue(ip_ban["is_banned"])
+        self.assertEqual(ip_ban["ip"], "127.0.0.1")
+        self.assertEqual(ip_ban["message"], "User reason")
 
         # repeated call uses cache
         get_request_ip_ban(MockRequest())
@@ -221,15 +211,15 @@ class RequestIPBansTests(TestCase):
         """ip is caught by temporary ban"""
         Ban.objects.create(
             check_type=Ban.IP,
-            banned_value='127.0.0.1',
-            user_message='User reason',
+            banned_value="127.0.0.1",
+            user_message="User reason",
             expires_on=timezone.now() + timedelta(days=7),
         )
 
         ip_ban = get_request_ip_ban(MockRequest())
-        self.assertTrue(ip_ban['is_banned'])
-        self.assertEqual(ip_ban['ip'], '127.0.0.1')
-        self.assertEqual(ip_ban['message'], 'User reason')
+        self.assertTrue(ip_ban["is_banned"])
+        self.assertEqual(ip_ban["ip"], "127.0.0.1")
+        self.assertEqual(ip_ban["message"], "User reason")
 
         # repeated call uses cache
         get_request_ip_ban(MockRequest())
@@ -238,8 +228,8 @@ class RequestIPBansTests(TestCase):
         """ip is not caught by expired ban"""
         Ban.objects.create(
             check_type=Ban.IP,
-            banned_value='127.0.0.1',
-            user_message='User reason',
+            banned_value="127.0.0.1",
+            user_message="User reason",
             expires_on=timezone.now() - timedelta(days=7),
         )
 
@@ -253,11 +243,11 @@ class RequestIPBansTests(TestCase):
 class BanUserTests(TestCase):
     def test_ban_user(self):
         """ban_user utility bans user"""
-        user = UserModel.objects.create_user('Bob', 'bob@boberson.com', 'pass123')
+        user = UserModel.objects.create_user("Bob", "bob@boberson.com", "pass123")
 
-        ban = ban_user(user, 'User reason', 'Staff reason')
-        self.assertEqual(ban.user_message, 'User reason')
-        self.assertEqual(ban.staff_message, 'Staff reason')
+        ban = ban_user(user, "User reason", "Staff reason")
+        self.assertEqual(ban.user_message, "User reason")
+        self.assertEqual(ban.staff_message, "Staff reason")
 
         db_ban = get_user_ban(user, cache_versions)
         self.assertEqual(ban.pk, db_ban.ban_id)
@@ -266,9 +256,9 @@ class BanUserTests(TestCase):
 class BanIpTests(TestCase):
     def test_ban_ip(self):
         """ban_ip utility bans IP address"""
-        ban = ban_ip('127.0.0.1', 'User reason', 'Staff reason')
-        self.assertEqual(ban.user_message, 'User reason')
-        self.assertEqual(ban.staff_message, 'Staff reason')
+        ban = ban_ip("127.0.0.1", "User reason", "Staff reason")
+        self.assertEqual(ban.user_message, "User reason")
+        self.assertEqual(ban.staff_message, "Staff reason")
 
-        db_ban = get_ip_ban('127.0.0.1')
-        self.assertEqual(ban.pk, db_ban.pk)
+        db_ban = get_ip_ban("127.0.0.1")
+        self.assertEqual(ban.pk, db_ban.pk)

+ 88 - 96
misago/users/tests/test_bio_profilefield.py

@@ -12,10 +12,7 @@ class BioProfileFieldTests(AdminTestCase):
         super().setUp()
 
         self.test_link = reverse(
-            'misago:admin:users:accounts:edit',
-            kwargs={
-                'pk': self.user.pk,
-            },
+            "misago:admin:users:accounts:edit", kwargs={"pk": self.user.pk}
         )
 
     def test_field_displays_in_admin(self):
@@ -25,188 +22,183 @@ class BioProfileFieldTests(AdminTestCase):
 
     def test_admin_clears_field(self):
         """admin form allows admins to clear field"""
-        self.user.profile_fields['bio'] = 'Exists!'
+        self.user.profile_fields["bio"] = "Exists!"
         self.user.save()
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['bio'], 'Exists!')
+        self.assertEqual(self.user.profile_fields["bio"], "Exists!")
 
         response = self.client.post(
             self.test_link,
             data={
-                'username': 'Edited',
-                'rank': str(self.user.rank_id),
-                'roles': str(self.user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'new_password': '',
-                'signature': '',
-                'is_signature_locked': '0',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': '',
-                'signature_lock_user_message': '',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Edited",
+                "rank": str(self.user.rank_id),
+                "roles": str(self.user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "new_password": "",
+                "signature": "",
+                "is_signature_locked": "0",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "",
+                "signature_lock_user_message": "",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['bio'], '')
+        self.assertEqual(self.user.profile_fields["bio"], "")
 
     def test_admin_edits_field(self):
         """admin form allows admins to edit field"""
         response = self.client.post(
             self.test_link,
             data={
-                'username': 'Edited',
-                'rank': str(self.user.rank_id),
-                'roles': str(self.user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'bio': 'Edited field!',
-                'new_password': '',
-                'signature': '',
-                'is_signature_locked': '0',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': '',
-                'signature_lock_user_message': '',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Edited",
+                "rank": str(self.user.rank_id),
+                "roles": str(self.user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "bio": "Edited field!",
+                "new_password": "",
+                "signature": "",
+                "is_signature_locked": "0",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "",
+                "signature_lock_user_message": "",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['bio'], 'Edited field!')
+        self.assertEqual(self.user.profile_fields["bio"], "Edited field!")
 
     def test_admin_search_field(self):
         """admin users search searches this field"""
-        test_link = reverse('misago:admin:users:accounts:index')
+        test_link = reverse("misago:admin:users:accounts:index")
 
-        response = self.client.get('%s?redirected=1&profilefields=Ipsum' % test_link)
-        self.assertContains(response, "No users matching search criteria have been found.")
+        response = self.client.get("%s?redirected=1&profilefields=Ipsum" % test_link)
+        self.assertContains(
+            response, "No users matching search criteria have been found."
+        )
 
-        self.user.profile_fields['bio'] = 'Lorem Ipsum Dolor Met'
+        self.user.profile_fields["bio"] = "Lorem Ipsum Dolor Met"
         self.user.save()
 
-        response = self.client.get('%s?redirected=1&profilefields=Ipsum' % test_link)
-        self.assertNotContains(response, "No users matching search criteria have been found.")
+        response = self.client.get("%s?redirected=1&profilefields=Ipsum" % test_link)
+        self.assertNotContains(
+            response, "No users matching search criteria have been found."
+        )
 
     def test_field_display(self):
         """field displays on user profile when filled in"""
         test_link = reverse(
-            'misago:user-details',
-            kwargs={
-                'pk': self.user.pk,
-                'slug': self.user.slug,
-            },
+            "misago:user-details", kwargs={"pk": self.user.pk, "slug": self.user.slug}
         )
 
         response = self.client.get(test_link)
-        self.assertNotContains(response, 'Bio')
+        self.assertNotContains(response, "Bio")
 
-        self.user.profile_fields['bio'] = 'I am Bob!\n\nThis is <b>my</b> bio!'
+        self.user.profile_fields["bio"] = "I am Bob!\n\nThis is <b>my</b> bio!"
         self.user.save()
 
         response = self.client.get(test_link)
-        self.assertContains(response, 'Bio')
-        self.assertContains(response, '<p>I am Bob!</p>')
-        self.assertContains(response, '<p>This is &lt;b&gt;my&lt;/b&gt; bio!</p>')
+        self.assertContains(response, "Bio")
+        self.assertContains(response, "<p>I am Bob!</p>")
+        self.assertContains(response, "<p>This is &lt;b&gt;my&lt;/b&gt; bio!</p>")
 
     def test_field_display_json(self):
         """field is included in display json"""
-        test_link = reverse('misago:api:user-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-details", kwargs={"pk": self.user.pk})
 
         response = self.client.get(test_link)
         self.assertEqual(
-            response.json()['groups'],
+            response.json()["groups"],
             [
                 {
-                    'name': 'IP address',
-                    'fields': [
-                        {
-                            'fieldname': 'join_ip',
-                            'name': 'Join IP',
-                            'text': '127.0.0.1',
-                        },
+                    "name": "IP address",
+                    "fields": [
+                        {"fieldname": "join_ip", "name": "Join IP", "text": "127.0.0.1"}
                     ],
-                },
-            ]
+                }
+            ],
         )
 
-        self.user.profile_fields['bio'] = 'I am Bob!\n\nThis is <b>my</b> bio!'
+        self.user.profile_fields["bio"] = "I am Bob!\n\nThis is <b>my</b> bio!"
         self.user.save()
 
         response = self.client.get(test_link)
         self.assertEqual(
-            response.json()['groups'],
+            response.json()["groups"],
             [
                 {
-                    'name': 'Personal',
-                    'fields': [
+                    "name": "Personal",
+                    "fields": [
                         {
-                            'fieldname': 'bio',
-                            'name': 'Bio',
-                            'html': '<p>I am Bob!</p>\n\n<p>This is &lt;b&gt;my&lt;/b&gt; bio!</p>',
+                            "fieldname": "bio",
+                            "name": "Bio",
+                            "html": "<p>I am Bob!</p>\n\n<p>This is &lt;b&gt;my&lt;/b&gt; bio!</p>",
                         }
                     ],
                 },
                 {
-                    'name': 'IP address',
-                    'fields': [
-                        {
-                            'fieldname': 'join_ip',
-                            'name': 'Join IP',
-                            'text': '127.0.0.1',
-                        },
+                    "name": "IP address",
+                    "fields": [
+                        {"fieldname": "join_ip", "name": "Join IP", "text": "127.0.0.1"}
                     ],
                 },
-            ]
+            ],
         )
 
     def test_api_returns_field_json(self):
         """field json is returned from API"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
         response = self.client.get(test_link)
 
         found_field = None
         for group in response.json():
-            for field in group['fields']:
-                if field['fieldname'] == 'bio':
+            for field in group["fields"]:
+                if field["fieldname"] == "bio":
                     found_field = field
 
-        self.assertEqual(found_field, {
-            'fieldname': 'bio',
-            'label': 'Bio',
-            'help_text': None,
-            'input': {'type': 'textarea'},
-            'initial': '',
-        })
+        self.assertEqual(
+            found_field,
+            {
+                "fieldname": "bio",
+                "label": "Bio",
+                "help_text": None,
+                "input": {"type": "textarea"},
+                "initial": "",
+            },
+        )
 
     def test_api_clears_field(self):
         """field can be cleared via api"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
-        self.user.profile_fields['bio'] = 'Exists!'
+        self.user.profile_fields["bio"] = "Exists!"
         self.user.save()
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['bio'], 'Exists!')
+        self.assertEqual(self.user.profile_fields["bio"], "Exists!")
 
         response = self.client.post(test_link, data={})
         self.assertEqual(response.status_code, 200)
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['bio'], '')
+        self.assertEqual(self.user.profile_fields["bio"], "")
 
     def test_api_edits_field(self):
         """field can be edited via api"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
-        response = self.client.post(test_link, data={'bio': 'Lorem Ipsum!'})
+        response = self.client.post(test_link, data={"bio": "Lorem Ipsum!"})
         self.assertEqual(response.status_code, 200)
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['bio'], 'Lorem Ipsum!')
+        self.assertEqual(self.user.profile_fields["bio"], "Lorem Ipsum!")

+ 3 - 3
misago/users/tests/test_captcha_api.py

@@ -9,7 +9,7 @@ test_qa_help_text = 'Type in "yes".'
 
 class AuthenticateApiTests(TestCase):
     def setUp(self):
-        self.api_link = reverse('misago:api:captcha-question')
+        self.api_link = reverse("misago:api:captcha-question")
 
     @override_dynamic_settings(qa_question="")
     def test_api_no_qa_is_set(self):
@@ -26,5 +26,5 @@ class AuthenticateApiTests(TestCase):
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['question'], test_qa_question)
-        self.assertEqual(response_json['help_text'], test_qa_help_text)
+        self.assertEqual(response_json["question"], test_qa_question)
+        self.assertEqual(response_json["help_text"], test_qa_help_text)

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

@@ -19,11 +19,9 @@ def test_superuser_is_created_if_input_is_valid(db):
     )
 
     command_output = out.getvalue().splitlines()[-1].strip()
-    user = User.objects.order_by('-id')[:1][0]
+    user = User.objects.order_by("-id")[:1][0]
 
-    assert command_output == (
-        "Superuser #%s has been created successfully." % user.pk
-    )
+    assert command_output == ("Superuser #%s has been created successfully." % user.pk)
 
     assert user.username == "test"
     assert user.email == "test@example.com"

+ 20 - 12
misago/users/tests/test_credentialchange.py

@@ -15,42 +15,50 @@ class MockRequest(object):
 
 class CredentialChangeTests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        self.user = UserModel.objects.create_user("Bob", "bob@bob.com", "pass123")
 
     def test_valid_token_generation(self):
         """credentialchange module allows for store and read of change token"""
         request = MockRequest(self.user)
-        token = credentialchange.store_new_credential(request, 'email', 'newbob@test.com')
+        token = credentialchange.store_new_credential(
+            request, "email", "newbob@test.com"
+        )
 
-        email = credentialchange.read_new_credential(request, 'email', token)
-        self.assertEqual(email, 'newbob@test.com')
+        email = credentialchange.read_new_credential(request, "email", token)
+        self.assertEqual(email, "newbob@test.com")
 
     def test_email_change_invalidated_token(self):
         """token is invalidated by email change"""
         request = MockRequest(self.user)
-        token = credentialchange.store_new_credential(request, 'email', 'newbob@test.com')
+        token = credentialchange.store_new_credential(
+            request, "email", "newbob@test.com"
+        )
 
-        self.user.set_email('egebege@test.com')
+        self.user.set_email("egebege@test.com")
         self.user.save()
 
-        email = credentialchange.read_new_credential(request, 'email', token)
+        email = credentialchange.read_new_credential(request, "email", token)
         self.assertIsNone(email)
 
     def test_password_change_invalidated_token(self):
         """token is invalidated by password change"""
         request = MockRequest(self.user)
-        token = credentialchange.store_new_credential(request, 'email', 'newbob@test.com')
+        token = credentialchange.store_new_credential(
+            request, "email", "newbob@test.com"
+        )
 
-        self.user.set_password('Egebeg!123')
+        self.user.set_password("Egebeg!123")
         self.user.save()
 
-        email = credentialchange.read_new_credential(request, 'email', token)
+        email = credentialchange.read_new_credential(request, "email", token)
         self.assertIsNone(email)
 
     def test_invalid_token_is_handled(self):
         """there are no explosions in invalid tokens handling"""
         request = MockRequest(self.user)
-        token = credentialchange.store_new_credential(request, 'email', 'newbob@test.com')
+        token = credentialchange.store_new_credential(
+            request, "email", "newbob@test.com"
+        )
 
-        email = credentialchange.read_new_credential(request, 'em4il', token)
+        email = credentialchange.read_new_credential(request, "em4il", token)
         self.assertIsNone(email)

+ 37 - 41
misago/users/tests/test_datadownloads.py

@@ -7,15 +7,17 @@ from misago.threads.models import Attachment, AttachmentType
 from misago.threads.testutils import post_thread, post_poll
 from misago.users.audittrail import create_user_audit_trail
 from misago.users.datadownloads import (
-    expire_user_data_download, prepare_user_data_download, request_user_data_download,
-    user_has_data_download_request
+    expire_user_data_download,
+    prepare_user_data_download,
+    request_user_data_download,
+    user_has_data_download_request,
 )
 from misago.users.models import DataDownload
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
-TEST_FILE_PATH = os.path.join(TESTFILES_DIR, 'avatar.png')
+TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testfiles")
+TEST_FILE_PATH = os.path.join(TESTFILES_DIR, "avatar.png")
 
 
 class ExpireUserDataDownloadTests(AuthenticatedUserTestCase):
@@ -24,7 +26,7 @@ class ExpireUserDataDownloadTests(AuthenticatedUserTestCase):
         data_download = request_user_data_download(self.user)
         data_download.status = DataDownload.STATUS_READY
 
-        with open(TEST_FILE_PATH, 'rb') as download_file:
+        with open(TEST_FILE_PATH, "rb") as download_file:
             data_download.file = File(download_file)
             data_download.save()
 
@@ -37,7 +39,7 @@ class ExpireUserDataDownloadTests(AuthenticatedUserTestCase):
         data_download = request_user_data_download(self.user)
         data_download.status = DataDownload.STATUS_READY
 
-        with open(TEST_FILE_PATH, 'rb') as download_file:
+        with open(TEST_FILE_PATH, "rb") as download_file:
             data_download.file = File(download_file)
             data_download.save()
 
@@ -76,14 +78,14 @@ class PrepareUserDataDownload(AuthenticatedUserTestCase):
 
     def test_prepare_download_with_profle_fields(self):
         """function creates data download for user with profile fields"""
-        self.user.profile_fields = {'real_name': "Bob Boberthon!"}
+        self.user.profile_fields = {"real_name": "Bob Boberthon!"}
         self.user.save()
 
         self.assert_download_is_valid()
 
     def test_prepare_download_with_tmp_avatar(self):
         """function creates data download for user with tmp avatar"""
-        with open(TEST_FILE_PATH, 'rb') as test_file:
+        with open(TEST_FILE_PATH, "rb") as test_file:
             self.user.avatar_tmp = File(test_file)
             self.user.save()
 
@@ -91,7 +93,7 @@ class PrepareUserDataDownload(AuthenticatedUserTestCase):
 
     def test_prepare_download_with_src_avatar(self):
         """function creates data download for user with src avatar"""
-        with open(TEST_FILE_PATH, 'rb') as test_file:
+        with open(TEST_FILE_PATH, "rb") as test_file:
             self.user.avatar_src = File(test_file)
             self.user.save()
 
@@ -99,7 +101,7 @@ class PrepareUserDataDownload(AuthenticatedUserTestCase):
 
     def test_prepare_download_with_avatar_set(self):
         """function creates data download for user with avatar set"""
-        with open(TEST_FILE_PATH, 'rb') as test_file:
+        with open(TEST_FILE_PATH, "rb") as test_file:
             self.user.avatar_set.create(size=100, image=File(test_file))
 
         self.assert_download_is_valid()
@@ -107,18 +109,16 @@ class PrepareUserDataDownload(AuthenticatedUserTestCase):
     def test_prepare_download_with_file_attachment(self):
         """function creates data download for user with file attachment"""
         filetype = AttachmentType.objects.create(
-            name="Test extension",
-            extensions='png',
-            mimetypes='image/png',
+            name="Test extension", extensions="png", mimetypes="image/png"
         )
 
-        with open(TEST_FILE_PATH, 'rb') as test_file:
+        with open(TEST_FILE_PATH, "rb") as test_file:
             self.user.attachment_set.create(
-                secret='test',
+                secret="test",
                 filetype=filetype,
                 uploader_name=self.user.username,
                 uploader_slug=self.user.slug,
-                filename='test.png',
+                filename="test.png",
                 size=1000,
                 file=File(test_file),
             )
@@ -128,18 +128,16 @@ class PrepareUserDataDownload(AuthenticatedUserTestCase):
     def test_prepare_download_with_image_attachment(self):
         """function creates data download for user with image attachment"""
         filetype = AttachmentType.objects.create(
-            name="Test extension",
-            extensions='png',
-            mimetypes='image/png',
+            name="Test extension", extensions="png", mimetypes="image/png"
         )
 
-        with open(TEST_FILE_PATH, 'rb') as test_file:
+        with open(TEST_FILE_PATH, "rb") as test_file:
             self.user.attachment_set.create(
-                secret='test',
+                secret="test",
                 filetype=filetype,
                 uploader_name=self.user.username,
                 uploader_slug=self.user.slug,
-                filename='test.png',
+                filename="test.png",
                 size=1000,
                 image=File(test_file),
             )
@@ -149,18 +147,16 @@ class PrepareUserDataDownload(AuthenticatedUserTestCase):
     def test_prepare_download_with_thumbnail_attachment(self):
         """function creates data download for user with thumbnail attachment"""
         filetype = AttachmentType.objects.create(
-            name="Test extension",
-            extensions='png',
-            mimetypes='image/png',
+            name="Test extension", extensions="png", mimetypes="image/png"
         )
 
-        with open(TEST_FILE_PATH, 'rb') as test_file:
+        with open(TEST_FILE_PATH, "rb") as test_file:
             self.user.attachment_set.create(
-                secret='test',
+                secret="test",
                 filetype=filetype,
                 uploader_name=self.user.username,
                 uploader_slug=self.user.slug,
-                filename='test.png',
+                filename="test.png",
                 size=1000,
                 thumbnail=File(test_file),
             )
@@ -169,40 +165,40 @@ class PrepareUserDataDownload(AuthenticatedUserTestCase):
 
     def test_prepare_download_with_self_username_change(self):
         """function creates data download for user that changed their username"""
-        self.user.record_name_change(self.user, 'aerith', 'alice')
+        self.user.record_name_change(self.user, "aerith", "alice")
 
         self.assert_download_is_valid()
 
     def test_prepare_download_with_username_changed_by_staff(self):
         """function creates data download for user with username changed by staff"""
         staff_user = self.get_superuser()
-        self.user.record_name_change(staff_user, 'aerith', 'alice')
+        self.user.record_name_change(staff_user, "aerith", "alice")
 
         self.assert_download_is_valid()
 
     def test_prepare_download_with_username_changed_by_deleted_user(self):
         """function creates data download for user with username changed by deleted user"""
-        self.user.record_name_change(self.user, 'aerith', 'alice')
+        self.user.record_name_change(self.user, "aerith", "alice")
         self.user.namechanges.update(changed_by=None)
 
         self.assert_download_is_valid()
 
     def test_prepare_download_with_audit_trail(self):
         """function creates data download for user with audit trail"""
-        create_user_audit_trail(self.user, '127.0.0.1', self.user)
+        create_user_audit_trail(self.user, "127.0.0.1", self.user)
 
         self.assert_download_is_valid()
 
     def test_prepare_download_with_post(self):
         """function creates data download for user with post"""
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         post_thread(category, poster=self.user)
 
         self.assert_download_is_valid()
 
     def test_prepare_download_with_owm_post_edit(self):
         """function creates data download for user with own post edit"""
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         thread = post_thread(category, poster=self.user)
         post = thread.first_post
 
@@ -220,7 +216,7 @@ class PrepareUserDataDownload(AuthenticatedUserTestCase):
 
     def test_prepare_download_with_other_users_post_edit(self):
         """function creates data download for user with other user's post edit"""
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         thread = post_thread(category)
         post = thread.first_post
 
@@ -238,7 +234,7 @@ class PrepareUserDataDownload(AuthenticatedUserTestCase):
 
     def test_prepare_download_with_own_post_edit_by_staff(self):
         """function creates data download for user with post edited by staff"""
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         thread = post_thread(category, poster=self.user)
         post = thread.first_post
 
@@ -258,7 +254,7 @@ class PrepareUserDataDownload(AuthenticatedUserTestCase):
 
     def test_prepare_download_with_poll(self):
         """function creates data download for user with poll"""
-        category = Category.objects.get(slug='first-category')
+        category = Category.objects.get(slug="first-category")
         thread = post_thread(category, poster=self.user)
         post_poll(thread, self.user)
 
@@ -304,7 +300,7 @@ class UserHasRequestedDataDownloadTests(AuthenticatedUserTestCase):
         data_download = request_user_data_download(self.user)
         data_download.status = DataDownload.STATUS_EXPIRED
         data_download.save()
-        
+
         self.assertFalse(user_has_data_download_request(self.user))
 
     def test_util_returns_true_for_pending_download(self):
@@ -312,7 +308,7 @@ class UserHasRequestedDataDownloadTests(AuthenticatedUserTestCase):
         data_download = request_user_data_download(self.user)
         data_download.status = DataDownload.STATUS_PENDING
         data_download.save()
-        
+
         self.assertTrue(user_has_data_download_request(self.user))
 
     def test_util_returns_true_for_processing_download(self):
@@ -320,5 +316,5 @@ class UserHasRequestedDataDownloadTests(AuthenticatedUserTestCase):
         data_download = request_user_data_download(self.user)
         data_download.status = DataDownload.STATUS_PROCESSING
         data_download.save()
-        
-        self.assertTrue(user_has_data_download_request(self.user))
+
+        self.assertTrue(user_has_data_download_request(self.user))

+ 46 - 42
misago/users/tests/test_datadownloads_dataarchive.py

@@ -6,13 +6,17 @@ from django.test import TestCase
 from django.utils import timezone
 
 from misago.conf import settings
-from misago.users.datadownloads.dataarchive import FILENAME_MAX_LEN, DataArchive, trim_long_filename
+from misago.users.datadownloads.dataarchive import (
+    FILENAME_MAX_LEN,
+    DataArchive,
+    trim_long_filename,
+)
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 DATA_DOWNLOADS_WORKING_DIR = settings.MISAGO_USER_DATA_DOWNLOADS_WORKING_DIR
-TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
-TEST_AVATAR_PATH = os.path.join(TESTFILES_DIR, 'avatar.png')
+TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testfiles")
+TEST_AVATAR_PATH = os.path.join(TESTFILES_DIR, "avatar.png")
 
 
 class DataArchiveTests(AuthenticatedUserTestCase):
@@ -38,7 +42,7 @@ class DataArchiveTests(AuthenticatedUserTestCase):
             working_dir = str(DATA_DOWNLOADS_WORKING_DIR)
             tmp_dir_path = str(archive.tmp_dir_path)
             data_dir_path = str(archive.data_dir_path)
-            
+
             self.assertTrue(tmp_dir_path.startswith(working_dir))
             self.assertTrue(data_dir_path.startswith(working_dir))
 
@@ -55,13 +59,13 @@ class DataArchiveTests(AuthenticatedUserTestCase):
         """add_dict method creates text file with string"""
         with DataArchive(self.user, DATA_DOWNLOADS_WORKING_DIR) as archive:
             data_to_write = "Hello, łorld!"
-            file_path = archive.add_text('testfile', data_to_write)
+            file_path = archive.add_text("testfile", data_to_write)
             self.assertTrue(os.path.isfile(file_path))
 
-            valid_output_path = os.path.join(archive.data_dir_path, 'testfile.txt')
+            valid_output_path = os.path.join(archive.data_dir_path, "testfile.txt")
             self.assertEqual(file_path, valid_output_path)
 
-            with open(file_path, 'r') as fp:
+            with open(file_path, "r") as fp:
                 saved_data = fp.read().strip()
                 self.assertEqual(saved_data, data_to_write)
 
@@ -69,27 +73,27 @@ class DataArchiveTests(AuthenticatedUserTestCase):
         """add_dict method creates text file with int"""
         with DataArchive(self.user, DATA_DOWNLOADS_WORKING_DIR) as archive:
             data_to_write = 1234
-            file_path = archive.add_text('testfile', data_to_write)
+            file_path = archive.add_text("testfile", data_to_write)
             self.assertTrue(os.path.isfile(file_path))
 
-            valid_output_path = os.path.join(archive.data_dir_path, 'testfile.txt')
+            valid_output_path = os.path.join(archive.data_dir_path, "testfile.txt")
             self.assertEqual(file_path, valid_output_path)
 
-            with open(file_path, 'r') as fp:
+            with open(file_path, "r") as fp:
                 saved_data = fp.read().strip()
                 self.assertEqual(saved_data, str(data_to_write))
 
     def test_add_dict(self):
         """add_dict method creates text file from dict"""
         with DataArchive(self.user, DATA_DOWNLOADS_WORKING_DIR) as archive:
-            data_to_write = {'first': "łorld!", 'second': "łup!"}
-            file_path = archive.add_dict('testfile', data_to_write)
+            data_to_write = {"first": "łorld!", "second": "łup!"}
+            file_path = archive.add_dict("testfile", data_to_write)
             self.assertTrue(os.path.isfile(file_path))
 
-            valid_output_path = os.path.join(archive.data_dir_path, 'testfile.txt')
+            valid_output_path = os.path.join(archive.data_dir_path, "testfile.txt")
             self.assertEqual(file_path, valid_output_path)
 
-            with open(file_path, 'r') as fp:
+            with open(file_path, "r") as fp:
                 saved_data = fp.read().strip()
                 # order of dict items in py<3.6 is non-deterministic
                 # making testing for exact match a mistake
@@ -99,20 +103,20 @@ class DataArchiveTests(AuthenticatedUserTestCase):
     def test_add_dict_ordered(self):
         """add_dict method creates text file form ordered dict"""
         with DataArchive(self.user, DATA_DOWNLOADS_WORKING_DIR) as archive:
-            data_to_write = OrderedDict((('first', "łorld!"), ('second', "łup!")))
-            file_path = archive.add_dict('testfile', data_to_write)
+            data_to_write = OrderedDict((("first", "łorld!"), ("second", "łup!")))
+            file_path = archive.add_dict("testfile", data_to_write)
             self.assertTrue(os.path.isfile(file_path))
 
-            valid_output_path = os.path.join(archive.data_dir_path, 'testfile.txt')
+            valid_output_path = os.path.join(archive.data_dir_path, "testfile.txt")
             self.assertEqual(file_path, valid_output_path)
 
-            with open(file_path, 'r') as fp:
+            with open(file_path, "r") as fp:
                 saved_data = fp.read().strip()
                 self.assertEqual(saved_data, "first: łorld!\nsecond: łup!")
 
     def test_add_model_file(self):
         """add_model_file method adds model file"""
-        with open(TEST_AVATAR_PATH, 'rb') as avatar:
+        with open(TEST_AVATAR_PATH, "rb") as avatar:
             self.user.avatar_tmp = File(avatar)
             self.user.save()
 
@@ -120,7 +124,7 @@ class DataArchiveTests(AuthenticatedUserTestCase):
             file_path = archive.add_model_file(self.user.avatar_tmp)
 
             self.assertTrue(os.path.isfile(file_path))
-    
+
             data_dir_path = str(archive.data_dir_path)
             self.assertTrue(str(file_path).startswith(data_dir_path))
 
@@ -134,7 +138,7 @@ class DataArchiveTests(AuthenticatedUserTestCase):
 
     def test_add_model_file_prefixed(self):
         """add_model_file method adds model file with prefix"""
-        with open(TEST_AVATAR_PATH, 'rb') as avatar:
+        with open(TEST_AVATAR_PATH, "rb") as avatar:
             self.user.avatar_tmp = File(avatar)
             self.user.save()
 
@@ -142,12 +146,12 @@ class DataArchiveTests(AuthenticatedUserTestCase):
             file_path = archive.add_model_file(self.user.avatar_tmp, prefix="prefix")
 
             self.assertTrue(os.path.isfile(file_path))
-    
+
             data_dir_path = str(archive.data_dir_path)
             self.assertTrue(str(file_path).startswith(data_dir_path))
-            
+
             filename = os.path.basename(self.user.avatar_tmp.name)
-            target_filename = 'prefix-%s' % filename
+            target_filename = "prefix-%s" % filename
             self.assertTrue(str(file_path).endswith(target_filename))
 
     def test_make_final_path_no_kwargs(self):
@@ -159,8 +163,8 @@ class DataArchiveTests(AuthenticatedUserTestCase):
     def test_make_final_path_directory(self):
         """make_final_path returns path including directory name"""
         with DataArchive(self.user, DATA_DOWNLOADS_WORKING_DIR) as archive:
-            final_path = archive.make_final_path(directory='test-directory')
-            valid_path = os.path.join(archive.data_dir_path, 'test-directory')
+            final_path = archive.make_final_path(directory="test-directory")
+            valid_path = os.path.join(archive.data_dir_path, "test-directory")
             self.assertEqual(final_path, valid_path)
 
     def test_make_final_path_date(self):
@@ -168,12 +172,12 @@ class DataArchiveTests(AuthenticatedUserTestCase):
         with DataArchive(self.user, DATA_DOWNLOADS_WORKING_DIR) as archive:
             now = timezone.now().date()
             final_path = archive.make_final_path(date=now)
-            
+
             valid_path = os.path.join(
                 archive.data_dir_path,
-                now.strftime('%Y'),
-                now.strftime('%m'),
-                now.strftime('%d')
+                now.strftime("%Y"),
+                now.strftime("%m"),
+                now.strftime("%d"),
             )
 
             self.assertEqual(final_path, valid_path)
@@ -183,12 +187,12 @@ class DataArchiveTests(AuthenticatedUserTestCase):
         with DataArchive(self.user, DATA_DOWNLOADS_WORKING_DIR) as archive:
             now = timezone.now()
             final_path = archive.make_final_path(date=now)
-            
+
             valid_path = os.path.join(
                 archive.data_dir_path,
-                now.strftime('%Y'),
-                now.strftime('%m'),
-                now.strftime('%d')
+                now.strftime("%Y"),
+                now.strftime("%m"),
+                now.strftime("%d"),
             )
 
             self.assertEqual(final_path, valid_path)
@@ -198,13 +202,13 @@ class DataArchiveTests(AuthenticatedUserTestCase):
         with DataArchive(self.user, DATA_DOWNLOADS_WORKING_DIR) as archive:
             expected_message = "date and directory arguments are mutually exclusive"
             with self.assertRaisesMessage(ValueError, expected_message):
-                archive.make_final_path(date=timezone.now(), directory='test')
+                archive.make_final_path(date=timezone.now(), directory="test")
 
     def test_get_file(self):
         """get_file returns django file"""
         django_file = None
-        
-        with open(TEST_AVATAR_PATH, 'rb') as avatar:
+
+        with open(TEST_AVATAR_PATH, "rb") as avatar:
             self.user.avatar_tmp = File(avatar)
             self.user.save()
 
@@ -226,18 +230,18 @@ class DataArchiveTests(AuthenticatedUserTestCase):
 class TrimLongFilenameTests(TestCase):
     def test_trim_short_filename(self):
         """trim_too_long_filename returns short filename as it is"""
-        filename = 'filename.jpg'
+        filename = "filename.jpg"
         trimmed_filename = trim_long_filename(filename)
         self.assertEqual(trimmed_filename, filename)
 
     def test_trim_too_long_filename(self):
         """trim_too_long_filename trims filename if its longer than allowed"""
-        filename = 'filename'
-        extension = '.jpg'
-        long_filename = '%s%s' % (filename * 10, extension)
+        filename = "filename"
+        extension = ".jpg"
+        long_filename = "%s%s" % (filename * 10, extension)
 
         trimmed_filename = trim_long_filename(long_filename)
-        
+
         self.assertEqual(len(trimmed_filename), FILENAME_MAX_LEN)
         self.assertTrue(trimmed_filename.startswith(filename))
         self.assertTrue(trimmed_filename.endswith(extension))

+ 26 - 33
misago/users/tests/test_datadownloadsadmin_views.py

@@ -11,24 +11,26 @@ from misago.users.models import DataDownload
 
 UserModel = get_user_model()
 
-TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
-TEST_FILE_PATH = os.path.join(TESTFILES_DIR, 'avatar.png')
+TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testfiles")
+TEST_FILE_PATH = os.path.join(TESTFILES_DIR, "avatar.png")
 
 
 class DataDownloadAdminViewsTests(AdminTestCase):
     def test_link_registered(self):
         """admin nav contains data downloads link"""
-        response = self.client.get(reverse('misago:admin:users:accounts:index'))
+        response = self.client.get(reverse("misago:admin:users:accounts:index"))
 
-        response = self.client.get(response['location'])
-        self.assertContains(response, reverse('misago:admin:users:data-downloads:index'))
+        response = self.client.get(response["location"])
+        self.assertContains(
+            response, reverse("misago:admin:users:data-downloads:index")
+        )
 
     def test_list_view(self):
         """data downloads list view returns 200"""
-        response = self.client.get(reverse('misago:admin:users:data-downloads:index'))
+        response = self.client.get(reverse("misago:admin:users:data-downloads:index"))
         self.assertEqual(response.status_code, 302)
 
-        view_url = response['location']
+        view_url = response["location"]
 
         response = self.client.get(view_url)
         self.assertEqual(response.status_code, 200)
@@ -41,7 +43,7 @@ class DataDownloadAdminViewsTests(AdminTestCase):
         """expire action marks data download as expired and deletes its file"""
         data_download = request_user_data_download(self.user)
 
-        with open(TEST_FILE_PATH, 'rb') as upload:
+        with open(TEST_FILE_PATH, "rb") as upload:
             data_download.file = File(upload)
             data_download.save()
 
@@ -49,11 +51,8 @@ class DataDownloadAdminViewsTests(AdminTestCase):
         self.assertTrue(os.path.isfile(data_download.file.path))
 
         response = self.client.post(
-            reverse('misago:admin:users:data-downloads:index'),
-            data={
-                'action': 'expire',
-                'selected_items': [data_download.pk],
-            },
+            reverse("misago:admin:users:data-downloads:index"),
+            data={"action": "expire", "selected_items": [data_download.pk]},
         )
         self.assertEqual(response.status_code, 302)
 
@@ -67,7 +66,7 @@ class DataDownloadAdminViewsTests(AdminTestCase):
         """dele action deletes data download together with its file"""
         data_download = request_user_data_download(self.user)
 
-        with open(TEST_FILE_PATH, 'rb') as upload:
+        with open(TEST_FILE_PATH, "rb") as upload:
             data_download.file = File(upload)
             data_download.save()
 
@@ -75,11 +74,8 @@ class DataDownloadAdminViewsTests(AdminTestCase):
         self.assertTrue(os.path.isfile(data_download.file.path))
 
         response = self.client.post(
-            reverse('misago:admin:users:data-downloads:index'),
-            data={
-                'action': 'delete',
-                'selected_items': [data_download.pk],
-            },
+            reverse("misago:admin:users:data-downloads:index"),
+            data={"action": "delete", "selected_items": [data_download.pk]},
         )
         self.assertEqual(response.status_code, 302)
 
@@ -88,18 +84,15 @@ class DataDownloadAdminViewsTests(AdminTestCase):
 
     def test_request_view(self):
         """request data downloads view initializes new downloads"""
-        response = self.client.get(reverse('misago:admin:users:data-downloads:request'))
+        response = self.client.get(reverse("misago:admin:users:data-downloads:request"))
         self.assertEqual(response.status_code, 200)
 
-        other_user = UserModel.objects.create_user('bob', 'bob@boberson.com')
+        other_user = UserModel.objects.create_user("bob", "bob@boberson.com")
 
         response = self.client.post(
-            reverse('misago:admin:users:data-downloads:request'),
+            reverse("misago:admin:users:data-downloads:request"),
             data={
-                'user_identifiers': '\n'.join([
-                    self.user.username,
-                    other_user.email,
-                ]),
+                "user_identifiers": "\n".join([self.user.username, other_user.email])
             },
         )
         self.assertEqual(response.status_code, 302)
@@ -108,12 +101,12 @@ class DataDownloadAdminViewsTests(AdminTestCase):
 
     def test_request_view_empty_data(self):
         """request data downloads view handles empty data"""
-        response = self.client.get(reverse('misago:admin:users:data-downloads:request'))
+        response = self.client.get(reverse("misago:admin:users:data-downloads:request"))
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
-            reverse('misago:admin:users:data-downloads:request'),
-            data={'user_identifiers': ''},
+            reverse("misago:admin:users:data-downloads:request"),
+            data={"user_identifiers": ""},
         )
         self.assertEqual(response.status_code, 200)
 
@@ -121,13 +114,13 @@ class DataDownloadAdminViewsTests(AdminTestCase):
 
     def test_request_view_user_not_found(self):
         """request data downloads view handles empty data"""
-        response = self.client.get(reverse('misago:admin:users:data-downloads:request'))
+        response = self.client.get(reverse("misago:admin:users:data-downloads:request"))
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
-            reverse('misago:admin:users:data-downloads:request'),
-            data={'user_identifiers': 'not@found.com'},
+            reverse("misago:admin:users:data-downloads:request"),
+            data={"user_identifiers": "not@found.com"},
         )
         self.assertEqual(response.status_code, 200)
 
-        self.assertEqual(DataDownload.objects.count(), 0)
+        self.assertEqual(DataDownload.objects.count(), 0)

+ 13 - 15
misago/users/tests/test_decorators.py

@@ -8,14 +8,14 @@ from misago.users.testutils import UserTestCase
 class DenyAuthenticatedTests(UserTestCase):
     def test_success(self):
         """deny_authenticated decorator allowed guest request"""
-        response = self.client.post(reverse('misago:request-activation'))
+        response = self.client.post(reverse("misago:request-activation"))
         self.assertEqual(response.status_code, 200)
 
     def test_fail(self):
         """deny_authenticated decorator denied authenticated request"""
         self.login_user(self.get_authenticated_user())
 
-        response = self.client.post(reverse('misago:request-activation'))
+        response = self.client.post(reverse("misago:request-activation"))
         self.assertEqual(response.status_code, 403)
 
 
@@ -24,40 +24,38 @@ class DenyGuestsTests(UserTestCase):
         """deny_guests decorator allowed authenticated request"""
         self.login_user(self.get_authenticated_user())
 
-        response = self.client.post(reverse('misago:options'))
+        response = self.client.post(reverse("misago:options"))
         self.assertEqual(response.status_code, 200)
 
     def test_fail(self):
         """deny_guests decorator blocked guest request"""
-        response = self.client.post(reverse('misago:options'))
+        response = self.client.post(reverse("misago:options"))
         self.assertEqual(response.status_code, 403)
 
     def test_ref_login(self):
         """deny_guests decorator redirected guest request to homepage if ref=login"""
-        response = self.client.post('%s?ref=login' % reverse('misago:options'))
+        response = self.client.post("%s?ref=login" % reverse("misago:options"))
         self.assertEqual(response.status_code, 302)
-        self.assertEqual(response['location'], reverse('misago:index'))
+        self.assertEqual(response["location"], reverse("misago:index"))
 
 
 class DenyBannedIPTests(UserTestCase):
     def test_success(self):
         """deny_banned_ips decorator allowed unbanned request"""
         Ban.objects.create(
-            check_type=Ban.IP,
-            banned_value='83.*',
-            user_message="Ya got banned!",
+            check_type=Ban.IP, banned_value="83.*", user_message="Ya got banned!"
         )
 
-        response = self.client.post(reverse('misago:request-activation'))
+        response = self.client.post(reverse("misago:request-activation"))
         self.assertEqual(response.status_code, 200)
 
     def test_fail(self):
         """deny_banned_ips decorator denied banned request"""
         Ban.objects.create(
-            check_type=Ban.IP,
-            banned_value='127.*',
-            user_message="Ya got banned!",
+            check_type=Ban.IP, banned_value="127.*", user_message="Ya got banned!"
         )
 
-        response = self.client.post(reverse('misago:request-activation'))
-        self.assertContains(response, encode_json_html("<p>Ya got banned!</p>"), status_code=403)
+        response = self.client.post(reverse("misago:request-activation"))
+        self.assertContains(
+            response, encode_json_html("<p>Ya got banned!</p>"), status_code=403
+        )

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

@@ -14,7 +14,7 @@ UserModel = get_user_model()
 
 class DeleteInactiveUsersTests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        self.user = UserModel.objects.create_user("Bob", "bob@bob.com", "pass123")
 
     @override_settings(MISAGO_DELETE_NEW_INACTIVE_USERS_OLDER_THAN_DAYS=2)
     def test_delete_user_activation_user(self):
@@ -104,6 +104,8 @@ class DeleteInactiveUsersTests(TestCase):
         command_output = out.getvalue().splitlines()[0].strip()
 
         self.assertEqual(
-            command_output, "Automatic deletion of inactive users is currently disabled.")
+            command_output,
+            "Automatic deletion of inactive users is currently disabled.",
+        )
 
         UserModel.objects.get(pk=self.user.pk)

+ 6 - 6
misago/users/tests/test_deletemarkedusers.py

@@ -12,7 +12,7 @@ UserModel = get_user_model()
 
 class DeleteMarkedUsersTests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        self.user = UserModel.objects.create_user("Bob", "bob@bob.com", "pass123")
         self.user.mark_for_delete()
 
     def test_delete_marked_user(self):
@@ -34,10 +34,10 @@ class DeleteMarkedUsersTests(TestCase):
         command_output = out.getvalue().splitlines()[0].strip()
 
         self.assertEqual(command_output, "Deleted users: 1")
-        
+
         with self.assertRaises(UserModel.DoesNotExist):
             UserModel.objects.get(pk=self.user.pk)
-            
+
     def test_delete_not_marked(self):
         """user has to be marked to be deletable"""
         self.user.is_deleting_account = False
@@ -48,7 +48,7 @@ class DeleteMarkedUsersTests(TestCase):
         command_output = out.getvalue().splitlines()[0].strip()
 
         self.assertEqual(command_output, "Deleted users: 0")
-        
+
         UserModel.objects.get(pk=self.user.pk)
 
     def test_delete_is_staff(self):
@@ -61,7 +61,7 @@ class DeleteMarkedUsersTests(TestCase):
         command_output = out.getvalue().splitlines()[0].strip()
 
         self.assertEqual(command_output, "Deleted users: 0")
-        
+
         UserModel.objects.get(pk=self.user.pk)
 
     def test_delete_superuser(self):
@@ -74,5 +74,5 @@ class DeleteMarkedUsersTests(TestCase):
         command_output = out.getvalue().splitlines()[0].strip()
 
         self.assertEqual(command_output, "Deleted users: 0")
-        
+
         UserModel.objects.get(pk=self.user.pk)

+ 11 - 7
misago/users/tests/test_deleteprofilefield.py

@@ -22,22 +22,26 @@ class DeleteProfileFieldTests(TestCase):
     def test_no_fields_set(self):
         """utility has no showstoppers when no fields are set"""
         out = StringIO()
-        call_command(deleteprofilefield.Command(), 'gender', stdout=out)
+        call_command(deleteprofilefield.Command(), "gender", stdout=out)
         command_output = out.getvalue().splitlines()[0].strip()
 
-        self.assertEqual(command_output, '"gender" profile field has been deleted from 0 users.')
+        self.assertEqual(
+            command_output, '"gender" profile field has been deleted from 0 users.'
+        )
 
     def test_delete_fields(self):
         """utility has no showstoppers when no fields are set"""
-        user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
-        user.profile_fields = {'gender': 'male', 'bio': "Yup!"}
+        user = UserModel.objects.create_user("Bob", "bob@bob.com", "pass123")
+        user.profile_fields = {"gender": "male", "bio": "Yup!"}
         user.save()
 
         out = StringIO()
-        call_command(deleteprofilefield.Command(), 'gender', stdout=out)
+        call_command(deleteprofilefield.Command(), "gender", stdout=out)
         command_output = out.getvalue().splitlines()[0].strip()
 
-        self.assertEqual(command_output, '"gender" profile field has been deleted from 1 users.')
+        self.assertEqual(
+            command_output, '"gender" profile field has been deleted from 1 users.'
+        )
 
         user = UserModel.objects.get(pk=user.pk)
-        self.assertEqual(user.profile_fields, {'bio': "Yup!"})
+        self.assertEqual(user.profile_fields, {"bio": "Yup!"})

+ 8 - 11
misago/users/tests/test_djangoadmin_auth.py

@@ -4,7 +4,7 @@ from django.urls import reverse
 from misago.admin.testutils import AdminTestCase
 
 
-@override_settings(ROOT_URLCONF='misago.core.testproject.urls')
+@override_settings(ROOT_URLCONF="misago.core.testproject.urls")
 class DjangoAdminAuthTests(AdminTestCase):
     """assertions for Django admin auth interop with Misago User Model"""
 
@@ -13,35 +13,32 @@ class DjangoAdminAuthTests(AdminTestCase):
         self.logout_user()
 
         # form renders
-        response = self.client.get(reverse('admin:index'))
+        response = self.client.get(reverse("admin:index"))
         self.assertEqual(response.status_code, 200)
 
         # form handles login
         response = self.client.post(
-            reverse('admin:index'),
-            data={
-                'username': self.user.email,
-                'password': self.USER_PASSWORD,
-            },
+            reverse("admin:index"),
+            data={"username": self.user.email, "password": self.USER_PASSWORD},
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('admin:index'))
+        response = self.client.get(reverse("admin:index"))
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.user.username)
 
     def test_logout(self):
         """its possible to sign out from django admin"""
-        response = self.client.get(reverse('admin:index'))
+        response = self.client.get(reverse("admin:index"))
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.user.username)
 
         # assert there's no showstopper on signout page
-        response = self.client.get(reverse('admin:logout'))
+        response = self.client.get(reverse("admin:logout"))
         self.assertEqual(response.status_code, 200)
         self.assertNotContains(response, self.user.username)
 
         # user was signed out
-        response = self.client.get(reverse('admin:index'))
+        response = self.client.get(reverse("admin:index"))
         self.assertEqual(response.status_code, 200)
         self.assertNotContains(response, self.user.username)

+ 9 - 14
misago/users/tests/test_djangoadmin_user.py

@@ -8,22 +8,18 @@ from misago.admin.testutils import AdminTestCase
 from misago.users.djangoadmin import UserAdminModel
 
 
-@override_settings(ROOT_URLCONF='misago.core.testproject.urls')
+@override_settings(ROOT_URLCONF="misago.core.testproject.urls")
 class TestDjangoAdminUserForm(AdminTestCase):
     def setUp(self):
         super().setUp()
         self.test_user = get_user_model().objects.create_user(
-            username='Bob',
-            email='bob@test.com',
-            password='Pass.123',
+            username="Bob", email="bob@test.com", password="Pass.123"
         )
         self.edit_test_user_in_django_url = reverse(
-            'admin:misago_users_user_change',
-            args=[self.test_user.pk],
+            "admin:misago_users_user_change", args=[self.test_user.pk]
         )
         self.edit_test_user_in_misago_url = reverse(
-            'misago:admin:users:accounts:edit',
-            args=[self.test_user.pk],
+            "misago:admin:users:accounts:edit", args=[self.test_user.pk]
         )
 
     def test_user_edit_view_content(self):
@@ -54,8 +50,7 @@ class TestDjangoAdminUserForm(AdminTestCase):
             perms_all_pks.append(perm.pk)
 
         response = self.client.post(
-            self.edit_test_user_in_django_url,
-            data={'user_permissions': perms_all_pks},
+            self.edit_test_user_in_django_url, data={"user_permissions": perms_all_pks}
         )
         self.assertEqual(response.status_code, 302)
 
@@ -68,12 +63,12 @@ class TestDjangoAdminUserForm(AdminTestCase):
         """the url to Misago admin is present in Django admin user edit view"""
         response = self.client.get(self.edit_test_user_in_django_url)
         self.assertContains(response, self.edit_test_user_in_misago_url)
-        edit_from_misago_short_desc = UserAdminModel.get_edit_from_misago_url.short_description
+        edit_from_misago_short_desc = (
+            UserAdminModel.get_edit_from_misago_url.short_description
+        )
         self.assertContains(response, edit_from_misago_short_desc)
 
     def test_misago_admin_url_presence_in_user_list_view(self):
         """the url to Misago admin is present in Django admin user list view"""
-        response = self.client.get(
-            reverse('admin:misago_users_user_changelist'),
-        )
+        response = self.client.get(reverse("admin:misago_users_user_changelist"))
         self.assertContains(response, self.edit_test_user_in_misago_url)

+ 5 - 5
misago/users/tests/test_expireuserdatadownloads.py

@@ -11,8 +11,8 @@ from misago.users.models import DataDownload
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
-TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
-TEST_FILE_PATH = os.path.join(TESTFILES_DIR, 'avatar.png')
+TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testfiles")
+TEST_FILE_PATH = os.path.join(TESTFILES_DIR, "avatar.png")
 
 
 class ExpireUserDataDownloadsTests(AuthenticatedUserTestCase):
@@ -20,7 +20,7 @@ class ExpireUserDataDownloadsTests(AuthenticatedUserTestCase):
         """management command deletes expired data download"""
         data_download = request_user_data_download(self.user)
         data_download.status = DataDownload.STATUS_READY
-        with open(TEST_FILE_PATH, 'rb') as download_file:
+        with open(TEST_FILE_PATH, "rb") as download_file:
             data_download.file = File(download_file)
             data_download.save()
 
@@ -39,7 +39,7 @@ class ExpireUserDataDownloadsTests(AuthenticatedUserTestCase):
         data_download = request_user_data_download(self.user)
         data_download.status = DataDownload.STATUS_READY
         data_download.expires_on += timedelta(hours=1)
-        with open(TEST_FILE_PATH, 'rb') as download_file:
+        with open(TEST_FILE_PATH, "rb") as download_file:
             data_download.file = File(download_file)
             data_download.save()
 
@@ -52,7 +52,7 @@ class ExpireUserDataDownloadsTests(AuthenticatedUserTestCase):
         updated_data_download = DataDownload.objects.get(pk=data_download.pk)
         self.assertEqual(updated_data_download.status, DataDownload.STATUS_READY)
         self.assertTrue(updated_data_download.file)
-    
+
     def test_skip_pending_data_download(self):
         """management command skips pending data downloads"""
         data_download = request_user_data_download(self.user)

+ 21 - 38
misago/users/tests/test_forgottenpassword_views.py

@@ -13,14 +13,14 @@ UserModel = get_user_model()
 class ForgottenPasswordViewsTests(UserTestCase):
     def test_guest_request_view_returns_200(self):
         """request new password view returns 200 for guests"""
-        response = self.client.get(reverse('misago:forgotten-password'))
+        response = self.client.get(reverse("misago:forgotten-password"))
         self.assertEqual(response.status_code, 200)
 
     def test_authenticated_request_view_returns_200(self):
         """request new password view returns 200 for authenticated"""
         self.login_user(self.get_authenticated_user())
 
-        response = self.client.get(reverse('misago:forgotten-password'))
+        response = self.client.get(reverse("misago:forgotten-password"))
         self.assertEqual(response.status_code, 200)
 
     def test_authenticated_request_unusable_password_view_returns_200(self):
@@ -32,35 +32,30 @@ class ForgottenPasswordViewsTests(UserTestCase):
         self.assertFalse(user.has_usable_password())
         self.login_user(user)
 
-        response = self.client.get(reverse('misago:forgotten-password'))
+        response = self.client.get(reverse("misago:forgotten-password"))
         self.assertEqual(response.status_code, 200)
 
     def test_change_password_on_banned(self):
         """change banned user password errors"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        test_user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
         Ban.objects.create(
-            check_type=Ban.USERNAME,
-            banned_value='bob',
-            user_message='Nope!',
+            check_type=Ban.USERNAME, banned_value="bob", user_message="Nope!"
         )
 
         password_token = make_password_change_token(test_user)
 
         response = self.client.get(
             reverse(
-                'misago:forgotten-password-change-form',
-                kwargs={
-                    'pk': test_user.pk,
-                    'token': password_token,
-                },
+                "misago:forgotten-password-change-form",
+                kwargs={"pk": test_user.pk, "token": password_token},
             )
         )
         self.assertContains(response, encode_json_html("<p>Nope!</p>"), status_code=403)
 
     def test_change_password_on_other_user(self):
         """change other user password errors"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        test_user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
         password_token = make_password_change_token(test_user)
 
@@ -68,60 +63,48 @@ class ForgottenPasswordViewsTests(UserTestCase):
 
         response = self.client.get(
             reverse(
-                'misago:forgotten-password-change-form',
-                kwargs={
-                    'pk': test_user.pk,
-                    'token': password_token,
-                },
+                "misago:forgotten-password-change-form",
+                kwargs={"pk": test_user.pk, "token": password_token},
             )
         )
-        self.assertContains(response, 'your link has expired', status_code=400)
+        self.assertContains(response, "your link has expired", status_code=400)
 
     def test_change_password_invalid_token(self):
         """invalid form token errors"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        test_user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
         response = self.client.get(
             reverse(
-                'misago:forgotten-password-change-form',
-                kwargs={
-                    'pk': test_user.pk,
-                    'token': 'abcdfghqsads',
-                },
+                "misago:forgotten-password-change-form",
+                kwargs={"pk": test_user.pk, "token": "abcdfghqsads"},
             )
         )
-        self.assertContains(response, 'your link is invalid', status_code=400)
+        self.assertContains(response, "your link is invalid", status_code=400)
 
     def test_change_password_form(self):
         """change user password form displays for valid token"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
+        test_user = UserModel.objects.create_user("Bob", "bob@test.com", "Pass.123")
 
         password_token = make_password_change_token(test_user)
 
         response = self.client.get(
             reverse(
-                'misago:forgotten-password-change-form',
-                kwargs={
-                    'pk': test_user.pk,
-                    'token': password_token,
-                },
+                "misago:forgotten-password-change-form",
+                kwargs={"pk": test_user.pk, "token": password_token},
             )
         )
         self.assertContains(response, password_token)
 
     def test_change_password_unusable_password_form(self):
         """set user first password form displays for valid token"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com')
+        test_user = UserModel.objects.create_user("Bob", "bob@test.com")
 
         password_token = make_password_change_token(test_user)
 
         response = self.client.get(
             reverse(
-                'misago:forgotten-password-change-form',
-                kwargs={
-                    'pk': test_user.pk,
-                    'token': password_token,
-                },
+                "misago:forgotten-password-change-form",
+                kwargs={"pk": test_user.pk, "token": password_token},
             )
         )
         self.assertContains(response, password_token)

+ 140 - 156
misago/users/tests/test_gender_profilefield.py

@@ -12,10 +12,7 @@ class GenderProfileFieldTests(AdminTestCase):
         super().setUp()
 
         self.test_link = reverse(
-            'misago:admin:users:accounts:edit',
-            kwargs={
-                'pk': self.user.pk,
-            },
+            "misago:admin:users:accounts:edit", kwargs={"pk": self.user.pk}
         )
 
     def test_field_displays_in_admin(self):
@@ -25,294 +22,281 @@ class GenderProfileFieldTests(AdminTestCase):
 
     def test_admin_clears_field(self):
         """admin form allows admins to clear field"""
-        self.user.profile_fields['gender'] = 'female'
+        self.user.profile_fields["gender"] = "female"
         self.user.save()
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['gender'], 'female')
+        self.assertEqual(self.user.profile_fields["gender"], "female")
 
         response = self.client.post(
             self.test_link,
             data={
-                'username': 'Edited',
-                'rank': str(self.user.rank_id),
-                'roles': str(self.user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'new_password': '',
-                'signature': '',
-                'is_signature_locked': '0',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': '',
-                'signature_lock_user_message': '',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Edited",
+                "rank": str(self.user.rank_id),
+                "roles": str(self.user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "new_password": "",
+                "signature": "",
+                "is_signature_locked": "0",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "",
+                "signature_lock_user_message": "",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['gender'], '')
+        self.assertEqual(self.user.profile_fields["gender"], "")
 
     def test_admin_validates_field(self):
         """admin form allows admins to edit field"""
         response = self.client.post(
             self.test_link,
             data={
-                'username': 'Edited',
-                'rank': str(self.user.rank_id),
-                'roles': str(self.user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'gender': 'attackcopter',
-                'new_password': '',
-                'signature': '',
-                'is_signature_locked': '0',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': '',
-                'signature_lock_user_message': '',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Edited",
+                "rank": str(self.user.rank_id),
+                "roles": str(self.user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "gender": "attackcopter",
+                "new_password": "",
+                "signature": "",
+                "is_signature_locked": "0",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "",
+                "signature_lock_user_message": "",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
 
-        self.assertContains(response, 'attackcopter is not one of the available choices.')
+        self.assertContains(
+            response, "attackcopter is not one of the available choices."
+        )
 
     def test_admin_edits_field(self):
         """admin form allows admins to edit field"""
         response = self.client.post(
             self.test_link,
             data={
-                'username': 'Edited',
-                'rank': str(self.user.rank_id),
-                'roles': str(self.user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'gender': 'female',
-                'new_password': '',
-                'signature': '',
-                'is_signature_locked': '0',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': '',
-                'signature_lock_user_message': '',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Edited",
+                "rank": str(self.user.rank_id),
+                "roles": str(self.user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "gender": "female",
+                "new_password": "",
+                "signature": "",
+                "is_signature_locked": "0",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "",
+                "signature_lock_user_message": "",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['gender'], 'female')
+        self.assertEqual(self.user.profile_fields["gender"], "female")
 
     def test_admin_search_field(self):
         """admin users search searches this field"""
-        test_link = reverse('misago:admin:users:accounts:index')
+        test_link = reverse("misago:admin:users:accounts:index")
 
-        response = self.client.get('%s?redirected=1&profilefields=female' % test_link)
-        self.assertContains(response, "No users matching search criteria have been found.")
+        response = self.client.get("%s?redirected=1&profilefields=female" % test_link)
+        self.assertContains(
+            response, "No users matching search criteria have been found."
+        )
 
         # search by value
-        self.user.profile_fields['gender'] = 'female'
+        self.user.profile_fields["gender"] = "female"
         self.user.save()
 
-        response = self.client.get('%s?redirected=1&profilefields=female' % test_link)
-        self.assertNotContains(response, "No users matching search criteria have been found.")
+        response = self.client.get("%s?redirected=1&profilefields=female" % test_link)
+        self.assertNotContains(
+            response, "No users matching search criteria have been found."
+        )
 
         # search by choice name
-        self.user.profile_fields['gender'] = 'secret'
+        self.user.profile_fields["gender"] = "secret"
         self.user.save()
 
-        response = self.client.get('%s?redirected=1&profilefields=telling' % test_link)
-        self.assertNotContains(response, "No users matching search criteria have been found.")
+        response = self.client.get("%s?redirected=1&profilefields=telling" % test_link)
+        self.assertNotContains(
+            response, "No users matching search criteria have been found."
+        )
 
     def test_field_display(self):
         """field displays on user profile when filled in"""
         test_link = reverse(
-            'misago:user-details',
-            kwargs={
-                'pk': self.user.pk,
-                'slug': self.user.slug,
-            },
+            "misago:user-details", kwargs={"pk": self.user.pk, "slug": self.user.slug}
         )
 
         response = self.client.get(test_link)
-        self.assertNotContains(response, 'Gender')
+        self.assertNotContains(response, "Gender")
 
-        self.user.profile_fields['gender'] = 'secret'
+        self.user.profile_fields["gender"] = "secret"
         self.user.save()
 
         response = self.client.get(test_link)
-        self.assertContains(response, 'Gender')
-        self.assertContains(response, 'Not telling')
+        self.assertContains(response, "Gender")
+        self.assertContains(response, "Not telling")
 
     def test_field_outdated_hidden(self):
         """field with outdated value is hidden"""
         test_link = reverse(
-            'misago:user-details',
-            kwargs={
-                'pk': self.user.pk,
-                'slug': self.user.slug,
-            },
+            "misago:user-details", kwargs={"pk": self.user.pk, "slug": self.user.slug}
         )
 
         response = self.client.get(test_link)
-        self.assertNotContains(response, 'Gender')
+        self.assertNotContains(response, "Gender")
 
-        self.user.profile_fields['gender'] = 'not valid'
+        self.user.profile_fields["gender"] = "not valid"
         self.user.save()
 
         response = self.client.get(test_link)
-        self.assertNotContains(response, 'Gender')
+        self.assertNotContains(response, "Gender")
 
     def test_field_display_json(self):
         """field is included in display json"""
-        test_link = reverse('misago:api:user-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-details", kwargs={"pk": self.user.pk})
 
         response = self.client.get(test_link)
         self.assertEqual(
-            response.json()['groups'],
+            response.json()["groups"],
             [
                 {
-                    'name': 'IP address',
-                    'fields': [
-                        {
-                            'fieldname': 'join_ip',
-                            'name': 'Join IP',
-                            'text': '127.0.0.1',
-                        },
+                    "name": "IP address",
+                    "fields": [
+                        {"fieldname": "join_ip", "name": "Join IP", "text": "127.0.0.1"}
                     ],
-                },
-            ]
+                }
+            ],
         )
 
-        self.user.profile_fields['gender'] = 'male'
+        self.user.profile_fields["gender"] = "male"
         self.user.save()
 
         response = self.client.get(test_link)
         self.assertEqual(
-            response.json()['groups'],
+            response.json()["groups"],
             [
                 {
-                    'name': 'Personal',
-                    'fields': [
-                        {
-                            'fieldname': 'gender',
-                            'name': 'Gender',
-                            'text': 'Male',
-                        }
+                    "name": "Personal",
+                    "fields": [
+                        {"fieldname": "gender", "name": "Gender", "text": "Male"}
                     ],
                 },
                 {
-                    'name': 'IP address',
-                    'fields': [
-                        {
-                            'fieldname': 'join_ip',
-                            'name': 'Join IP',
-                            'text': '127.0.0.1',
-                        },
+                    "name": "IP address",
+                    "fields": [
+                        {"fieldname": "join_ip", "name": "Join IP", "text": "127.0.0.1"}
                     ],
                 },
-            ]
+            ],
         )
 
     def test_field_outdated_hidden_json(self):
         """field with outdated value is removed in display json"""
-        test_link = reverse('misago:api:user-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-details", kwargs={"pk": self.user.pk})
 
         response = self.client.get(test_link)
         self.assertEqual(
-            response.json()['groups'],
+            response.json()["groups"],
             [
                 {
-                    'name': 'IP address',
-                    'fields': [
-                        {
-                            'fieldname': 'join_ip',
-                            'name': 'Join IP',
-                            'text': '127.0.0.1',
-                        },
+                    "name": "IP address",
+                    "fields": [
+                        {"fieldname": "join_ip", "name": "Join IP", "text": "127.0.0.1"}
                     ],
-                },
-            ]
+                }
+            ],
         )
 
-        self.user.profile_fields['gender'] = 'invalid'
+        self.user.profile_fields["gender"] = "invalid"
         self.user.save()
 
         response = self.client.get(test_link)
         self.assertEqual(
-            response.json()['groups'],
+            response.json()["groups"],
             [
                 {
-                    'name': 'IP address',
-                    'fields': [
-                        {
-                            'fieldname': 'join_ip',
-                            'name': 'Join IP',
-                            'text': '127.0.0.1',
-                        },
+                    "name": "IP address",
+                    "fields": [
+                        {"fieldname": "join_ip", "name": "Join IP", "text": "127.0.0.1"}
                     ],
-                },
-            ]
+                }
+            ],
         )
 
     def test_api_returns_field_json(self):
         """field json is returned from API"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
         response = self.client.get(test_link)
 
         found_field = None
         for group in response.json():
-            for field in group['fields']:
-                if field['fieldname'] == 'gender':
+            for field in group["fields"]:
+                if field["fieldname"] == "gender":
                     found_field = field
 
-        self.assertEqual(found_field, {
-            'fieldname': 'gender',
-            'label': 'Gender',
-            'help_text': None,
-            'input': {
-                'type': 'select',
-                'choices': [
-                    {'label': 'Not specified', 'value': ''},
-                    {'label': 'Not telling', 'value': 'secret'},
-                    {'label': 'Female', 'value': 'female'},
-                    {'label': 'Male', 'value': 'male'},
-                ],
+        self.assertEqual(
+            found_field,
+            {
+                "fieldname": "gender",
+                "label": "Gender",
+                "help_text": None,
+                "input": {
+                    "type": "select",
+                    "choices": [
+                        {"label": "Not specified", "value": ""},
+                        {"label": "Not telling", "value": "secret"},
+                        {"label": "Female", "value": "female"},
+                        {"label": "Male", "value": "male"},
+                    ],
+                },
+                "initial": "",
             },
-            'initial': '',
-        })
+        )
 
     def test_api_clears_field(self):
         """field can be cleared via api"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
-        self.user.profile_fields['gender'] = 'secret'
+        self.user.profile_fields["gender"] = "secret"
         self.user.save()
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['gender'], 'secret')
+        self.assertEqual(self.user.profile_fields["gender"], "secret")
 
         response = self.client.post(test_link, data={})
         self.assertEqual(response.status_code, 200)
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['gender'], '')
+        self.assertEqual(self.user.profile_fields["gender"], "")
 
     def test_api_validates_field(self):
         """field can be edited via api"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
-        response = self.client.post(test_link, data={'gender': 'attackhelicopter'})
-        self.assertContains(response, "attackhelicopter is not one of the available choices.", status_code=400)
+        response = self.client.post(test_link, data={"gender": "attackhelicopter"})
+        self.assertContains(
+            response,
+            "attackhelicopter is not one of the available choices.",
+            status_code=400,
+        )
 
     def test_api_edits_field(self):
         """field can be edited via api"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
-        response = self.client.post(test_link, data={'gender': 'female'})
+        response = self.client.post(test_link, data={"gender": "female"})
         self.assertEqual(response.status_code, 200)
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['gender'], 'female')
+        self.assertEqual(self.user.profile_fields["gender"], "female")

+ 10 - 6
misago/users/tests/test_getting_user_status.py

@@ -11,35 +11,39 @@ User = get_user_model()
 class GetUserStatusTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
-        self.other_user = User.objects.create_user('Tyrael', 't123@test.com', 'pass123')
+        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},
+            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):
+    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},
+            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):
+    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},
+            user_acl={"can_see_hidden_users": True},
             cache_versions={"bans": "abcdefgh"},
         )
         assert get_user_status(request, self.other_user)["is_online_hidden"]

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

@@ -33,7 +33,7 @@ class InvalidateBansTests(TestCase):
         call_command(command, stdout=out)
         command_output = out.getvalue().splitlines()[0].strip()
 
-        self.assertEqual(command_output, 'Bans invalidated: 5')
+        self.assertEqual(command_output, "Bans invalidated: 5")
 
         self.assertEqual(Ban.objects.filter(is_checked=True).count(), 0)
 
@@ -55,15 +55,12 @@ class InvalidateBansTests(TestCase):
         call_command(command, stdout=out)
         command_output = out.getvalue().splitlines()[1].strip()
 
-        self.assertEqual(command_output, 'Ban caches emptied: 0')
+        self.assertEqual(command_output, "Ban caches emptied: 0")
         self.assertEqual(Ban.objects.filter(is_checked=True).count(), 1)
 
         # expire bans
         expired_date = timezone.now() - timedelta(days=10)
-        Ban.objects.all().update(
-            expires_on=expired_date,
-            is_checked=True,
-        )
+        Ban.objects.all().update(expires_on=expired_date, is_checked=True)
         BanCache.objects.all().update(expires_on=expired_date)
 
         # invalidate expired ban cache
@@ -71,7 +68,7 @@ class InvalidateBansTests(TestCase):
         call_command(command, stdout=out)
         command_output = out.getvalue().splitlines()[1].strip()
 
-        self.assertEqual(command_output, 'Ban caches emptied: 1')
+        self.assertEqual(command_output, "Ban caches emptied: 1")
         self.assertEqual(Ban.objects.filter(is_checked=True).count(), 0)
 
         # see if user is banned anymore

+ 46 - 61
misago/users/tests/test_joinip_profilefield.py

@@ -13,10 +13,7 @@ class JoinIpProfileFieldTests(AdminTestCase):
         super().setUp()
 
         self.test_link = reverse(
-            'misago:admin:users:accounts:edit',
-            kwargs={
-                'pk': self.user.pk,
-            },
+            "misago:admin:users:accounts:edit", kwargs={"pk": self.user.pk}
         )
 
     def test_field_hidden_in_admin(self):
@@ -31,42 +28,42 @@ class JoinIpProfileFieldTests(AdminTestCase):
         response = self.client.post(
             self.test_link,
             data={
-                'username': 'Edited',
-                'rank': str(self.user.rank_id),
-                'roles': str(self.user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'join_ip': '127.0.0.1',
-                'new_password': '',
-                'signature': '',
-                'is_signature_locked': '0',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': '',
-                'signature_lock_user_message': '',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Edited",
+                "rank": str(self.user.rank_id),
+                "roles": str(self.user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "join_ip": "127.0.0.1",
+                "new_password": "",
+                "signature": "",
+                "is_signature_locked": "0",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "",
+                "signature_lock_user_message": "",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         self.reload_user()
-        self.assertNotIn('join_ip', self.user.profile_fields)
+        self.assertNotIn("join_ip", self.user.profile_fields)
 
     def test_admin_search_field(self):
         """admin users search searches this field"""
-        test_link = reverse('misago:admin:users:accounts:index')
+        test_link = reverse("misago:admin:users:accounts:index")
 
-        response = self.client.get('%s?redirected=1&profilefields=127.0.0.1' % test_link)
-        self.assertContains(response, "No users matching search criteria have been found.")
+        response = self.client.get(
+            "%s?redirected=1&profilefields=127.0.0.1" % test_link
+        )
+        self.assertContains(
+            response, "No users matching search criteria have been found."
+        )
 
     def test_field_display(self):
         """field displays on user profile"""
         test_link = reverse(
-            'misago:user-details',
-            kwargs={
-                'pk': self.user.pk,
-                'slug': self.user.slug,
-            },
+            "misago:user-details", kwargs={"pk": self.user.pk, "slug": self.user.slug}
         )
 
         response = self.client.get(test_link)
@@ -74,15 +71,11 @@ class JoinIpProfileFieldTests(AdminTestCase):
         self.assertContains(response, "Join IP")
         self.assertContains(response, "127.0.0.1")
 
-    @patch_user_acl({'can_see_users_ips': 0})
+    @patch_user_acl({"can_see_users_ips": 0})
     def test_field_hidden_no_permission(self):
         """field is hidden on user profile if user has no permission"""
         test_link = reverse(
-            'misago:user-details',
-            kwargs={
-                'pk': self.user.pk,
-                'slug': self.user.slug,
-            },
+            "misago:user-details", kwargs={"pk": self.user.pk, "slug": self.user.slug}
         )
 
         response = self.client.get(test_link)
@@ -93,11 +86,7 @@ class JoinIpProfileFieldTests(AdminTestCase):
     def test_field_hidden_ip_removed(self):
         """field is hidden on user profile if ip is removed"""
         test_link = reverse(
-            'misago:user-details',
-            kwargs={
-                'pk': self.user.pk,
-                'slug': self.user.slug,
-            },
+            "misago:user-details", kwargs={"pk": self.user.pk, "slug": self.user.slug}
         )
 
         self.user.joined_from_ip = None
@@ -110,63 +99,59 @@ class JoinIpProfileFieldTests(AdminTestCase):
 
     def test_field_display_json(self):
         """field is included in display json"""
-        test_link = reverse('misago:api:user-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-details", kwargs={"pk": self.user.pk})
 
         response = self.client.get(test_link)
         self.assertEqual(
-            response.json()['groups'],
+            response.json()["groups"],
             [
                 {
-                    'name': 'IP address',
-                    'fields': [
-                        {
-                            'fieldname': 'join_ip',
-                            'name': 'Join IP',
-                            'text': '127.0.0.1',
-                        },
+                    "name": "IP address",
+                    "fields": [
+                        {"fieldname": "join_ip", "name": "Join IP", "text": "127.0.0.1"}
                     ],
-                },
-            ]
+                }
+            ],
         )
 
-    @patch_user_acl({'can_see_users_ips': 0})
+    @patch_user_acl({"can_see_users_ips": 0})
     def test_field_hidden_no_permission_json(self):
         """field is not included in display json if user has no permission"""
-        test_link = reverse('misago:api:user-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-details", kwargs={"pk": self.user.pk})
 
         response = self.client.get(test_link)
-        self.assertEqual(response.json()['groups'], [])
+        self.assertEqual(response.json()["groups"], [])
 
     def test_field_hidden_ip_removed_json(self):
         """field is not included in display json if user ip is removed"""
-        test_link = reverse('misago:api:user-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-details", kwargs={"pk": self.user.pk})
 
         self.user.joined_from_ip = None
         self.user.save()
 
         response = self.client.get(test_link)
-        self.assertEqual(response.json()['groups'], [])
+        self.assertEqual(response.json()["groups"], [])
 
     def test_field_not_in_edit_json(self):
         """readonly field json is not returned from API"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
         response = self.client.get(test_link)
 
         found_field = None
         for group in response.json():
-            for field in group['fields']:
-                if field['fieldname'] == 'join_ip':
+            for field in group["fields"]:
+                if field["fieldname"] == "join_ip":
                     found_field = field
 
         self.assertIsNone(found_field)
 
     def test_field_is_not_editable_in_api(self):
         """readonly field can't be edited via api"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
-        response = self.client.post(test_link, data={'join_ip': '88.12.13.14'})
+        response = self.client.post(test_link, data={"join_ip": "88.12.13.14"})
         self.assertEqual(response.status_code, 200)
 
         self.reload_user()
-        self.assertNotIn('join_ip', self.user.profile_fields)
+        self.assertNotIn("join_ip", self.user.profile_fields)

+ 16 - 39
misago/users/tests/test_lists_views.py

@@ -13,42 +13,40 @@ class UsersListTestCase(AuthenticatedUserTestCase):
 
 
 class UsersListLanderTests(UsersListTestCase):
-    @patch_user_acl({'can_browse_users_list': 0})
+    @patch_user_acl({"can_browse_users_list": 0})
     def test_lander_no_permission(self):
         """lander returns 403 if user has no permission"""
-        response = self.client.get(reverse('misago:users'))
+        response = self.client.get(reverse("misago:users"))
         self.assertEqual(response.status_code, 403)
 
     def test_lander_redirect(self):
         """lander returns redirect to valid page if user has permission"""
-        response = self.client.get(reverse('misago:users'))
+        response = self.client.get(reverse("misago:users"))
         self.assertEqual(response.status_code, 302)
-        self.assertTrue(response['location'].endswith(reverse('misago:users-active-posters')))
+        self.assertTrue(
+            response["location"].endswith(reverse("misago:users-active-posters"))
+        )
 
 
 class ActivePostersTests(UsersListTestCase):
     def test_empty_active_posters_list(self):
         """empty active posters page has no showstoppers"""
-        view_link = reverse('misago:users-active-posters')
+        view_link = reverse("misago:users-active-posters")
 
         response = self.client.get(view_link)
         self.assertEqual(response.status_code, 200)
 
     def test_active_posters_list(self):
         """active posters page has no showstoppers"""
-        category = Category.objects.get(slug='first-category')
-        view_link = reverse('misago:users-active-posters')
+        category = Category.objects.get(slug="first-category")
+        view_link = reverse("misago:users-active-posters")
 
         response = self.client.get(view_link)
         self.assertEqual(response.status_code, 200)
 
         # Create 50 test users and see if errors appeared
         for i in range(50):
-            user = create_test_user(
-                'Bob%s' % i,
-                'm%s@te.com' % i,
-                posts=12345,
-            )
+            user = create_test_user("Bob%s" % i, "m%s@te.com" % i, posts=12345)
             post_thread(category, poster=user)
 
         build_active_posters_ranking()
@@ -60,18 +58,13 @@ class ActivePostersTests(UsersListTestCase):
 class UsersRankTests(UsersListTestCase):
     def test_ranks(self):
         """ranks lists are handled correctly"""
-        rank_user = create_test_user('Visible', 'visible@te.com')
+        rank_user = create_test_user("Visible", "visible@te.com")
 
         for rank in Rank.objects.iterator():
             rank_user.rank = rank
             rank_user.save()
 
-            rank_link = reverse(
-                'misago:users-rank',
-                kwargs={
-                    'slug': rank.slug,
-                },
-            )
+            rank_link = reverse("misago:users-rank", kwargs={"slug": rank.slug})
             response = self.client.get(rank_link)
 
             if rank.is_tab:
@@ -83,22 +76,14 @@ class UsersRankTests(UsersListTestCase):
     def test_disabled_users(self):
         """ranks lists excludes disabled accounts"""
         rank_user = create_test_user(
-            'Visible',
-            'visible@te.com',
-            'Pass.123',
-            is_active=False,
+            "Visible", "visible@te.com", "Pass.123", is_active=False
         )
 
         for rank in Rank.objects.iterator():
             rank_user.rank = rank
             rank_user.save()
 
-            rank_link = reverse(
-                'misago:users-rank',
-                kwargs={
-                    'slug': rank.slug,
-                },
-            )
+            rank_link = reverse("misago:users-rank", kwargs={"slug": rank.slug})
             response = self.client.get(rank_link)
 
             if rank.is_tab:
@@ -113,22 +98,14 @@ class UsersRankTests(UsersListTestCase):
         self.user.save()
 
         rank_user = create_test_user(
-            'Visible',
-            'visible@te.com',
-            'Pass.123',
-            is_active=False,
+            "Visible", "visible@te.com", "Pass.123", is_active=False
         )
 
         for rank in Rank.objects.iterator():
             rank_user.rank = rank
             rank_user.save()
 
-            rank_link = reverse(
-                'misago:users-rank',
-                kwargs={
-                    'slug': rank.slug,
-                },
-            )
+            rank_link = reverse("misago:users-rank", kwargs={"slug": rank.slug})
             response = self.client.get(rank_link)
 
             if rank.is_tab:

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

@@ -13,7 +13,7 @@ UserModel = get_user_model()
 class ListUsedProfileFieldsTests(TestCase):
     def test_no_fields_set(self):
         """utility has no showstoppers when no fields are set"""
-        UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        UserModel.objects.create_user("Bob", "bob@bob.com", "pass123")
 
         out = StringIO()
         call_command(listusedprofilefields.Command(), stdout=out)
@@ -23,24 +23,20 @@ class ListUsedProfileFieldsTests(TestCase):
 
     def test_fields_set(self):
         """utility lists number of users that have different fields set"""
-        user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
-        user.profile_fields = {'gender': 'male', 'bio': "Yup!"}
+        user = UserModel.objects.create_user("Bob", "bob@bob.com", "pass123")
+        user.profile_fields = {"gender": "male", "bio": "Yup!"}
         user.save()
 
-        user = UserModel.objects.create_user('Bob2', 'bob2@bob.com', 'pass123')
-        user.profile_fields = {'gender': 'male'}
+        user = UserModel.objects.create_user("Bob2", "bob2@bob.com", "pass123")
+        user.profile_fields = {"gender": "male"}
         user.save()
 
-        user = UserModel.objects.create_user('Bob3', 'bob3@bob.com', 'pass123')
-        user.profile_fields = {'location': ""}
+        user = UserModel.objects.create_user("Bob3", "bob3@bob.com", "pass123")
+        user.profile_fields = {"location": ""}
         user.save()
 
         out = StringIO()
         call_command(listusedprofilefields.Command(), stdout=out)
         command_output = [l.strip() for l in out.getvalue().strip().splitlines()]
 
-        self.assertEqual(command_output, [
-            "bio:      1",
-            "gender:   2",
-            "location: 1",
-        ])
+        self.assertEqual(command_output, ["bio:      1", "gender:   2", "location: 1"])

+ 19 - 25
misago/users/tests/test_mention_api.py

@@ -7,7 +7,7 @@ from misago.users.testutils import create_test_user
 
 class AuthenticateApiTests(TestCase):
     def setUp(self):
-        self.api_link = reverse('misago:api:mention-suggestions')
+        self.api_link = reverse("misago:api:mention-suggestions")
 
     def test_no_query(self):
         """api returns empty result set if no query is given"""
@@ -18,46 +18,40 @@ class AuthenticateApiTests(TestCase):
 
     def test_no_results(self):
         """api returns empty result set if no query is given"""
-        response = self.client.get(self.api_link + '?q=none')
+        response = self.client.get(self.api_link + "?q=none")
 
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), [])
 
     def test_user_search(self):
         """api searches uses"""
-        create_test_user('BobBoberson', 'bob@test.com')
+        create_test_user("BobBoberson", "bob@test.com")
 
         # 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.json(), [
-            {
-                'avatar': 'http://placekitten.com/100/100',
-                'username': 'BobBoberson',
-            }
-        ])
+        self.assertEqual(
+            response.json(),
+            [{"avatar": "http://placekitten.com/100/100", "username": "BobBoberson"}],
+        )
 
         # rought case insensitive match
-        response = self.client.get(self.api_link + '?q=bob')
+        response = self.client.get(self.api_link + "?q=bob")
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), [
-            {
-                'avatar': 'http://placekitten.com/100/100',
-                'username': 'BobBoberson',
-            }
-        ])
+        self.assertEqual(
+            response.json(),
+            [{"avatar": "http://placekitten.com/100/100", "username": "BobBoberson"}],
+        )
 
         # eager case insensitive match
-        response = self.client.get(self.api_link + '?q=b')
+        response = self.client.get(self.api_link + "?q=b")
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), [
-            {
-                'avatar': 'http://placekitten.com/100/100',
-                'username': 'BobBoberson',
-            }
-        ])
+        self.assertEqual(
+            response.json(),
+            [{"avatar": "http://placekitten.com/100/100", "username": "BobBoberson"}],
+        )
 
         # invalid match
-        response = self.client.get(self.api_link + '?q=bu')
+        response = self.client.get(self.api_link + "?q=bu")
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), [])

+ 9 - 18
misago/users/tests/test_misagoavatars_tags.py

@@ -9,21 +9,12 @@ UserModel = get_user_model()
 class TemplateTagsTests(TestCase):
     def test_user_avatar_filter(self):
         """avatar filter returns url to avatar image"""
-        user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
+        user = UserModel.objects.create_user("Bob", "bob@test.com", "pass123")
 
         user.avatars = [
-            {
-                'size': 400,
-                'url': '/avatar/400.png'
-            },
-            {
-                'size': 128,
-                'url': '/avatar/400.png'
-            },
-            {
-                'size': 30,
-                'url': '/avatar/30.png'
-            },
+            {"size": 400, "url": "/avatar/400.png"},
+            {"size": 128, "url": "/avatar/400.png"},
+            {"size": 30, "url": "/avatar/30.png"},
         ]
 
         tpl_content = """
@@ -36,9 +27,9 @@ class TemplateTagsTests(TestCase):
 """
 
         tpl = Template(tpl_content)
-        render = tpl.render(Context({'user': user})).strip().splitlines()
+        render = tpl.render(Context({"user": user})).strip().splitlines()
 
-        self.assertEqual(render[0].strip(), user.avatars[0]['url'])
-        self.assertEqual(render[1].strip(), user.avatars[1]['url'])
-        self.assertEqual(render[2].strip(), user.avatars[2]['url'])
-        self.assertEqual(render[3].strip(), user.avatars[2]['url'])
+        self.assertEqual(render[0].strip(), user.avatars[0]["url"])
+        self.assertEqual(render[1].strip(), user.avatars[1]["url"])
+        self.assertEqual(render[2].strip(), user.avatars[2]["url"])
+        self.assertEqual(render[3].strip(), user.avatars[2]["url"])

+ 28 - 22
misago/users/tests/test_namechanges.py

@@ -4,7 +4,9 @@ from django.contrib.auth import get_user_model
 from django.test import TestCase
 
 from misago.users.namechanges import (
-    get_next_available_namechange, get_left_namechanges, get_username_options
+    get_next_available_namechange,
+    get_left_namechanges,
+    get_username_options,
 )
 
 User = get_user_model()
@@ -12,69 +14,73 @@ User = get_user_model()
 
 class UsernameChangesTests(TestCase):
     def test_user_without_permission_to_change_name_has_no_changes_left(self):
-        user = User.objects.create_user('User', 'test@example.com')
+        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 = 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 = User.objects.create_user("User", "test@example.com")
         user_acl = {"name_changes_allowed": 3, "name_changes_expire": 0}
-        user.set_username('Changed')
+        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 = User.objects.create_user("User", "test@example.com")
         user_acl = {"name_changes_allowed": 3, "name_changes_expire": 5}
-    
-        user.set_username('Changed')
+
+        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 = 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 = 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 = 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 = 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 = User.objects.create_user("User", "test@example.com")
         user_acl = {"name_changes_allowed": 3, "name_changes_expire": 0}
 
         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')
+
+    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')
+        user.set_username("Changed")
 
         assert get_next_available_namechange(user, user_acl, 0) is None
 
-    def test_user_next_available_namechange_is_calculated_if_own_namechanges_expire(self):
-        user = User.objects.create_user('User', 'test@example.com')
+    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}
 
-        username_change = user.set_username('Changed')
+        username_change = user.set_username("Changed")
         next_change_on = get_next_available_namechange(user, user_acl, 0)
 
         assert next_change_on
-        assert next_change_on == username_change.changed_on + timedelta(days=1)
+        assert next_change_on == username_change.changed_on + timedelta(days=1)

+ 5 - 5
misago/users/tests/test_new_user_setup.py

@@ -5,9 +5,7 @@ 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
-)
+from misago.users.setupnewuser import set_default_subscription_options, setup_new_user
 
 User = get_user_model()
 
@@ -56,7 +54,9 @@ class NewUserSetupTests(TestCase):
             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")
+        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)
@@ -67,4 +67,4 @@ class NewUserSetupTests(TestCase):
         cache_versions = get_cache_versions()
         settings = DynamicSettings(cache_versions)
         setup_new_user(settings, user)
-        assert user.audittrail_set.exists() is False
+        assert user.audittrail_set.exists() is False

+ 18 - 32
misago/users/tests/test_options_views.py

@@ -7,18 +7,13 @@ from misago.users.testutils import AuthenticatedUserTestCase
 class OptionsViewsTests(AuthenticatedUserTestCase):
     def test_lander_view_returns_200(self):
         """/options has no show stoppers"""
-        response = self.client.get(reverse('misago:options'))
+        response = self.client.get(reverse("misago:options"))
         self.assertEqual(response.status_code, 200)
 
     def test_form_view_returns_200(self):
         """/options/some-form has no show stoppers"""
         response = self.client.get(
-            reverse(
-                'misago:options-form',
-                kwargs={
-                    'form_name': 'some-fake-form',
-                },
-            )
+            reverse("misago:options-form", kwargs={"form_name": "some-fake-form"})
         )
         self.assertEqual(response.status_code, 200)
 
@@ -26,31 +21,27 @@ class OptionsViewsTests(AuthenticatedUserTestCase):
 class ConfirmChangeEmailTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
-        link = '/api/users/%s/change-email/' % self.user.pk
+        link = "/api/users/%s/change-email/" % self.user.pk
 
         response = self.client.post(
-            link, data={'new_email': 'n3w@email.com',
-                        'password': self.USER_PASSWORD}
+            link, data={"new_email": "n3w@email.com", "password": self.USER_PASSWORD}
         )
         self.assertEqual(response.status_code, 200)
 
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
-            if line.startswith('http://'):
+            if line.startswith("http://"):
                 self.link = line.strip()
                 break
 
     def test_invalid_token(self):
         """invalid token is rejected"""
         response = self.client.get(
-            reverse(
-                'misago:options-confirm-email-change',
-                kwargs={
-                    'token': 'invalid',
-                },
-            )
+            reverse("misago:options-confirm-email-change", kwargs={"token": "invalid"})
         )
 
-        self.assertContains(response, "Change confirmation link is invalid.", status_code=400)
+        self.assertContains(
+            response, "Change confirmation link is invalid.", status_code=400
+        )
 
     def test_change_email(self):
         """valid token changes email"""
@@ -59,25 +50,21 @@ class ConfirmChangeEmailTests(AuthenticatedUserTestCase):
         self.assertContains(response, "your e-mail has been changed")
 
         self.reload_user()
-        self.assertEqual(self.user.email, 'n3w@email.com')
+        self.assertEqual(self.user.email, "n3w@email.com")
 
 
 class ConfirmChangePasswordTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
-        link = '/api/users/%s/change-password/' % self.user.pk
+        link = "/api/users/%s/change-password/" % self.user.pk
 
         response = self.client.post(
-            link,
-            data={
-                'new_password': 'n3wp4ssword',
-                'password': self.USER_PASSWORD,
-            },
+            link, data={"new_password": "n3wp4ssword", "password": self.USER_PASSWORD}
         )
         self.assertEqual(response.status_code, 200)
 
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
-            if line.startswith('http://'):
+            if line.startswith("http://"):
                 self.link = line.strip()
                 break
 
@@ -85,14 +72,13 @@ class ConfirmChangePasswordTests(AuthenticatedUserTestCase):
         """invalid token is rejected"""
         response = self.client.get(
             reverse(
-                'misago:options-confirm-password-change',
-                kwargs={
-                    'token': 'invalid',
-                },
+                "misago:options-confirm-password-change", kwargs={"token": "invalid"}
             )
         )
 
-        self.assertContains(response, "Change confirmation link is invalid.", status_code=400)
+        self.assertContains(
+            response, "Change confirmation link is invalid.", status_code=400
+        )
 
     def test_change_password(self):
         """valid token changes password"""
@@ -102,4 +88,4 @@ class ConfirmChangePasswordTests(AuthenticatedUserTestCase):
 
         self.reload_user()
         self.assertFalse(self.user.check_password(self.USER_PASSWORD))
-        self.assertTrue(self.user.check_password('n3wp4ssword'))
+        self.assertTrue(self.user.check_password("n3wp4ssword"))

+ 2 - 2
misago/users/tests/test_populateonlinetracker.py

@@ -14,7 +14,7 @@ UserModel = get_user_model()
 class PopulateOnlineTrackerTests(TestCase):
     def test_populate_user_online(self):
         """user account without online tracker gets one"""
-        test_user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
+        test_user = UserModel.objects.create_user("Bob", "bob@bob.com", "pass123")
 
         Online.objects.filter(user=test_user).delete()
         self.assertEqual(Online.objects.filter(user=test_user).count(), 0)
@@ -23,5 +23,5 @@ class PopulateOnlineTrackerTests(TestCase):
         call_command(populateonlinetracker.Command(), stdout=out)
         command_output = out.getvalue().splitlines()[0].strip()
 
-        self.assertEqual(command_output, 'Tracker entries created: 1')
+        self.assertEqual(command_output, "Tracker entries created: 1")
         self.assertEqual(Online.objects.filter(user=test_user).count(), 1)

+ 9 - 5
misago/users/tests/test_prepareuserdatadownloads.py

@@ -28,9 +28,13 @@ class PrepareUserDataDownloadsTests(AuthenticatedUserTestCase):
         self.assertTrue(updated_data_download.file)
 
         self.assertEqual(len(mail.outbox), 1)
-        self.assertEqual(mail.outbox[0].subject, "TestUser, your data download is ready")
+        self.assertEqual(
+            mail.outbox[0].subject, "TestUser, your data download is ready"
+        )
 
-        absolute_url = ''.join([settings.MISAGO_ADDRESS.rstrip('/'), updated_data_download.file.url])
+        absolute_url = "".join(
+            [settings.MISAGO_ADDRESS.rstrip("/"), updated_data_download.file.url]
+        )
         self.assertIn(absolute_url, mail.outbox[0].body)
 
     def test_skip_ready_data_download(self):
@@ -64,7 +68,7 @@ class PrepareUserDataDownloadsTests(AuthenticatedUserTestCase):
 
         updated_data_download = DataDownload.objects.get(pk=data_download.pk)
         self.assertEqual(updated_data_download.status, DataDownload.STATUS_PROCESSING)
-        
+
         self.assertEqual(len(mail.outbox), 0)
 
     def test_skip_expired_data_download(self):
@@ -81,5 +85,5 @@ class PrepareUserDataDownloadsTests(AuthenticatedUserTestCase):
 
         updated_data_download = DataDownload.objects.get(pk=data_download.pk)
         self.assertEqual(updated_data_download.status, DataDownload.STATUS_EXPIRED)
-        
-        self.assertEqual(len(mail.outbox), 0)
+
+        self.assertEqual(len(mail.outbox), 0)

+ 48 - 75
misago/users/tests/test_profile_views.py

@@ -5,9 +5,7 @@ from misago.acl.test import patch_user_acl
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.users.models import Ban
-from misago.users.testutils import (
-    AuthenticatedUserTestCase, create_test_user
-)
+from misago.users.testutils import AuthenticatedUserTestCase, create_test_user
 
 
 UserModel = get_user_model()
@@ -16,17 +14,14 @@ UserModel = get_user_model()
 class UserProfileViewsTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
-        self.link_kwargs = {'slug': self.user.slug, 'pk': self.user.pk}
+        self.link_kwargs = {"slug": self.user.slug, "pk": self.user.pk}
 
-        self.category = Category.objects.get(slug='first-category')
+        self.category = Category.objects.get(slug="first-category")
 
     def test_outdated_slugs(self):
         """user profile view redirects to valid slug"""
         response = self.client.get(
-            reverse('misago:user-posts', kwargs={
-                'slug': 'baww',
-                'pk': self.user.pk,
-            })
+            reverse("misago:user-posts", kwargs={"slug": "baww", "pk": self.user.pk})
         )
 
         self.assertEqual(response.status_code, 301)
@@ -36,7 +31,7 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
         self.user.is_staff = False
         self.user.save()
 
-        test_user = create_test_user('Tyrael', 't123@test.com')
+        test_user = create_test_user("Tyrael", "t123@test.com")
 
         test_user.is_active = False
         test_user.save()
@@ -51,21 +46,18 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 302)
 
         # 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")
 
     def test_user_posts_list(self):
         """user profile posts list has no showstoppers"""
-        link = reverse('misago:user-posts', kwargs=self.link_kwargs)
+        link = reverse("misago:user-posts", kwargs=self.link_kwargs)
         response = self.client.get(link)
 
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "You have posted no messages")
 
-        thread = testutils.post_thread(
-            category=self.category,
-            poster=self.user,
-        )
+        thread = testutils.post_thread(category=self.category, poster=self.user)
 
         response = self.client.get(link)
         self.assertEqual(response.status_code, 200)
@@ -81,16 +73,13 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
 
     def test_user_threads_list(self):
         """user profile threads list has no showstoppers"""
-        link = reverse('misago:user-threads', kwargs=self.link_kwargs)
+        link = reverse("misago:user-threads", kwargs=self.link_kwargs)
 
         response = self.client.get(link)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "You have no started threads.")
 
-        thread = testutils.post_thread(
-            category=self.category,
-            poster=self.user,
-        )
+        thread = testutils.post_thread(category=self.category, poster=self.user)
 
         response = self.client.get(link)
         self.assertEqual(response.status_code, 200)
@@ -106,13 +95,12 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
 
     def test_user_followers(self):
         """user profile followers list has no showstoppers"""
-        response = self.client.get(reverse(
-            'misago:user-followers',
-            kwargs=self.link_kwargs,
-        ))
+        response = self.client.get(
+            reverse("misago:user-followers", kwargs=self.link_kwargs)
+        )
 
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'You have no followers.')
+        self.assertContains(response, "You have no followers.")
 
         followers = []
         for i in range(10):
@@ -120,23 +108,21 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
             followers.append(UserModel.objects.create_user(*user_data))
             self.user.followed_by.add(followers[-1])
 
-        response = self.client.get(reverse(
-            'misago:user-followers',
-            kwargs=self.link_kwargs,
-        ))
+        response = self.client.get(
+            reverse("misago:user-followers", kwargs=self.link_kwargs)
+        )
         self.assertEqual(response.status_code, 200)
         for i in range(10):
             self.assertContains(response, "Follower%s" % i)
 
     def test_user_follows(self):
         """user profile follows list has no showstoppers"""
-        response = self.client.get(reverse(
-            'misago:user-follows',
-            kwargs=self.link_kwargs,
-        ))
+        response = self.client.get(
+            reverse("misago:user-follows", kwargs=self.link_kwargs)
+        )
 
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'You are not following any users.')
+        self.assertContains(response, "You are not following any users.")
 
         followers = []
         for i in range(10):
@@ -144,41 +130,37 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
             followers.append(UserModel.objects.create_user(*user_data))
             followers[-1].followed_by.add(self.user)
 
-        response = self.client.get(reverse(
-            'misago:user-follows',
-            kwargs=self.link_kwargs,
-        ))
+        response = self.client.get(
+            reverse("misago:user-follows", kwargs=self.link_kwargs)
+        )
         self.assertEqual(response.status_code, 200)
         for i in range(10):
             self.assertContains(response, "Follower%s" % i)
 
     def test_user_details(self):
         """user details page has no showstoppers"""
-        response = self.client.get(reverse(
-            'misago:user-details',
-            kwargs=self.link_kwargs,
-        ))
+        response = self.client.get(
+            reverse("misago:user-details", kwargs=self.link_kwargs)
+        )
 
         self.assertEqual(response.status_code, 200)
 
     def test_username_history_list(self):
         """user name changes history list has no showstoppers"""
-        response = self.client.get(reverse(
-            'misago:username-history',
-            kwargs=self.link_kwargs,
-        ))
+        response = self.client.get(
+            reverse("misago:username-history", kwargs=self.link_kwargs)
+        )
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'Your username was never changed.')
+        self.assertContains(response, "Your username was never changed.")
 
-        self.user.set_username('RenamedAdmin')
+        self.user.set_username("RenamedAdmin")
         self.user.save()
-        self.user.set_username('TestUser')
+        self.user.set_username("TestUser")
         self.user.save()
 
-        response = self.client.get(reverse(
-            'misago:username-history',
-            kwargs=self.link_kwargs,
-        ))
+        response = self.client.get(
+            reverse("misago:username-history", kwargs=self.link_kwargs)
+        )
 
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "TestUser")
@@ -186,21 +168,15 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
 
     def test_user_ban_details(self):
         """user ban details page has no showstoppers"""
-        test_user = create_test_user("Bob", "bob@bob.com", 'pass.123')
-        link_kwargs = {'slug': test_user.slug, 'pk': test_user.pk}
-
-        with patch_user_acl({'can_see_ban_details': 0}):
-            response = self.client.get(reverse(
-                'misago:user-ban',
-                kwargs=link_kwargs,
-            ))
+        test_user = create_test_user("Bob", "bob@bob.com", "pass.123")
+        link_kwargs = {"slug": test_user.slug, "pk": test_user.pk}
+
+        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)
 
-        with patch_user_acl({'can_see_ban_details': 1}):
-            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, 404)
 
         Ban.objects.create(
@@ -208,15 +184,12 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
             user_message="User m3ss4ge.",
             staff_message="Staff m3ss4ge.",
             is_checked=True,
-        )      
+        )
         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,
-            ))
+        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.assertContains(response, "User m3ss4ge")
+            self.assertContains(response, "Staff m3ss4ge")

+ 60 - 57
misago/users/tests/test_profilefields.py

@@ -17,12 +17,7 @@ class ProfileFieldsLoadTests(TestCase):
 
     def test_empty_group(self):
         """profile fields util handles empty group"""
-        profilefields = ProfileFields([
-            {
-                'name': "Test",
-                'fields': [],
-            },
-        ])
+        profilefields = ProfileFields([{"name": "Test", "fields": []}])
 
         profilefields.load()
 
@@ -30,14 +25,16 @@ class ProfileFieldsLoadTests(TestCase):
 
     def test_field_defines_fieldname(self):
         """fields need to define fieldname"""
-        profilefields = ProfileFields([
-            {
-                'name': "Test",
-                'fields': [
-                    'misago.users.tests.testfiles.profilefields.NofieldnameField',
-                ],
-            },
-        ])
+        profilefields = ProfileFields(
+            [
+                {
+                    "name": "Test",
+                    "fields": [
+                        "misago.users.tests.testfiles.profilefields.NofieldnameField"
+                    ],
+                }
+            ]
+        )
 
         with self.assertRaises(ValueError):
             profilefields.load()
@@ -47,25 +44,25 @@ class ProfileFieldsLoadTests(TestCase):
         except ValueError as e:
             error = str(e)
 
-            self.assertIn('misago.users.tests.testfiles.profilefields.NofieldnameField', error)
-            self.assertIn('profile field has to specify fieldname attribute', error)
+            self.assertIn(
+                "misago.users.tests.testfiles.profilefields.NofieldnameField", error
+            )
+            self.assertIn("profile field has to specify fieldname attribute", error)
 
     def test_detect_repeated_imports(self):
         """fields can't be specified multiple times"""
-        profilefields = ProfileFields([
-            {
-                'name': "Test",
-                'fields': [
-                    'misago.users.profilefields.default.TwitterHandleField',
-                ],
-            },
-            {
-                'name': "Other test",
-                'fields': [
-                    'misago.users.profilefields.default.TwitterHandleField',
-                ],
-            },
-        ])
+        profilefields = ProfileFields(
+            [
+                {
+                    "name": "Test",
+                    "fields": ["misago.users.profilefields.default.TwitterHandleField"],
+                },
+                {
+                    "name": "Other test",
+                    "fields": ["misago.users.profilefields.default.TwitterHandleField"],
+                },
+            ]
+        )
 
         with self.assertRaises(ValueError):
             profilefields.load()
@@ -75,25 +72,29 @@ class ProfileFieldsLoadTests(TestCase):
         except ValueError as e:
             error = str(e)
 
-            self.assertIn('misago.users.profilefields.default.TwitterHandleField', error)
-            self.assertIn('profile field has been specified twice', error)
+            self.assertIn(
+                "misago.users.profilefields.default.TwitterHandleField", error
+            )
+            self.assertIn("profile field has been specified twice", error)
 
     def test_detect_repeated_fieldnames(self):
         """fields can't reuse other field's fieldnames"""
-        profilefields = ProfileFields([
-            {
-                'name': "Test",
-                'fields': [
-                    'misago.users.tests.testfiles.profilefields.FieldnameField',
-                ],
-            },
-            {
-                'name': "Other test",
-                'fields': [
-                    'misago.users.tests.testfiles.profilefields.RepeatedFieldnameField',
-                ],
-            },
-        ])
+        profilefields = ProfileFields(
+            [
+                {
+                    "name": "Test",
+                    "fields": [
+                        "misago.users.tests.testfiles.profilefields.FieldnameField"
+                    ],
+                },
+                {
+                    "name": "Other test",
+                    "fields": [
+                        "misago.users.tests.testfiles.profilefields.RepeatedFieldnameField"
+                    ],
+                },
+            ]
+        )
 
         with self.assertRaises(ValueError):
             profilefields.load()
@@ -103,20 +104,22 @@ class ProfileFieldsLoadTests(TestCase):
         except ValueError as e:
             error = str(e)
 
-            self.assertIn('misago.users.tests.testfiles.profilefields.FieldnameField', error)
-            self.assertIn('misago.users.tests.testfiles.profilefields.RepeatedFieldnameField', error)
-            self.assertIn('field defines fieldname "hello" that is already in use by the', error)
+            self.assertIn(
+                "misago.users.tests.testfiles.profilefields.FieldnameField", error
+            )
+            self.assertIn(
+                "misago.users.tests.testfiles.profilefields.RepeatedFieldnameField",
+                error,
+            )
+            self.assertIn(
+                'field defines fieldname "hello" that is already in use by the', error
+            )
 
     def test_field_correct_field(self):
         """util loads correct field"""
-        field_path = 'misago.users.profilefields.default.RealNameField'
-
-        profilefields = ProfileFields([
-            {
-                'name': "Test",
-                'fields': [field_path],
-            },
-        ])
+        field_path = "misago.users.profilefields.default.RealNameField"
+
+        profilefields = ProfileFields([{"name": "Test", "fields": [field_path]}])
 
         profilefields.load()
 

+ 124 - 188
misago/users/tests/test_rankadmin_views.py

@@ -10,102 +10,89 @@ from misago.users.models import Rank
 class RankAdminViewsTests(AdminTestCase):
     def test_link_registered(self):
         """admin nav contains ranks link"""
-        response = self.client.get(reverse('misago:admin:users:accounts:index'))
+        response = self.client.get(reverse("misago:admin:users:accounts:index"))
 
-        response = self.client.get(response['location'])
-        self.assertContains(response, reverse('misago:admin:users:ranks:index'))
+        response = self.client.get(response["location"])
+        self.assertContains(response, reverse("misago:admin:users:ranks:index"))
 
     def test_list_view(self):
         """ranks list view returns 200"""
-        response = self.client.get(reverse('misago:admin:users:ranks:index'))
+        response = self.client.get(reverse("misago:admin:users:ranks:index"))
 
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'Team')
+        self.assertContains(response, "Team")
 
     def test_new_view(self):
         """new rank view has no showstoppers"""
-        test_role_a = Role.objects.create(name='Test Role A')
-        test_role_b = Role.objects.create(name='Test Role B')
-        test_role_c = Role.objects.create(name='Test Role C')
+        test_role_a = Role.objects.create(name="Test Role A")
+        test_role_b = Role.objects.create(name="Test Role B")
+        test_role_c = Role.objects.create(name="Test Role C")
 
-        response = self.client.get(reverse('misago:admin:users:ranks:new'))
+        response = self.client.get(reverse("misago:admin:users:ranks:new"))
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
-            reverse('misago:admin:users:ranks:new'),
+            reverse("misago:admin:users:ranks:new"),
             data={
-                'name': 'Test Rank',
-                'description': 'Lorem ipsum dolor met',
-                'title': 'Test Title',
-                'style': 'test',
-                'is_tab': '1',
-                'roles': [test_role_a.pk, test_role_c.pk],
+                "name": "Test Rank",
+                "description": "Lorem ipsum dolor met",
+                "title": "Test Title",
+                "style": "test",
+                "is_tab": "1",
+                "roles": [test_role_a.pk, test_role_c.pk],
             },
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:users:ranks:index'))
+        response = self.client.get(reverse("misago:admin:users:ranks:index"))
         self.assertEqual(response.status_code, 200)
-        self.assertContains(response, 'Test Rank')
-        self.assertContains(response, 'Test Title')
+        self.assertContains(response, "Test Rank")
+        self.assertContains(response, "Test Title")
 
-        test_rank = Rank.objects.get(slug='test-rank')
+        test_rank = Rank.objects.get(slug="test-rank")
         self.assertIn(test_role_a, test_rank.roles.all())
         self.assertIn(test_role_c, test_rank.roles.all())
         self.assertTrue(test_role_b not in test_rank.roles.all())
 
     def test_edit_view(self):
         """edit rank view has no showstoppers"""
-        test_role_a = Role.objects.create(name='Test Role A')
-        test_role_b = Role.objects.create(name='Test Role B')
-        test_role_c = Role.objects.create(name='Test Role C')
+        test_role_a = Role.objects.create(name="Test Role A")
+        test_role_b = Role.objects.create(name="Test Role B")
+        test_role_c = Role.objects.create(name="Test Role C")
 
         self.client.post(
-            reverse('misago:admin:users:ranks:new'),
+            reverse("misago:admin:users:ranks:new"),
             data={
-                'name': 'Test Rank',
-                'description': 'Lorem ipsum dolor met',
-                'title': 'Test Title',
-                'style': 'test',
-                'is_tab': '1',
-                'roles': [test_role_a.pk, test_role_c.pk],
+                "name": "Test Rank",
+                "description": "Lorem ipsum dolor met",
+                "title": "Test Title",
+                "style": "test",
+                "is_tab": "1",
+                "roles": [test_role_a.pk, test_role_c.pk],
             },
         )
 
-        test_rank = Rank.objects.get(slug='test-rank')
+        test_rank = Rank.objects.get(slug="test-rank")
 
         response = self.client.get(
-            reverse(
-                'misago:admin:users:ranks:edit',
-                kwargs={
-                    'pk': test_rank.pk,
-                },
-            )
+            reverse("misago:admin:users:ranks:edit", kwargs={"pk": test_rank.pk})
         )
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, test_rank.name)
         self.assertContains(response, test_rank.title)
 
         response = self.client.post(
-            reverse(
-                'misago:admin:users:ranks:edit',
-                kwargs={
-                    'pk': test_rank.pk,
-                },
-            ),
-            data={
-                'name': 'Top Lel',
-                'roles': [test_role_b.pk],
-            },
+            reverse("misago:admin:users:ranks:edit", kwargs={"pk": test_rank.pk}),
+            data={"name": "Top Lel", "roles": [test_role_b.pk]},
         )
         self.assertEqual(response.status_code, 302)
 
-        test_rank = Rank.objects.get(slug='top-lel')
-        response = self.client.get(reverse('misago:admin:users:ranks:index'))
+        test_rank = Rank.objects.get(slug="top-lel")
+        response = self.client.get(reverse("misago:admin:users:ranks:index"))
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, test_rank.name)
-        self.assertTrue('Test Rank' not in test_rank.roles.all())
-        self.assertTrue('Test Title' not in test_rank.roles.all())
+        self.assertTrue("Test Rank" not in test_rank.roles.all())
+        self.assertTrue("Test Title" not in test_rank.roles.all())
 
         self.assertIn(test_role_b, test_rank.roles.all())
         self.assertTrue(test_role_a not in test_rank.roles.all())
@@ -113,253 +100,202 @@ class RankAdminViewsTests(AdminTestCase):
 
     def test_editing_rank_invalidates_acl_cache(self):
         self.client.post(
-            reverse('misago:admin:users:ranks:new'),
+            reverse("misago:admin:users:ranks:new"),
             data={
-                'name': 'Test Rank',
-                'description': 'Lorem ipsum dolor met',
-                'title': 'Test Title',
-                'style': 'test',
-                'is_tab': '1',
+                "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')
-        
+        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],
-                },
+                reverse("misago:admin:users:ranks:edit", kwargs={"pk": test_rank.pk}),
+                data={"name": "Top Lel", "roles": [test_role_b.pk]},
             )
 
     def test_default_view(self):
         """default rank view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:users:ranks:new'),
+            reverse("misago:admin:users:ranks:new"),
             data={
-                'name': 'Test Rank',
-                'description': 'Lorem ipsum dolor met',
-                'title': 'Test Title',
-                'style': 'test',
-                'is_tab': '1',
+                "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_rank = Rank.objects.get(slug="test-rank")
 
         response = self.client.post(
-            reverse(
-                'misago:admin:users:ranks:default',
-                kwargs={
-                    'pk': test_rank.pk,
-                },
-            )
+            reverse("misago:admin:users:ranks:default", kwargs={"pk": test_rank.pk})
         )
         self.assertEqual(response.status_code, 302)
 
-        test_rank = Rank.objects.get(slug='test-rank')
+        test_rank = Rank.objects.get(slug="test-rank")
         self.assertTrue(test_rank.is_default)
 
     def test_move_up_view(self):
         """move rank up view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:users:ranks:new'),
+            reverse("misago:admin:users:ranks:new"),
             data={
-                'name': 'Test Rank',
-                'description': 'Lorem ipsum dolor met',
-                'title': 'Test Title',
-                'style': 'test',
-                'is_tab': '1',
+                "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_rank = Rank.objects.get(slug="test-rank")
 
         response = self.client.post(
-            reverse(
-                'misago:admin:users:ranks:up',
-                kwargs={
-                    'pk': test_rank.pk,
-                },
-            )
+            reverse("misago:admin:users:ranks:up", kwargs={"pk": test_rank.pk})
         )
         self.assertEqual(response.status_code, 302)
 
-        changed_rank = Rank.objects.get(slug='test-rank')
+        changed_rank = Rank.objects.get(slug="test-rank")
         self.assertEqual(changed_rank.order + 1, test_rank.order)
 
     def test_move_down_view(self):
         """move rank down view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:users:ranks:new'),
+            reverse("misago:admin:users:ranks:new"),
             data={
-                'name': 'Test Rank',
-                'description': 'Lorem ipsum dolor met',
-                'title': 'Test Title',
-                'style': 'test',
-                'is_tab': '1',
+                "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_rank = Rank.objects.get(slug="test-rank")
 
         # Move rank up
         response = self.client.post(
-            reverse(
-                'misago:admin:users:ranks:up',
-                kwargs={
-                    'pk': test_rank.pk,
-                },
-            )
+            reverse("misago:admin:users:ranks:up", kwargs={"pk": test_rank.pk})
         )
 
         response = self.client.post(
-            reverse(
-                'misago:admin:users:ranks:down',
-                kwargs={
-                    'pk': test_rank.pk,
-                },
-            )
+            reverse("misago:admin:users:ranks:down", kwargs={"pk": test_rank.pk})
         )
         self.assertEqual(response.status_code, 302)
 
         # Test move down
-        changed_rank = Rank.objects.get(slug='test-rank')
+        changed_rank = Rank.objects.get(slug="test-rank")
         self.assertEqual(changed_rank.order, test_rank.order)
 
     def test_users_view(self):
         """users with this rank view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:users:ranks:new'),
+            reverse("misago:admin:users:ranks:new"),
             data={
-                'name': 'Test Rank',
-                'description': 'Lorem ipsum dolor met',
-                'title': 'Test Title',
-                'style': 'test',
-                'is_tab': '1',
+                "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_rank = Rank.objects.get(slug="test-rank")
 
         response = self.client.get(
-            reverse(
-                'misago:admin:users:ranks:users',
-                kwargs={
-                    'pk': test_rank.pk,
-                },
-            )
+            reverse("misago:admin:users:ranks:users", kwargs={"pk": test_rank.pk})
         )
         self.assertEqual(response.status_code, 302)
 
     def test_delete_view(self):
         """delete rank view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:users:ranks:new'),
+            reverse("misago:admin:users:ranks:new"),
             data={
-                'name': 'Test Rank',
-                'description': 'Lorem ipsum dolor met',
-                'title': 'Test Title',
-                'style': 'test',
-                'is_tab': '1',
+                "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_rank = Rank.objects.get(slug="test-rank")
 
         response = self.client.post(
-            reverse(
-                'misago:admin:users:ranks:delete',
-                kwargs={
-                    'pk': test_rank.pk,
-                },
-            )
+            reverse("misago:admin:users:ranks:delete", kwargs={"pk": test_rank.pk})
         )
         self.assertEqual(response.status_code, 302)
 
-        self.client.get(reverse('misago:admin:users:ranks:index'))
-        response = self.client.get(reverse('misago:admin:users:ranks:index'))
+        self.client.get(reverse("misago:admin:users:ranks:index"))
+        response = self.client.get(reverse("misago:admin:users:ranks:index"))
         self.assertEqual(response.status_code, 200)
 
         self.assertNotContains(response, test_rank.name)
         self.assertNotContains(response, test_rank.title)
-        
+
     def test_deleting_rank_invalidates_acl_cache(self):
         self.client.post(
-            reverse('misago:admin:users:ranks:new'),
+            reverse("misago:admin:users:ranks:new"),
             data={
-                'name': 'Test Rank',
-                'description': 'Lorem ipsum dolor met',
-                'title': 'Test Title',
-                'style': 'test',
-                'is_tab': '1',
+                "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_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,
-                    },
-                )
+                reverse("misago:admin:users:ranks:delete", kwargs={"pk": test_rank.pk})
             )
 
     def test_uniquess(self):
         """rank slug uniqueness is enforced by admin forms"""
-        test_role_a = Role.objects.create(name='Test Role A')
+        test_role_a = Role.objects.create(name="Test Role A")
 
         response = self.client.post(
-            reverse('misago:admin:users:ranks:new'),
+            reverse("misago:admin:users:ranks:new"),
             data={
-                'name': 'Members',
-                'description': 'Colliding rank',
-                'title': 'Test Title',
-                'style': 'test',
-                'is_tab': '1',
-                'roles': [test_role_a.pk],
-            }
+                "name": "Members",
+                "description": "Colliding rank",
+                "title": "Test Title",
+                "style": "test",
+                "is_tab": "1",
+                "roles": [test_role_a.pk],
+            },
         )
 
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "This name collides with other rank.")
 
         self.client.post(
-            reverse('misago:admin:users:ranks:new'),
+            reverse("misago:admin:users:ranks:new"),
             data={
-                'name': 'Test rank',
-                'description': 'Colliding rank',
-                'title': 'Test Title',
-                'style': 'test',
-                'is_tab': '1',
-                'roles': [test_role_a.pk],
-            }
+                "name": "Test rank",
+                "description": "Colliding rank",
+                "title": "Test Title",
+                "style": "test",
+                "is_tab": "1",
+                "roles": [test_role_a.pk],
+            },
         )
 
-        test_rank = Rank.objects.get(slug='test-rank')
+        test_rank = Rank.objects.get(slug="test-rank")
 
         response = self.client.post(
-            reverse(
-                'misago:admin:users:ranks:edit',
-                kwargs={
-                    'pk': test_rank.pk,
-                },
-            ),
-            data={
-                'name': 'Members',
-                'roles': [test_role_a.pk],
-            },
+            reverse("misago:admin:users:ranks:edit", kwargs={"pk": test_rank.pk}),
+            data={"name": "Members", "roles": [test_role_a.pk]},
         )
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "This name collides with other rank.")

+ 6 - 6
misago/users/tests/test_realip_middleware.py

@@ -5,23 +5,23 @@ from misago.users.middleware import RealIPMiddleware
 
 class MockRequest(object):
     def __init__(self, addr, forwarded_for=None):
-        self.META = {'REMOTE_ADDR': addr}
+        self.META = {"REMOTE_ADDR": addr}
 
         if forwarded_for:
-            self.META['HTTP_X_FORWARDED_FOR'] = forwarded_for
+            self.META["HTTP_X_FORWARDED_FOR"] = forwarded_for
 
 
 class RealIPMiddlewareTests(TestCase):
     def test_middleware_sets_ip_from_remote_add(self):
         """Middleware sets ip from remote_addr header"""
-        request = MockRequest('83.42.13.77')
+        request = MockRequest("83.42.13.77")
         RealIPMiddleware().process_request(request)
 
-        self.assertEqual(request.user_ip, request.META['REMOTE_ADDR'])
+        self.assertEqual(request.user_ip, request.META["REMOTE_ADDR"])
 
     def test_middleware_sets_ip_from_forwarded_for(self):
         """Middleware sets ip from forwarded_for header"""
-        request = MockRequest('127.0.0.1', '83.42.13.77')
+        request = MockRequest("127.0.0.1", "83.42.13.77")
         RealIPMiddleware().process_request(request)
 
-        self.assertEqual(request.user_ip, request.META['HTTP_X_FORWARDED_FOR'])
+        self.assertEqual(request.user_ip, request.META["HTTP_X_FORWARDED_FOR"])

+ 15 - 10
misago/users/tests/test_removeoldips.py

@@ -11,24 +11,28 @@ from misago.users.management.commands import removeoldips
 
 UserModel = get_user_model()
 
-USER_IP = '31.41.51.65'
+USER_IP = "31.41.51.65"
 
 
 class RemoveOldIpsTests(TestCase):
     def test_removeoldips_recent_user(self):
         """command is not removing user's IP if its recent"""
-        user = UserModel.objects.create_user('Bob', 'bob@bob.com', joined_from_ip=USER_IP)
-        
+        user = UserModel.objects.create_user(
+            "Bob", "bob@bob.com", joined_from_ip=USER_IP
+        )
+
         out = StringIO()
         call_command(removeoldips.Command(), stdout=out)
 
         user_joined_from_ip = UserModel.objects.get(pk=user.pk).joined_from_ip
         self.assertEqual(user_joined_from_ip, USER_IP)
-    
+
     def test_removeoldips_old_user(self):
         """command removes user's IP if its old"""
         joined_on_past = timezone.now() - timedelta(days=50)
-        user = UserModel.objects.create_user('Bob1', 'bob1@bob.com', joined_from_ip=USER_IP)
+        user = UserModel.objects.create_user(
+            "Bob1", "bob1@bob.com", joined_from_ip=USER_IP
+        )
         user.joined_on = joined_on_past
         user.save()
 
@@ -41,14 +45,15 @@ class RemoveOldIpsTests(TestCase):
     @override_settings(MISAGO_IP_STORE_TIME=None)
     def test_not_removing_user_ip(self):
         """command is not removing user's IP if removing is disabled"""
-        user = UserModel.objects.create_user('Bob1', 'bob1@bob.com', joined_from_ip=USER_IP)
-        
+        user = UserModel.objects.create_user(
+            "Bob1", "bob1@bob.com", joined_from_ip=USER_IP
+        )
+
         out = StringIO()
         call_command(removeoldips.Command(), stdout=out)
         command_output = out.getvalue().splitlines()[0].strip()
-        
+
         self.assertEqual(command_output, "Old IP removal is disabled.")
-        
+
         user_joined_from_ip = UserModel.objects.get(pk=user.pk).joined_from_ip
         self.assertEqual(user_joined_from_ip, USER_IP)
-

+ 8 - 30
misago/users/tests/test_rest_permissions.py

@@ -11,10 +11,7 @@ class UnbannedOnlyTests(UserTestCase):
     def test_api_allows_guests(self):
         """policy allows guests"""
         response = self.client.post(
-            reverse('misago:api:send-password-form'),
-            data={
-                'email': self.user.email,
-            },
+            reverse("misago:api:send-password-form"), data={"email": self.user.email}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -23,26 +20,18 @@ class UnbannedOnlyTests(UserTestCase):
         self.login_user(self.user)
 
         response = self.client.post(
-            reverse('misago:api:send-password-form'),
-            data={
-                'email': self.user.email,
-            },
+            reverse("misago:api:send-password-form"), data={"email": self.user.email}
         )
         self.assertEqual(response.status_code, 200)
 
     def test_api_blocks_banned(self):
         """policy blocked banned ip"""
         Ban.objects.create(
-            check_type=Ban.IP,
-            banned_value='127.*',
-            user_message='Ya got banned!',
+            check_type=Ban.IP, banned_value="127.*", user_message="Ya got banned!"
         )
 
         response = self.client.post(
-            reverse('misago:api:send-password-form'),
-            data={
-                'email': self.user.email,
-            },
+            reverse("misago:api:send-password-form"), data={"email": self.user.email}
         )
         self.assertEqual(response.status_code, 403)
 
@@ -57,10 +46,7 @@ class UnbannedAnonOnlyTests(UserTestCase):
         self.user.save()
 
         response = self.client.post(
-            reverse('misago:api:send-activation'),
-            data={
-                'email': self.user.email,
-            },
+            reverse("misago:api:send-activation"), data={"email": self.user.email}
         )
         self.assertEqual(response.status_code, 200)
 
@@ -69,25 +55,17 @@ class UnbannedAnonOnlyTests(UserTestCase):
         self.login_user(self.user)
 
         response = self.client.post(
-            reverse('misago:api:send-activation'),
-            data={
-                'email': self.user.email,
-            },
+            reverse("misago:api:send-activation"), data={"email": self.user.email}
         )
         self.assertEqual(response.status_code, 403)
 
     def test_api_blocks_banned(self):
         """policy blocked banned ip"""
         Ban.objects.create(
-            check_type=Ban.IP,
-            banned_value='127.*',
-            user_message='Ya got banned!',
+            check_type=Ban.IP, banned_value="127.*", user_message="Ya got banned!"
         )
 
         response = self.client.post(
-            reverse('misago:api:send-activation'),
-            data={
-                'email': self.user.email,
-            },
+            reverse("misago:api:send-activation"), data={"email": self.user.email}
         )
         self.assertEqual(response.status_code, 403)

+ 40 - 45
misago/users/tests/test_search.py

@@ -12,14 +12,14 @@ class SearchApiTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.api_link = reverse('misago:api:search')
+        self.api_link = reverse("misago:api:search")
 
-    @patch_user_acl({'can_search_users': 0})
+    @patch_user_acl({"can_search_users": 0})
     def test_no_permission(self):
         """api respects permission to search users"""
         response = self.client.get(self.api_link)
         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()])
 
     def test_no_query(self):
         """api handles no search query"""
@@ -27,112 +27,109 @@ class SearchApiTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertIn('users', [p['id'] for p in response_json])
+        self.assertIn("users", [p["id"] for p in response_json])
 
         for provider in response_json:
-            if provider['id'] == 'users':
-                self.assertEqual(provider['results']['results'], [])
+            if provider["id"] == "users":
+                self.assertEqual(provider["results"]["results"], [])
 
     def test_empty_query(self):
         """api handles empty search query"""
-        response = self.client.get('%s?q=' % self.api_link)
+        response = self.client.get("%s?q=" % self.api_link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertIn('users', [p['id'] for p in response_json])
+        self.assertIn("users", [p["id"] for p in response_json])
 
         for provider in response_json:
-            if provider['id'] == 'users':
-                self.assertEqual(provider['results']['results'], [])
+            if provider["id"] == "users":
+                self.assertEqual(provider["results"]["results"], [])
 
     def test_short_query(self):
         """api handles short search query"""
-        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)
 
         response_json = response.json()
-        self.assertIn('users', [p['id'] for p in response_json])
+        self.assertIn("users", [p["id"] for p in response_json])
 
         for provider in response_json:
-            if provider['id'] == 'users':
-                results = provider['results']['results']
+            if provider["id"] == "users":
+                results = provider["results"]["results"]
                 self.assertEqual(len(results), 1)
-                self.assertEqual(results[0]['id'], self.user.id)
+                self.assertEqual(results[0]["id"], self.user.id)
 
     def test_exact_match(self):
         """api handles exact search query"""
-        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)
 
         response_json = response.json()
-        self.assertIn('users', [p['id'] for p in response_json])
+        self.assertIn("users", [p["id"] for p in response_json])
 
         for provider in response_json:
-            if provider['id'] == 'users':
-                results = provider['results']['results']
+            if provider["id"] == "users":
+                results = provider["results"]["results"]
                 self.assertEqual(len(results), 1)
-                self.assertEqual(results[0]['id'], self.user.id)
+                self.assertEqual(results[0]["id"], self.user.id)
 
     def test_tail_match(self):
         """api handles last three chars match query"""
-        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)
 
         response_json = response.json()
-        self.assertIn('users', [p['id'] for p in response_json])
+        self.assertIn("users", [p["id"] for p in response_json])
 
         for provider in response_json:
-            if provider['id'] == 'users':
-                results = provider['results']['results']
+            if provider["id"] == "users":
+                results = provider["results"]["results"]
                 self.assertEqual(len(results), 1)
-                self.assertEqual(results[0]['id'], self.user.id)
+                self.assertEqual(results[0]["id"], self.user.id)
 
     def test_no_match(self):
         """api handles no match"""
-        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)
 
         response_json = response.json()
-        self.assertIn('users', [p['id'] for p in response_json])
+        self.assertIn("users", [p["id"] for p in response_json])
 
         for provider in response_json:
-            if provider['id'] == 'users':
-                self.assertEqual(provider['results']['results'], [])
+            if provider["id"] == "users":
+                self.assertEqual(provider["results"]["results"], [])
 
     def test_search_disabled(self):
         """api respects disabled users visibility"""
         disabled_user = UserModel.objects.create_user(
-            'DisabledUser',
-            'visible@te.com',
-            'Pass.123',
-            is_active=False,
+            "DisabledUser", "visible@te.com", "Pass.123", is_active=False
         )
 
-        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)
 
         response_json = response.json()
-        self.assertIn('users', [p['id'] for p in response_json])
+        self.assertIn("users", [p["id"] for p in response_json])
 
         for provider in response_json:
-            if provider['id'] == 'users':
-                self.assertEqual(provider['results']['results'], [])
+            if provider["id"] == "users":
+                self.assertEqual(provider["results"]["results"], [])
 
         # user shows in searchech performed by staff
         self.user.is_staff = True
         self.user.save()
 
-        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)
 
         response_json = response.json()
-        self.assertIn('users', [p['id'] for p in response_json])
+        self.assertIn("users", [p["id"] for p in response_json])
 
         for provider in response_json:
-            if provider['id'] == 'users':
-                results = provider['results']['results']
+            if provider["id"] == "users":
+                results = provider["results"]["results"]
                 self.assertEqual(len(results), 1)
-                self.assertEqual(results[0]['id'], disabled_user.id)
+                self.assertEqual(results[0]["id"], disabled_user.id)
 
 
 class SearchProviderApiTests(SearchApiTests):
@@ -140,7 +137,5 @@ class SearchProviderApiTests(SearchApiTests):
         super().setUp()
 
         self.api_link = reverse(
-            'misago:api:search', kwargs={
-                'search_provider': 'users',
-            }
+            "misago:api:search", kwargs={"search_provider": "users"}
         )

+ 5 - 5
misago/users/tests/test_signatures.py

@@ -12,15 +12,15 @@ cache_versions = get_cache_versions()
 
 
 class MockRequest(object):
-    scheme = 'http'
+    scheme = "http"
 
     def get_host(self):
-        return '127.0.0.1:8000'
+        return "127.0.0.1:8000"
 
 
 class UserSignatureTests(TestCase):
     def test_user_signature_and_valid_checksum_is_set(self):
-        user = User.objects.create_user('Bob', 'bob@bob.com')
+        user = User.objects.create_user("Bob", "bob@bob.com")
         user.signature = "Test"
         user.signature_parsed = "Test"
         user.signature_checksum = "Test"
@@ -37,7 +37,7 @@ class UserSignatureTests(TestCase):
         assert signatures.is_user_signature_valid(user)
 
     def test_user_signature_is_cleared(self):
-        user = User.objects.create_user('Bob', 'bob@bob.com')
+        user = User.objects.create_user("Bob", "bob@bob.com")
         user.signature = "Test"
         user.signature_parsed = "Test"
         user.signature_checksum = "Test"
@@ -53,7 +53,7 @@ class UserSignatureTests(TestCase):
         assert not user.signature_checksum
 
     def test_signature_validity_check_fails_for_incorrect_signature_checksum(self):
-        user = User.objects.create_user('Bob', 'bob@bob.com')
+        user = User.objects.create_user("Bob", "bob@bob.com")
         user.signature = "Test"
         user.signature_parsed = "Test"
         user.signature_checksum = "Test"

+ 215 - 257
misago/users/tests/test_social_pipeline.py

@@ -16,8 +16,13 @@ from misago.legal.models import Agreement
 
 from misago.users.models import AnonymousUser, Ban, BanCache
 from misago.users.social.pipeline import (
-    associate_by_email, create_user, create_user_with_form, get_username, require_activation,
-    validate_ip_not_banned, validate_user_not_banned
+    associate_by_email,
+    create_user,
+    create_user_with_form,
+    get_username,
+    require_activation,
+    validate_ip_not_banned,
+    validate_user_not_banned,
 )
 from misago.users.testutils import UserTestCase
 
@@ -25,12 +30,14 @@ from misago.users.testutils import UserTestCase
 UserModel = get_user_model()
 
 
-def create_request(user_ip='0.0.0.0', data=None):
+def create_request(user_ip="0.0.0.0", data=None):
     factory = RequestFactory()
     if data is None:
-        request = factory.get('/')
+        request = factory.get("/")
     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.cache_versions = get_cache_versions()
     request.frontend_context = {}
@@ -48,7 +55,7 @@ def create_strategy():
 
 
 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.request = create_request(user_ip)
 
@@ -64,22 +71,26 @@ class PipelineTestCase(UserTestCase):
         self, new_user, form_data=None, activation=None, email_verified=False
     ):
         self.assertFalse(new_user.has_usable_password())
-        self.assertIn('Welcome', mail.outbox[0].subject)
+        self.assertIn("Welcome", mail.outbox[0].subject)
 
         if form_data:
-            self.assertEqual(new_user.email, form_data['email'])
-            self.assertEqual(new_user.username, form_data['username'])
+            self.assertEqual(new_user.email, form_data["email"])
+            self.assertEqual(new_user.username, form_data["username"])
 
-        if activation == 'none':
+        if activation == "none":
             self.assertEqual(new_user.requires_activation, UserModel.ACTIVATION_NONE)
 
-        if activation == 'user':
+        if activation == "user":
             if email_verified:
-                self.assertEqual(new_user.requires_activation, UserModel.ACTIVATION_NONE)
+                self.assertEqual(
+                    new_user.requires_activation, UserModel.ACTIVATION_NONE
+                )
             else:
-                self.assertEqual(new_user.requires_activation, UserModel.ACTIVATION_USER)
+                self.assertEqual(
+                    new_user.requires_activation, UserModel.ACTIVATION_USER
+                )
 
-        if activation == 'admin':
+        if activation == "admin":
             self.assertEqual(new_user.requires_activation, UserModel.ACTIVATION_ADMIN)
 
         self.assertEqual(new_user.audittrail_set.count(), 1)
@@ -103,7 +114,7 @@ class AssociateByEmailTests(PipelineTestCase):
 
     def test_skip_if_user_with_email_not_found(self):
         """pipeline step is skipped if no email was passed"""
-        result = associate_by_email(None, {'email': 'not@found.com'}, GithubOAuth2)
+        result = associate_by_email(None, {"email": "not@found.com"}, GithubOAuth2)
         self.assertIsNone(result)
 
     def test_raise_if_user_is_inactive(self):
@@ -112,7 +123,7 @@ class AssociateByEmailTests(PipelineTestCase):
         self.user.save()
 
         try:
-            associate_by_email(None, {'email': self.user.email}, GithubOAuth2)
+            associate_by_email(None, {"email": self.user.email}, GithubOAuth2)
             self.fail("associate_by_email should raise SocialAuthFailed")
         except SocialAuthFailed as e:
             self.assertEqual(
@@ -129,7 +140,7 @@ class AssociateByEmailTests(PipelineTestCase):
         self.user.save()
 
         try:
-            associate_by_email(None, {'email': self.user.email}, GithubOAuth2)
+            associate_by_email(None, {"email": self.user.email}, GithubOAuth2)
             self.fail("associate_by_email should raise SocialAuthFailed")
         except SocialAuthFailed as e:
             self.assertEqual(
@@ -142,17 +153,17 @@ class AssociateByEmailTests(PipelineTestCase):
 
     def test_return_user(self):
         """pipeline returns user if email was found"""
-        result = associate_by_email(None, {'email': self.user.email}, GithubOAuth2)
-        self.assertEqual(result, {'user': self.user, 'is_new': False})
-    
+        result = associate_by_email(None, {"email": self.user.email}, GithubOAuth2)
+        self.assertEqual(result, {"user": self.user, "is_new": False})
+
     def test_return_user_email_inactive(self):
         """pipeline returns user even if they didn't activate their account manually"""
         self.user.requires_activation = UserModel.ACTIVATION_USER
         self.user.save()
 
-        result = associate_by_email(None, {'email': self.user.email}, GithubOAuth2)
-        self.assertEqual(result, {'user': self.user, 'is_new': False})
-    
+        result = associate_by_email(None, {"email": self.user.email}, GithubOAuth2)
+        self.assertEqual(result, {"user": self.user, "is_new": False})
+
 
 class CreateUser(PipelineTestCase):
     def test_skip_if_user_is_set(self):
@@ -163,19 +174,14 @@ class CreateUser(PipelineTestCase):
     def test_skip_if_no_email_passed(self):
         """pipeline step is skipped if no email was passed"""
         result = create_user(
-            MockStrategy(),
-            {},
-            GithubOAuth2(),
-            clean_username='TestBob',
+            MockStrategy(), {}, GithubOAuth2(), clean_username="TestBob"
         )
         self.assertIsNone(result)
 
     def test_skip_if_no_clean_username_passed(self):
         """pipeline step is skipped if cleaned username wasnt passed"""
         result = create_user(
-            MockStrategy(),
-            {'email': 'hello@example.com'},
-            GithubOAuth2(),
+            MockStrategy(), {"email": "hello@example.com"}, GithubOAuth2()
         )
         self.assertIsNone(result)
 
@@ -183,62 +189,53 @@ class CreateUser(PipelineTestCase):
         """pipeline step is skipped if email was taken"""
         result = create_user(
             MockStrategy(),
-            {'email': self.user.email},
+            {"email": self.user.email},
             GithubOAuth2(),
-            clean_username='NewUser',
+            clean_username="NewUser",
         )
         self.assertIsNone(result)
 
-    @override_dynamic_settings(account_activation='none')
+    @override_dynamic_settings(account_activation="none")
     def test_user_created_no_activation(self):
         """pipeline step creates active user for valid data and disabled activation"""
         result = create_user(
             MockStrategy(),
-            {'email': 'new@example.com'},
+            {"email": "new@example.com"},
             GithubOAuth2(),
-            clean_username='NewUser',
+            clean_username="NewUser",
         )
-        new_user = UserModel.objects.get(email='new@example.com')
-        self.assertEqual(result, {
-            'user': new_user,
-            'is_new': True,
-        })
-        self.assertEqual(new_user.username, 'NewUser')
-        self.assertNewUserIsCorrect(new_user, email_verified=True, activation='none')
+        new_user = UserModel.objects.get(email="new@example.com")
+        self.assertEqual(result, {"user": new_user, "is_new": True})
+        self.assertEqual(new_user.username, "NewUser")
+        self.assertNewUserIsCorrect(new_user, email_verified=True, activation="none")
 
-    @override_dynamic_settings(account_activation='user')
+    @override_dynamic_settings(account_activation="user")
     def test_user_created_activation_by_user(self):
         """pipeline step creates active user for valid data and user activation"""
         result = create_user(
             MockStrategy(),
-            {'email': 'new@example.com'},
+            {"email": "new@example.com"},
             GithubOAuth2(),
-            clean_username='NewUser',
+            clean_username="NewUser",
         )
-        new_user = UserModel.objects.get(email='new@example.com')
-        self.assertEqual(result, {
-            'user': new_user,
-            'is_new': True,
-        })
-        self.assertEqual(new_user.username, 'NewUser')
-        self.assertNewUserIsCorrect(new_user, email_verified=True, activation='user')
+        new_user = UserModel.objects.get(email="new@example.com")
+        self.assertEqual(result, {"user": new_user, "is_new": True})
+        self.assertEqual(new_user.username, "NewUser")
+        self.assertNewUserIsCorrect(new_user, email_verified=True, activation="user")
 
-    @override_dynamic_settings(account_activation='admin')
+    @override_dynamic_settings(account_activation="admin")
     def test_user_created_activation_by_admin(self):
         """pipeline step creates in user for valid data and admin activation"""
         result = create_user(
             MockStrategy(),
-            {'email': 'new@example.com'},
+            {"email": "new@example.com"},
             GithubOAuth2(),
-            clean_username='NewUser',
+            clean_username="NewUser",
         )
-        new_user = UserModel.objects.get(email='new@example.com')
-        self.assertEqual(result, {
-            'user': new_user,
-            'is_new': True,
-        })
-        self.assertEqual(new_user.username, 'NewUser')
-        self.assertNewUserIsCorrect(new_user, email_verified=True, activation='admin')
+        new_user = UserModel.objects.get(email="new@example.com")
+        self.assertEqual(result, {"user": new_user, "is_new": True})
+        self.assertEqual(new_user.username, "NewUser")
+        self.assertNewUserIsCorrect(new_user, email_verified=True, activation="admin")
 
 
 class CreateUserWithFormTests(PipelineTestCase):
@@ -249,14 +246,14 @@ class CreateUserWithFormTests(PipelineTestCase):
 
     def tearDown(self):
         super().tearDown()
-        
+
         Agreement.objects.invalidate_cache()
 
     def test_skip_if_user_is_set(self):
         """pipeline step is skipped if user was passed"""
         request = create_request()
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         result = create_user_with_form(
             strategy=strategy,
@@ -271,14 +268,10 @@ class CreateUserWithFormTests(PipelineTestCase):
         """pipeline step renders form if not POST"""
         request = create_request()
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         response = create_user_with_form(
-            strategy=strategy,
-            details={},
-            backend=backend,
-            user=None,
-            pipeline_index=1,
+            strategy=strategy, details={}, backend=backend, user=None, pipeline_index=1
         )
         self.assertContains(response, "GitHub")
 
@@ -286,259 +279,245 @@ class CreateUserWithFormTests(PipelineTestCase):
         """form rejects empty data"""
         request = create_request(data={})
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         response = create_user_with_form(
-            strategy=strategy,
-            details={},
-            backend=backend,
-            user=None,
-            pipeline_index=1,
+            strategy=strategy, details={}, backend=backend, user=None, pipeline_index=1
         )
         self.assertEqual(response.status_code, 400)
-        self.assertJsonResponseEquals(response, {
-            'email': ["This field is required."],
-            'username': ["This field is required."],
-        })
+        self.assertJsonResponseEquals(
+            response,
+            {
+                "email": ["This field is required."],
+                "username": ["This field is required."],
+            },
+        )
 
     def test_taken_data_rejected(self):
         """form rejects taken data"""
-        request = create_request(data={
-            'email': self.user.email,
-            'username': self.user.username,
-        })
+        request = create_request(
+            data={"email": self.user.email, "username": self.user.username}
+        )
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         response = create_user_with_form(
-            strategy=strategy,
-            details={},
-            backend=backend,
-            user=None,
-            pipeline_index=1,
+            strategy=strategy, details={}, backend=backend, user=None, pipeline_index=1
         )
         self.assertEqual(response.status_code, 400)
-        self.assertJsonResponseEquals(response, {
-            'email': ["This e-mail address is not available."],
-            'username': ["This username is not available."],
-        })
+        self.assertJsonResponseEquals(
+            response,
+            {
+                "email": ["This e-mail address is not available."],
+                "username": ["This username is not available."],
+            },
+        )
 
-    @override_dynamic_settings(account_activation='none')
+    @override_dynamic_settings(account_activation="none")
     def test_user_created_no_activation_verified_email(self):
         """active user is created for verified email and activation disabled"""
-        form_data = {
-            'email': 'social@auth.com',
-            'username': 'SocialUser',
-        }
+        form_data = {"email": "social@auth.com", "username": "SocialUser"}
         request = create_request(data=form_data)
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         result = create_user_with_form(
             strategy=strategy,
-            details={'email': form_data['email']},
+            details={"email": form_data["email"]},
             backend=backend,
             user=None,
             pipeline_index=1,
         )
 
-        new_user = UserModel.objects.get(email='social@auth.com')
-        self.assertEqual(result, {'user': new_user, 'is_new': True})
+        new_user = UserModel.objects.get(email="social@auth.com")
+        self.assertEqual(result, {"user": new_user, "is_new": True})
 
-        self.assertNewUserIsCorrect(new_user, form_data, activation='none', email_verified=True)
+        self.assertNewUserIsCorrect(
+            new_user, form_data, activation="none", email_verified=True
+        )
 
-    @override_dynamic_settings(account_activation='none')
+    @override_dynamic_settings(account_activation="none")
     def test_user_created_no_activation_nonverified_email(self):
         """active user is created for non-verified email and activation disabled"""
-        form_data = {
-            'email': 'social@auth.com',
-            'username': 'SocialUser',
-        }
+        form_data = {"email": "social@auth.com", "username": "SocialUser"}
         request = create_request(data=form_data)
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         result = create_user_with_form(
             strategy=strategy,
-            details={'email': ''},
+            details={"email": ""},
             backend=backend,
             user=None,
             pipeline_index=1,
         )
 
-        new_user = UserModel.objects.get(email='social@auth.com')
-        self.assertEqual(result, {'user': new_user, 'is_new': True})
+        new_user = UserModel.objects.get(email="social@auth.com")
+        self.assertEqual(result, {"user": new_user, "is_new": True})
 
-        self.assertNewUserIsCorrect(new_user, form_data, activation='none', email_verified=False)
+        self.assertNewUserIsCorrect(
+            new_user, form_data, activation="none", email_verified=False
+        )
 
-    @override_dynamic_settings(account_activation='user')
+    @override_dynamic_settings(account_activation="user")
     def test_user_created_activation_by_user_verified_email(self):
         """active user is created for verified email and activation by user"""
-        form_data = {
-            'email': 'social@auth.com',
-            'username': 'SocialUser',
-        }
+        form_data = {"email": "social@auth.com", "username": "SocialUser"}
         request = create_request(data=form_data)
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         result = create_user_with_form(
             strategy=strategy,
-            details={'email': form_data['email']},
+            details={"email": form_data["email"]},
             backend=backend,
             user=None,
             pipeline_index=1,
         )
 
-        new_user = UserModel.objects.get(email='social@auth.com')
-        self.assertEqual(result, {'user': new_user, 'is_new': True})
+        new_user = UserModel.objects.get(email="social@auth.com")
+        self.assertEqual(result, {"user": new_user, "is_new": True})
 
-        self.assertNewUserIsCorrect(new_user, form_data, activation='user', email_verified=True)
+        self.assertNewUserIsCorrect(
+            new_user, form_data, activation="user", email_verified=True
+        )
 
-    @override_dynamic_settings(account_activation='user')
+    @override_dynamic_settings(account_activation="user")
     def test_user_created_activation_by_user_nonverified_email(self):
         """inactive user is created for non-verified email and activation by user"""
-        form_data = {
-            'email': 'social@auth.com',
-            'username': 'SocialUser',
-        }
+        form_data = {"email": "social@auth.com", "username": "SocialUser"}
         request = create_request(data=form_data)
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         result = create_user_with_form(
             strategy=strategy,
-            details={'email': ''},
+            details={"email": ""},
             backend=backend,
             user=None,
             pipeline_index=1,
         )
 
-        new_user = UserModel.objects.get(email='social@auth.com')
-        self.assertEqual(result, {'user': new_user, 'is_new': True})
+        new_user = UserModel.objects.get(email="social@auth.com")
+        self.assertEqual(result, {"user": new_user, "is_new": True})
 
-        self.assertNewUserIsCorrect(new_user, form_data, activation='user', email_verified=False)
+        self.assertNewUserIsCorrect(
+            new_user, form_data, activation="user", email_verified=False
+        )
 
-    @override_dynamic_settings(account_activation='admin')
+    @override_dynamic_settings(account_activation="admin")
     def test_user_created_activation_by_admin_verified_email(self):
         """inactive user is created for verified email and activation by admin"""
-        form_data = {
-            'email': 'social@auth.com',
-            'username': 'SocialUser',
-        }
+        form_data = {"email": "social@auth.com", "username": "SocialUser"}
         request = create_request(data=form_data)
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         result = create_user_with_form(
             strategy=strategy,
-            details={'email': form_data['email']},
+            details={"email": form_data["email"]},
             backend=backend,
             user=None,
             pipeline_index=1,
         )
 
-        new_user = UserModel.objects.get(email='social@auth.com')
-        self.assertEqual(result, {'user': new_user, 'is_new': True})
+        new_user = UserModel.objects.get(email="social@auth.com")
+        self.assertEqual(result, {"user": new_user, "is_new": True})
 
-        self.assertNewUserIsCorrect(new_user, form_data, activation='admin', email_verified=True)
+        self.assertNewUserIsCorrect(
+            new_user, form_data, activation="admin", email_verified=True
+        )
 
-    @override_dynamic_settings(account_activation='admin')
+    @override_dynamic_settings(account_activation="admin")
     def test_user_created_activation_by_admin_nonverified_email(self):
         """inactive user is created for non-verified email and activation by admin"""
-        form_data = {
-            'email': 'social@auth.com',
-            'username': 'SocialUser',
-        }
+        form_data = {"email": "social@auth.com", "username": "SocialUser"}
         request = create_request(data=form_data)
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         result = create_user_with_form(
             strategy=strategy,
-            details={'email': ''},
+            details={"email": ""},
             backend=backend,
             user=None,
             pipeline_index=1,
         )
 
-        new_user = UserModel.objects.get(email='social@auth.com')
-        self.assertEqual(result, {'user': new_user, 'is_new': True})
+        new_user = UserModel.objects.get(email="social@auth.com")
+        self.assertEqual(result, {"user": new_user, "is_new": True})
 
-        self.assertNewUserIsCorrect(new_user, form_data, activation='admin', email_verified=False)
+        self.assertNewUserIsCorrect(
+            new_user, form_data, activation="admin", email_verified=False
+        )
 
     def test_form_check_agreement(self):
         """social register checks agreement"""
-        form_data = {
-            'email': 'social@auth.com',
-            'username': 'SocialUser',
-        }
+        form_data = {"email": "social@auth.com", "username": "SocialUser"}
         request = create_request(data=form_data)
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         agreement = Agreement.objects.create(
-            type=Agreement.TYPE_TOS,
-            text="Lorem ipsum",
-            is_active=True,
+            type=Agreement.TYPE_TOS, text="Lorem ipsum", is_active=True
         )
 
         response = create_user_with_form(
             strategy=strategy,
-            details={'email': form_data['email']},
+            details={"email": form_data["email"]},
             backend=backend,
             user=None,
             pipeline_index=1,
         )
-        
+
         self.assertEqual(response.status_code, 400)
-        self.assertJsonResponseEquals(response, {
-            'terms_of_service': ['This agreement is required.'],
-        })
+        self.assertJsonResponseEquals(
+            response, {"terms_of_service": ["This agreement is required."]}
+        )
 
         # invalid agreement id
         form_data = {
-            'email': 'social@auth.com',
-            'username': 'SocialUser',
-            'terms_of_service': agreement.id + 1,
+            "email": "social@auth.com",
+            "username": "SocialUser",
+            "terms_of_service": agreement.id + 1,
         }
         request = create_request(data=form_data)
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         response = create_user_with_form(
             strategy=strategy,
-            details={'email': form_data['email']},
+            details={"email": form_data["email"]},
             backend=backend,
             user=None,
             pipeline_index=1,
         )
-        
+
         self.assertEqual(response.status_code, 400)
-        self.assertJsonResponseEquals(response, {
-            'terms_of_service': ['This agreement is required.'],
-        })
+        self.assertJsonResponseEquals(
+            response, {"terms_of_service": ["This agreement is required."]}
+        )
 
         # valid agreement id
         form_data = {
-            'email': 'social@auth.com',
-            'username': 'SocialUser',
-            'terms_of_service': agreement.id,
+            "email": "social@auth.com",
+            "username": "SocialUser",
+            "terms_of_service": agreement.id,
         }
         request = create_request(data=form_data)
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         result = create_user_with_form(
             strategy=strategy,
-            details={'email': form_data['email']},
+            details={"email": form_data["email"]},
             backend=backend,
             user=None,
             pipeline_index=1,
         )
 
-        new_user = UserModel.objects.get(email='social@auth.com')
-        self.assertEqual(result, {'user': new_user, 'is_new': True})
+        new_user = UserModel.objects.get(email="social@auth.com")
+        self.assertEqual(result, {"user": new_user, "is_new": True})
 
         self.assertEqual(new_user.agreements, [agreement.id])
         self.assertEqual(new_user.useragreement_set.count(), 1)
@@ -546,30 +525,28 @@ class CreateUserWithFormTests(PipelineTestCase):
     def test_form_ignore_inactive_agreement(self):
         """social register ignores inactive agreement"""
         form_data = {
-            'email': 'social@auth.com',
-            'username': 'SocialUser',
-            'terms_of_service': None,
+            "email": "social@auth.com",
+            "username": "SocialUser",
+            "terms_of_service": None,
         }
         request = create_request(data=form_data)
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         Agreement.objects.create(
-            type=Agreement.TYPE_TOS,
-            text="Lorem ipsum",
-            is_active=False,
+            type=Agreement.TYPE_TOS, text="Lorem ipsum", is_active=False
         )
 
         result = create_user_with_form(
             strategy=strategy,
-            details={'email': form_data['email']},
+            details={"email": form_data["email"]},
             backend=backend,
             user=None,
             pipeline_index=1,
         )
 
-        new_user = UserModel.objects.get(email='social@auth.com')
-        self.assertEqual(result, {'user': new_user, 'is_new': True})
+        new_user = UserModel.objects.get(email="social@auth.com")
+        self.assertEqual(result, {"user": new_user, "is_new": True})
 
         self.assertEqual(new_user.agreements, [])
         self.assertEqual(new_user.useragreement_set.count(), 0)
@@ -591,75 +568,58 @@ class GetUsernameTests(PipelineTestCase):
     def test_resolve_to_username(self):
         """pipeline step resolves username"""
         strategy = create_strategy()
-        result = get_username(strategy, {'username': 'BobBoberson'}, None)
-        self.assertEqual(result, {'clean_username': 'BobBoberson'})
+        result = get_username(strategy, {"username": "BobBoberson"}, None)
+        self.assertEqual(result, {"clean_username": "BobBoberson"})
 
     def test_normalize_username(self):
         """pipeline step normalizes username"""
         strategy = create_strategy()
-        result = get_username(strategy, {'username': 'Błop Błoperson'}, None)
-        self.assertEqual(result, {'clean_username': 'BlopBloperson'})
+        result = get_username(strategy, {"username": "Błop Błoperson"}, None)
+        self.assertEqual(result, {"clean_username": "BlopBloperson"})
 
     def test_resolve_to_first_name(self):
         """pipeline attempts to use first name because username is taken"""
         strategy = create_strategy()
-        details = {
-            'username': self.user.username,
-            'first_name': 'Błob',
-        }
+        details = {"username": self.user.username, "first_name": "Błob"}
         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):
         """pipeline will not fallback to last name because username is taken"""
         strategy = create_strategy()
-        details = {
-            'username': self.user.username,
-            'last_name': 'Błob',
-        }
+        details = {"username": self.user.username, "last_name": "Błob"}
         result = get_username(strategy, details, None)
         self.assertIsNone(result)
 
     def test_resolve_to_first_last_name_first_char(self):
         """pipeline will construct username from first name and first char of surname"""
         strategy = create_strategy()
-        details = {
-            'first_name': self.user.username,
-            'last_name': 'Błob',
-        }
+        details = {"first_name": self.user.username, "last_name": "Błob"}
         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):
         """pipeline will not resolve to banned name"""
         strategy = create_strategy()
-        Ban.objects.create(banned_value='*Admin*', check_type=Ban.USERNAME)
-        details = {
-            'username': 'Misago Admin',
-            'first_name': 'Błob',
-        }
+        Ban.objects.create(banned_value="*Admin*", check_type=Ban.USERNAME)
+        details = {"username": "Misago Admin", "first_name": "Błob"}
         result = get_username(strategy, details, None)
-        self.assertEqual(result, {'clean_username': 'Blob'})
+        self.assertEqual(result, {"clean_username": "Blob"})
 
     def test_resolve_full_name(self):
         """pipeline will resolve to full name"""
         strategy = create_strategy()
-        Ban.objects.create(banned_value='*Admin*', check_type=Ban.USERNAME)
-        details = {
-            'username': 'Misago Admin',
-            'full_name': 'Błob Błopo',
-        }
+        Ban.objects.create(banned_value="*Admin*", check_type=Ban.USERNAME)
+        details = {"username": "Misago Admin", "full_name": "Błob Błopo"}
         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):
         """pipeline will resolve cut too long name on second pass"""
         strategy = create_strategy()
-        details = {
-            'username': 'Abrakadabrapokuskonstantynopolitańczykowianeczkatrzy',
-        }
+        details = {"username": "Abrakadabrapokuskonstantynopolitańczykowianeczkatrzy"}
         result = get_username(strategy, details, None)
-        self.assertEqual(result, {'clean_username': 'Abrakadabrapok'})
+        self.assertEqual(result, {"clean_username": "Abrakadabrapok"})
 
 
 class RequireActivationTests(PipelineTestCase):
@@ -673,14 +633,10 @@ class RequireActivationTests(PipelineTestCase):
         """pipeline step is skipped if user is not set"""
         request = create_request()
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         result = require_activation(
-            strategy=strategy,
-            details={},
-            backend=backend,
-            user=None,
-            pipeline_index=1,
+            strategy=strategy, details={}, backend=backend, user=None, pipeline_index=1
         )
         self.assertEqual(result, {})
 
@@ -688,15 +644,11 @@ class RequireActivationTests(PipelineTestCase):
         """pipeline step handles set session token if user is not set"""
         request = create_request()
         strategy = load_strategy(request=request)
-        strategy.request.session['partial_pipeline_token'] = 'test-token'
-        backend = GithubOAuth2(strategy, '/')
+        strategy.request.session["partial_pipeline_token"] = "test-token"
+        backend = GithubOAuth2(strategy, "/")
 
         require_activation(
-            strategy=strategy,
-            details={},
-            backend=backend,
-            user=None,
-            pipeline_index=1,
+            strategy=strategy, details={}, backend=backend, user=None, pipeline_index=1
         )
 
     def test_skip_if_user_is_active(self):
@@ -708,7 +660,7 @@ class RequireActivationTests(PipelineTestCase):
 
         request = create_request()
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         result = require_activation(
             strategy=strategy,
@@ -723,7 +675,7 @@ class RequireActivationTests(PipelineTestCase):
         """pipeline step renders http response for GET request and inactive user"""
         request = create_request()
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         response = require_activation(
             strategy=strategy,
@@ -733,13 +685,13 @@ class RequireActivationTests(PipelineTestCase):
             pipeline_index=1,
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response['content-type'], 'text/html; charset=utf-8')
+        self.assertEqual(response["content-type"], "text/html; charset=utf-8")
 
     def test_pipeline_returns_json_response_on_post(self):
         """pipeline step renders json response for POST request and inactive user"""
-        request = create_request(data={'username': 'anything'})
+        request = create_request(data={"username": "anything"})
         strategy = load_strategy(request=request)
-        backend = GithubOAuth2(strategy, '/')
+        backend = GithubOAuth2(strategy, "/")
 
         response = require_activation(
             strategy=strategy,
@@ -749,14 +701,17 @@ class RequireActivationTests(PipelineTestCase):
             pipeline_index=1,
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response['content-type'], 'application/json')
-        self.assertJsonResponseEquals(response, {
-            'step': 'done',
-            'backend_name': 'GitHub',
-            'activation': 'admin',
-            'email': 'test@user.com',
-            'username': 'TestUser',
-        })
+        self.assertEqual(response["content-type"], "application/json")
+        self.assertJsonResponseEquals(
+            response,
+            {
+                "step": "done",
+                "backend_name": "GitHub",
+                "activation": "admin",
+                "email": "test@user.com",
+                "username": "TestUser",
+            },
+        )
 
 
 class ValidateIpNotBannedTests(PipelineTestCase):
@@ -767,10 +722,12 @@ class ValidateIpNotBannedTests(PipelineTestCase):
 
     def test_raise_if_banned(self):
         """pipeline raises if user's IP is banned"""
-        Ban.objects.create(banned_value='188.*', check_type=Ban.IP)
+        Ban.objects.create(banned_value="188.*", check_type=Ban.IP)
 
         try:
-            validate_ip_not_banned(MockStrategy(user_ip='188.1.2.3'), {}, GithubOAuth2, self.user)
+            validate_ip_not_banned(
+                MockStrategy(user_ip="188.1.2.3"), {}, GithubOAuth2, self.user
+            )
             self.fail("validate_ip_not_banned should raise SocialAuthBanned")
         except SocialAuthBanned as e:
             self.assertTrue(isinstance(e.ban, Ban))
@@ -780,10 +737,11 @@ class ValidateIpNotBannedTests(PipelineTestCase):
         self.user.is_staff = True
         self.user.save()
 
-        Ban.objects.create(banned_value='188.*', check_type=Ban.IP)
+        Ban.objects.create(banned_value="188.*", check_type=Ban.IP)
 
         result = validate_ip_not_banned(
-            MockStrategy(user_ip='188.1.2.3'), {}, GithubOAuth2, self.user)
+            MockStrategy(user_ip="188.1.2.3"), {}, GithubOAuth2, self.user
+        )
         self.assertIsNone(result)
 
 

+ 41 - 35
misago/users/tests/test_social_utils.py

@@ -5,45 +5,51 @@ from misago.users.social.utils import get_enabled_social_auth_sites_list
 
 
 class SocialUtilsTests(TestCase):
-    @override_settings(AUTHENTICATION_BACKENDS=[
-        'misago.users.authbackends.MisagoBackend',
-        'social_core.backends.facebook.FacebookOAuth2',
-        'social_core.backends.github.GithubOAuth2',
-    ])
+    @override_settings(
+        AUTHENTICATION_BACKENDS=[
+            "misago.users.authbackends.MisagoBackend",
+            "social_core.backends.facebook.FacebookOAuth2",
+            "social_core.backends.github.GithubOAuth2",
+        ]
+    )
     def test_get_enabled_social_auth_sites_list(self):
-        self.assertEqual(get_enabled_social_auth_sites_list(), [
-            {
-                'id': 'facebook',
-                'name': 'Facebook',
-                'url': reverse('social:begin', kwargs={'backend': 'facebook'}),
-            },
-            {
-                'id': 'github',
-                'name': 'GitHub',
-                'url': reverse('social:begin', kwargs={'backend': 'github'}),
-            }
-        ])
+        self.assertEqual(
+            get_enabled_social_auth_sites_list(),
+            [
+                {
+                    "id": "facebook",
+                    "name": "Facebook",
+                    "url": reverse("social:begin", kwargs={"backend": "facebook"}),
+                },
+                {
+                    "id": "github",
+                    "name": "GitHub",
+                    "url": reverse("social:begin", kwargs={"backend": "github"}),
+                },
+            ],
+        )
 
     @override_settings(
         AUTHENTICATION_BACKENDS=[
-            'misago.users.authbackends.MisagoBackend',
-            'social_core.backends.facebook.FacebookOAuth2',
-            'social_core.backends.github.GithubOAuth2',
+            "misago.users.authbackends.MisagoBackend",
+            "social_core.backends.facebook.FacebookOAuth2",
+            "social_core.backends.github.GithubOAuth2",
         ],
-        MISAGO_SOCIAL_AUTH_BACKENDS_NAMES={
-            'facebook': "Facebook Connect",
-        }
+        MISAGO_SOCIAL_AUTH_BACKENDS_NAMES={"facebook": "Facebook Connect"},
     )
     def test_get_enabled_social_auth_sites_list_override_name(self):
-        self.assertEqual(get_enabled_social_auth_sites_list(), [
-            {
-                'id': 'facebook',
-                'name': 'Facebook Connect',
-                'url': reverse('social:begin', kwargs={'backend': 'facebook'}),
-            },
-            {
-                'id': 'github',
-                'name': 'GitHub',
-                'url': reverse('social:begin', kwargs={'backend': 'github'}),
-            }
-        ])
+        self.assertEqual(
+            get_enabled_social_auth_sites_list(),
+            [
+                {
+                    "id": "facebook",
+                    "name": "Facebook Connect",
+                    "url": reverse("social:begin", kwargs={"backend": "facebook"}),
+                },
+                {
+                    "id": "github",
+                    "name": "GitHub",
+                    "url": reverse("social:begin", kwargs={"backend": "github"}),
+                },
+            ],
+        )

+ 16 - 12
misago/users/tests/test_testutils.py

@@ -1,6 +1,10 @@
 from django.urls import reverse
 
-from misago.users.testutils import AuthenticatedUserTestCase, SuperUserTestCase, UserTestCase
+from misago.users.testutils import (
+    AuthenticatedUserTestCase,
+    SuperUserTestCase,
+    UserTestCase,
+)
 
 
 class UserTestCaseTests(UserTestCase):
@@ -31,22 +35,22 @@ class UserTestCaseTests(UserTestCase):
         user = self.get_authenticated_user()
         self.login_user(user)
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertEqual(user_json['id'], user.id)
+        self.assertEqual(user_json["id"], user.id)
 
     def test_login_superuser(self):
         """login_user logs superuser"""
         user = self.get_superuser()
         self.login_user(user)
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertEqual(user_json['id'], user.id)
+        self.assertEqual(user_json["id"], user.id)
 
     def test_logout_user(self):
         """logout_user logs user out"""
@@ -54,11 +58,11 @@ class UserTestCaseTests(UserTestCase):
         self.login_user(user)
         self.logout_user()
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertIsNone(user_json['id'])
+        self.assertIsNone(user_json["id"])
 
     def test_logout_superuser(self):
         """logout_user logs superuser out"""
@@ -66,17 +70,17 @@ class UserTestCaseTests(UserTestCase):
         self.login_user(user)
         self.logout_user()
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertIsNone(user_json['id'])
+        self.assertIsNone(user_json["id"])
 
 
 class AuthenticatedUserTestCaseTests(AuthenticatedUserTestCase):
     def test_setup(self):
         """setup executed correctly"""
-        response = self.client.get(reverse('misago:index'))
+        response = self.client.get(reverse("misago:index"))
         self.assertContains(response, self.user.username)
 
     def test_reload_user(self):
@@ -93,8 +97,8 @@ class SuperUserTestCaseTests(SuperUserTestCase):
         self.assertTrue(self.user.is_staff)
         self.assertTrue(self.user.is_superuser)
 
-        response = self.client.get('/api/auth/')
+        response = self.client.get("/api/auth/")
         self.assertEqual(response.status_code, 200)
 
         user_json = response.json()
-        self.assertEqual(user_json['id'], self.user.id)
+        self.assertEqual(user_json["id"], self.user.id)

+ 6 - 6
misago/users/tests/test_tokens.py

@@ -10,12 +10,12 @@ UserModel = get_user_model()
 class TokensTests(TestCase):
     def test_tokens(self):
         """misago.users.tokens implementation works"""
-        user_a = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
-        user_b = UserModel.objects.create_user('Weebl', 'weebl@test.com', 'pass123')
+        user_a = UserModel.objects.create_user("Bob", "bob@test.com", "pass123")
+        user_b = UserModel.objects.create_user("Weebl", "weebl@test.com", "pass123")
 
-        token_a = tokens.make(user_a, 'test')
-        token_b = tokens.make(user_b, 'test')
+        token_a = tokens.make(user_a, "test")
+        token_b = tokens.make(user_b, "test")
 
-        self.assertTrue(tokens.is_valid(user_a, 'test', token_a))
-        self.assertTrue(tokens.is_valid(user_b, 'test', token_b))
+        self.assertTrue(tokens.is_valid(user_a, "test", token_a))
+        self.assertTrue(tokens.is_valid(user_b, "test", token_b))
         self.assertTrue(token_a != token_b)

+ 112 - 118
misago/users/tests/test_twitter_profilefield.py

@@ -12,10 +12,7 @@ class TwitterProfileFieldTests(AdminTestCase):
         super().setUp()
 
         self.test_link = reverse(
-            'misago:admin:users:accounts:edit',
-            kwargs={
-                'pk': self.user.pk,
-            },
+            "misago:admin:users:accounts:edit", kwargs={"pk": self.user.pk}
         )
 
     def test_field_displays_in_admin(self):
@@ -25,55 +22,55 @@ class TwitterProfileFieldTests(AdminTestCase):
 
     def test_admin_clears_field(self):
         """admin form allows admins to clear field"""
-        self.user.profile_fields['twitter'] = 'lorem_ipsum'
+        self.user.profile_fields["twitter"] = "lorem_ipsum"
         self.user.save()
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['twitter'], 'lorem_ipsum')
+        self.assertEqual(self.user.profile_fields["twitter"], "lorem_ipsum")
 
         response = self.client.post(
             self.test_link,
             data={
-                'username': 'Edited',
-                'rank': str(self.user.rank_id),
-                'roles': str(self.user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'new_password': '',
-                'signature': '',
-                'is_signature_locked': '0',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': '',
-                'signature_lock_user_message': '',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Edited",
+                "rank": str(self.user.rank_id),
+                "roles": str(self.user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "new_password": "",
+                "signature": "",
+                "is_signature_locked": "0",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "",
+                "signature_lock_user_message": "",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['twitter'], '')
+        self.assertEqual(self.user.profile_fields["twitter"], "")
 
     def test_admin_validates_field(self):
         """admin form allows admins to edit field"""
         response = self.client.post(
             self.test_link,
             data={
-                'username': 'Edited',
-                'rank': str(self.user.rank_id),
-                'roles': str(self.user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'twitter': 'lorem!ipsum',
-                'new_password': '',
-                'signature': '',
-                'is_signature_locked': '0',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': '',
-                'signature_lock_user_message': '',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Edited",
+                "rank": str(self.user.rank_id),
+                "roles": str(self.user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "twitter": "lorem!ipsum",
+                "new_password": "",
+                "signature": "",
+                "is_signature_locked": "0",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "",
+                "signature_lock_user_message": "",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
 
         self.assertContains(response, "This is not a valid twitter handle.")
@@ -83,166 +80,163 @@ class TwitterProfileFieldTests(AdminTestCase):
         response = self.client.post(
             self.test_link,
             data={
-                'username': 'Edited',
-                'rank': str(self.user.rank_id),
-                'roles': str(self.user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'twitter': 'lorem_ipsum',
-                'new_password': '',
-                'signature': '',
-                'is_signature_locked': '0',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': '',
-                'signature_lock_user_message': '',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Edited",
+                "rank": str(self.user.rank_id),
+                "roles": str(self.user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "twitter": "lorem_ipsum",
+                "new_password": "",
+                "signature": "",
+                "is_signature_locked": "0",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "",
+                "signature_lock_user_message": "",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['twitter'], 'lorem_ipsum')
+        self.assertEqual(self.user.profile_fields["twitter"], "lorem_ipsum")
 
     def test_admin_search_field(self):
         """admin users search searches this field"""
-        test_link = reverse('misago:admin:users:accounts:index')
+        test_link = reverse("misago:admin:users:accounts:index")
 
-        response = self.client.get('%s?redirected=1&profilefields=ipsum' % test_link)
-        self.assertContains(response, "No users matching search criteria have been found.")
+        response = self.client.get("%s?redirected=1&profilefields=ipsum" % test_link)
+        self.assertContains(
+            response, "No users matching search criteria have been found."
+        )
 
-        self.user.profile_fields['twitter'] = 'lorem_ipsum'
+        self.user.profile_fields["twitter"] = "lorem_ipsum"
         self.user.save()
 
-        response = self.client.get('%s?redirected=1&profilefields=ipsum' % test_link)
-        self.assertNotContains(response, "No users matching search criteria have been found.")
+        response = self.client.get("%s?redirected=1&profilefields=ipsum" % test_link)
+        self.assertNotContains(
+            response, "No users matching search criteria have been found."
+        )
 
     def test_field_display(self):
         """field displays on user profile when filled in"""
         test_link = reverse(
-            'misago:user-details',
-            kwargs={
-                'pk': self.user.pk,
-                'slug': self.user.slug,
-            },
+            "misago:user-details", kwargs={"pk": self.user.pk, "slug": self.user.slug}
         )
 
         response = self.client.get(test_link)
-        self.assertNotContains(response, 'Twitter')
+        self.assertNotContains(response, "Twitter")
 
-        self.user.profile_fields['twitter'] = 'lorem_ipsum'
+        self.user.profile_fields["twitter"] = "lorem_ipsum"
         self.user.save()
 
         response = self.client.get(test_link)
-        self.assertContains(response, 'Twitter')
+        self.assertContains(response, "Twitter")
         self.assertContains(response, 'href="https://twitter.com/lorem_ipsum"')
-        self.assertContains(response, '@lorem_ipsum')
+        self.assertContains(response, "@lorem_ipsum")
 
     def test_field_display_json(self):
         """field is included in display json"""
-        test_link = reverse('misago:api:user-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-details", kwargs={"pk": self.user.pk})
 
         response = self.client.get(test_link)
         self.assertEqual(
-            response.json()['groups'],
+            response.json()["groups"],
             [
                 {
-                    'name': 'IP address',
-                    'fields': [
-                        {
-                            'fieldname': 'join_ip',
-                            'name': 'Join IP',
-                            'text': '127.0.0.1',
-                        },
+                    "name": "IP address",
+                    "fields": [
+                        {"fieldname": "join_ip", "name": "Join IP", "text": "127.0.0.1"}
                     ],
-                },
-            ]
+                }
+            ],
         )
 
-        self.user.profile_fields['twitter'] = 'lorem_ipsum'
+        self.user.profile_fields["twitter"] = "lorem_ipsum"
         self.user.save()
 
         response = self.client.get(test_link)
         self.assertEqual(
-            response.json()['groups'],
+            response.json()["groups"],
             [
                 {
-                    'name': 'Contact',
-                    'fields': [
+                    "name": "Contact",
+                    "fields": [
                         {
-                            'fieldname': 'twitter',
-                            'name': 'Twitter handle',
-                            'text': '@lorem_ipsum',
-                            'url': 'https://twitter.com/lorem_ipsum',
+                            "fieldname": "twitter",
+                            "name": "Twitter handle",
+                            "text": "@lorem_ipsum",
+                            "url": "https://twitter.com/lorem_ipsum",
                         }
                     ],
                 },
                 {
-                    'name': 'IP address',
-                    'fields': [
-                        {
-                            'fieldname': 'join_ip',
-                            'name': 'Join IP',
-                            'text': '127.0.0.1',
-                        },
+                    "name": "IP address",
+                    "fields": [
+                        {"fieldname": "join_ip", "name": "Join IP", "text": "127.0.0.1"}
                     ],
                 },
-            ]
+            ],
         )
 
     def test_api_returns_field_json(self):
         """field json is returned from API"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
         response = self.client.get(test_link)
 
         found_field = None
         for group in response.json():
-            for field in group['fields']:
-                if field['fieldname'] == 'twitter':
+            for field in group["fields"]:
+                if field["fieldname"] == "twitter":
                     found_field = field
 
-        self.assertEqual(found_field, {
-            'fieldname': 'twitter',
-            'label': 'Twitter handle',
-            'help_text': (
-                'If you own Twitter account, here you may enter your Twitter handle for other users to find you. '
-                'Starting your handle with "@" sign is optional. Either "@testsuperuser" or "testsuperuser" are '
-                'valid values.'
-            ),
-            'input': {'type': 'text'},
-            'initial': '',
-        })
+        self.assertEqual(
+            found_field,
+            {
+                "fieldname": "twitter",
+                "label": "Twitter handle",
+                "help_text": (
+                    "If you own Twitter account, here you may enter your Twitter handle for other users to find you. "
+                    'Starting your handle with "@" sign is optional. Either "@testsuperuser" or "testsuperuser" are '
+                    "valid values."
+                ),
+                "input": {"type": "text"},
+                "initial": "",
+            },
+        )
 
     def test_api_clears_field(self):
         """field can be cleared via api"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
-        self.user.profile_fields['twitter'] = 'lorem_ipsum'
+        self.user.profile_fields["twitter"] = "lorem_ipsum"
         self.user.save()
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['twitter'], 'lorem_ipsum')
+        self.assertEqual(self.user.profile_fields["twitter"], "lorem_ipsum")
 
         response = self.client.post(test_link, data={})
         self.assertEqual(response.status_code, 200)
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['twitter'], '')
+        self.assertEqual(self.user.profile_fields["twitter"], "")
 
     def test_api_validates_field(self):
         """field can be edited via api"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
-        response = self.client.post(test_link, data={'twitter': '@lorem!ipsum'})
-        self.assertContains(response, "This is not a valid twitter handle.", status_code=400)
+        response = self.client.post(test_link, data={"twitter": "@lorem!ipsum"})
+        self.assertContains(
+            response, "This is not a valid twitter handle.", status_code=400
+        )
 
     def test_api_edits_field(self):
         """field can be edited via api"""
-        test_link = reverse('misago:api:user-edit-details', kwargs={'pk': self.user.pk})
+        test_link = reverse("misago:api:user-edit-details", kwargs={"pk": self.user.pk})
 
-        response = self.client.post(test_link, data={'twitter': '@lorem_ipsum'})
+        response = self.client.post(test_link, data={"twitter": "@lorem_ipsum"})
         self.assertEqual(response.status_code, 200)
 
         self.reload_user()
-        self.assertEqual(self.user.profile_fields['twitter'], 'lorem_ipsum')
+        self.assertEqual(self.user.profile_fields["twitter"], "lorem_ipsum")

+ 152 - 184
misago/users/tests/test_user_avatar_api.py

@@ -11,8 +11,8 @@ from misago.users.avatars import gallery, store
 from misago.users.models import AvatarGallery
 from misago.users.testutils import AuthenticatedUserTestCase
 
-TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'testfiles')
-TEST_AVATAR_PATH = os.path.join(TESTFILES_DIR, 'avatar.png')
+TESTFILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "testfiles")
+TEST_AVATAR_PATH = os.path.join(TESTFILES_DIR, "avatar.png")
 
 User = get_user_model()
 
@@ -22,16 +22,14 @@ class UserAvatarTests(AuthenticatedUserTestCase):
 
     def setUp(self):
         super().setUp()
-        self.link = '/api/users/%s/avatar/' % self.user.pk
-        self.client.post(self.link, data={'avatar': 'generated'})
+        self.link = "/api/users/%s/avatar/" % self.user.pk
+        self.client.post(self.link, data={"avatar": "generated"})
 
     def get_current_user(self):
         return User.objects.get(pk=self.user.pk)
 
     def assertOldAvatarsAreDeleted(self, user):
-        self.assertEqual(
-            user.avatar_set.count(), len(settings.MISAGO_AVATARS_SIZES)
-        )
+        self.assertEqual(user.avatar_set.count(), len(settings.MISAGO_AVATARS_SIZES))
 
     @override_dynamic_settings(allow_custom_avatars=False)
     def test_avatars_off(self):
@@ -40,12 +38,12 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         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'])
+        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):
@@ -54,12 +52,12 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         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'])
+        self.assertTrue(options["generated"])
+        self.assertTrue(options["gravatar"])
+        self.assertFalse(options["crop_src"])
+        self.assertFalse(options["crop_tmp"])
+        self.assertTrue(options["upload"])
+        self.assertFalse(options["galleries"])
 
     def test_gallery_exists(self):
         """api returns gallery"""
@@ -69,7 +67,7 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
 
         options = response.json()
-        self.assertTrue(options['galleries'])
+        self.assertTrue(options["galleries"])
 
     def test_avatar_locked(self):
         """requests to api error if user's avatar is locked"""
@@ -79,10 +77,13 @@ class UserAvatarTests(AuthenticatedUserTestCase):
 
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "Your avatar is locked. You can't change it.",
-            "reason": "<p>Your avatar is pwnt.</p>",
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "Your avatar is locked. You can't change it.",
+                "reason": "<p>Your avatar is pwnt.</p>",
+            },
+        )
 
     def test_other_user_avatar(self):
         """requests to api error if user tries to access other user"""
@@ -90,9 +91,9 @@ class UserAvatarTests(AuthenticatedUserTestCase):
 
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You have to sign in to perform this action.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You have to sign in to perform this action."}
+        )
 
         self.login_user(
             User.objects.create_user("BobUser", "bob@bob.com", self.USER_PASSWORD)
@@ -100,35 +101,34 @@ class UserAvatarTests(AuthenticatedUserTestCase):
 
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't change other users avatars.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't change other users avatars."}
+        )
 
     def test_empty_requests(self):
         """empty request errors with code 400"""
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Unknown avatar type.",
-        })
+        self.assertEqual(response.json(), {"detail": "Unknown avatar type."})
 
     def test_failed_gravatar_request(self):
         """no gravatar RPC fails"""
-        self.user.email_hash = 'wolololo'
+        self.user.email_hash = "wolololo"
         self.user.save()
 
-        response = self.client.post(self.link, data={'avatar': 'gravatar'})
+        response = self.client.post(self.link, data={"avatar": "gravatar"})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "No Gravatar is associated with your e-mail address.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "No Gravatar is associated with your e-mail address."},
+        )
 
     def test_successful_gravatar_request(self):
         """gravatar RPC passes"""
-        self.user.set_email('rafio.xudb@gmail.com')
+        self.user.set_email("rafio.xudb@gmail.com")
         self.user.save()
 
-        response = self.client.post(self.link, data={'avatar': 'gravatar'})
+        response = self.client.post(self.link, data={"avatar": "gravatar"})
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
             response.json()["detail"], "Gravatar was downloaded and set as new avatar."
@@ -138,7 +138,7 @@ class UserAvatarTests(AuthenticatedUserTestCase):
 
     def test_generation_request(self):
         """generated avatar is set"""
-        response = self.client.post(self.link, data={'avatar': 'generated'})
+        response = self.client.post(self.link, data={"avatar": "generated"})
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
             response.json()["detail"], "New avatar based on your account was set."
@@ -148,20 +148,20 @@ class UserAvatarTests(AuthenticatedUserTestCase):
 
     def test_avatar_upload_and_crop(self):
         """avatar can be uploaded and cropped"""
-        response = self.client.post(self.link, data={'avatar': 'upload'})
+        response = self.client.post(self.link, data={"avatar": "upload"})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "No file was sent.",
-        })
+        self.assertEqual(response.json(), {"detail": "No file was sent."})
 
-        with open(TEST_AVATAR_PATH, 'rb') as avatar:
-            response = self.client.post(self.link, data={'avatar': 'upload', 'image': avatar})
+        with open(TEST_AVATAR_PATH, "rb") as avatar:
+            response = self.client.post(
+                self.link, data={"avatar": "upload", "image": avatar}
+            )
             self.assertEqual(response.status_code, 200)
 
             response_json = response.json()
-            self.assertTrue(response_json['crop_tmp'])
+            self.assertTrue(response_json["crop_tmp"])
             self.assertEqual(
-                self.get_current_user().avatar_tmp.url, response_json['crop_tmp']['url']
+                self.get_current_user().avatar_tmp.url, response_json["crop_tmp"]["url"]
             )
 
         avatar = Path(self.get_current_user().avatar_tmp.path)
@@ -170,24 +170,15 @@ class UserAvatarTests(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.link,
-            json.dumps({
-                'avatar': 'crop_tmp',
-                'crop': {
-                    'offset': {
-                        'x': 0,
-                        'y': 0
-                    },
-                    'zoom': 1,
-                },
-            }),
+            json.dumps(
+                {"avatar": "crop_tmp", "crop": {"offset": {"x": 0, "y": 0}, "zoom": 1}}
+            ),
             content_type="application/json",
         )
 
         response_json = response.json()
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(
-            response.json()["detail"], "Uploaded avatar was set."
-        )
+        self.assertEqual(response.json()["detail"], "Uploaded avatar was set.")
 
         self.assertFalse(self.get_current_user().avatar_tmp)
         self.assertOldAvatarsAreDeleted(self.user)
@@ -198,41 +189,25 @@ class UserAvatarTests(AuthenticatedUserTestCase):
 
         response = self.client.post(
             self.link,
-            json.dumps({
-                'avatar': 'crop_tmp',
-                'crop': {
-                    'offset': {
-                        'x': 0,
-                        'y': 0
-                    },
-                    'zoom': 1,
-                },
-            }),
+            json.dumps(
+                {"avatar": "crop_tmp", "crop": {"offset": {"x": 0, "y": 0}, "zoom": 1}}
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "This avatar type is not allowed.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This avatar type is not allowed."}
+        )
 
         response = self.client.post(
             self.link,
-            json.dumps({
-                'avatar': 'crop_src',
-                'crop': {
-                    'offset': {
-                        'x': 0,
-                        'y': 0
-                    },
-                    'zoom': 1,
-                },
-            }),
+            json.dumps(
+                {"avatar": "crop_src", "crop": {"offset": {"x": 0, "y": 0}, "zoom": 1}}
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(
-            response.json()["detail"], "Avatar was re-cropped."
-        )
+        self.assertEqual(response.json()["detail"], "Avatar was re-cropped.")
         self.assertOldAvatarsAreDeleted(self.user)
 
         # delete user avatars, test if it deletes src and tmp
@@ -249,11 +224,13 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
-        response = self.client.post(self.link, data={'avatar': 'galleries', 'image': 123})
+        response = self.client.post(
+            self.link, data={"avatar": "galleries", "image": 123}
+        )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "This avatar type is not allowed.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This avatar type is not allowed."}
+        )
 
     def test_gallery_image_validation(self):
         """gallery validates image to set"""
@@ -263,58 +240,41 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
 
         # no image id is handled
-        response = self.client.post(
-            self.link,
-            data={
-                'avatar': 'galleries',
-            },
-        )
+        response = self.client.post(self.link, data={"avatar": "galleries"})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Incorrect image.",
-        })
+        self.assertEqual(response.json(), {"detail": "Incorrect image."})
 
         # invalid id is handled
         response = self.client.post(
-            self.link,
-            data={
-                'avatar': 'galleries',
-                'image': 'asdsadsadsa',
-            },
+            self.link, data={"avatar": "galleries", "image": "asdsadsadsa"}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Incorrect image.",
-        })
+        self.assertEqual(response.json(), {"detail": "Incorrect image."})
 
         # nonexistant image is handled
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
         options = response.json()
-        self.assertTrue(options['galleries'])
+        self.assertTrue(options["galleries"])
 
-        test_avatar = options['galleries'][0]['images'][0]['id']
+        test_avatar = options["galleries"][0]["images"][0]["id"]
         response = self.client.post(
-            self.link,
-            data={
-                'avatar': 'galleries',
-                'image': test_avatar + 5000,
-            },
+            self.link, data={"avatar": "galleries", "image": test_avatar + 5000}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Incorrect image.",
-        })
+        self.assertEqual(response.json(), {"detail": "Incorrect image."})
 
         # default gallery image is handled
-        AvatarGallery.objects.filter(pk=test_avatar).update(gallery=gallery.DEFAULT_GALLERY)
+        AvatarGallery.objects.filter(pk=test_avatar).update(
+            gallery=gallery.DEFAULT_GALLERY
+        )
 
-        response = self.client.post(self.link, data={'avatar': 'galleries', 'image': test_avatar})
+        response = self.client.post(
+            self.link, data={"avatar": "galleries", "image": test_avatar}
+        )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Incorrect image.",
-        })
+        self.assertEqual(response.json(), {"detail": "Incorrect image."})
 
     def test_gallery_set_valid_avatar(self):
         """its possible to set avatar from gallery"""
@@ -324,20 +284,14 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
 
         options = response.json()
-        self.assertTrue(options['galleries'])
+        self.assertTrue(options["galleries"])
 
-        test_avatar = options['galleries'][0]['images'][0]['id']
+        test_avatar = options["galleries"][0]["images"][0]["id"]
         response = self.client.post(
-            self.link,
-            data={
-                'avatar': 'galleries',
-                'image': test_avatar,
-            },
+            self.link, data={"avatar": "galleries", "image": test_avatar}
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(
-            response.json()["detail"], "Avatar from gallery was set."
-        )
+        self.assertEqual(response.json()["detail"], "Avatar from gallery was set.")
         self.assertOldAvatarsAreDeleted(self.user)
 
 
@@ -347,41 +301,45 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.other_user = User.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})
+    @patch_user_acl({"can_moderate_avatars": 0})
     def test_no_permission(self):
         """no permission to moderate avatar"""
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't moderate avatars.",
-        })
+        self.assertEqual(response.json(), {"detail": "You can't moderate avatars."})
 
-    @patch_user_acl({'can_moderate_avatars': 1})
+    @patch_user_acl({"can_moderate_avatars": 1})
     def test_moderate_avatar(self):
         """moderate avatar"""
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
         options = response.json()
-        self.assertEqual(options['is_avatar_locked'], self.other_user.is_avatar_locked)
+        self.assertEqual(options["is_avatar_locked"], self.other_user.is_avatar_locked)
         self.assertEqual(
-            options['avatar_lock_user_message'], self.other_user.avatar_lock_user_message
+            options["avatar_lock_user_message"],
+            self.other_user.avatar_lock_user_message,
         )
         self.assertEqual(
-            options['avatar_lock_staff_message'], self.other_user.avatar_lock_staff_message
+            options["avatar_lock_staff_message"],
+            self.other_user.avatar_lock_staff_message,
         )
 
         response = self.client.post(
             self.link,
-            json.dumps({
-                'is_avatar_locked': True,
-                'avatar_lock_user_message': "Test user message.",
-                'avatar_lock_staff_message': "Test staff message.",
-            }),
+            json.dumps(
+                {
+                    "is_avatar_locked": True,
+                    "avatar_lock_user_message": "Test user message.",
+                    "avatar_lock_staff_message": "Test staff message.",
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -393,20 +351,24 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         self.assertEqual(other_user.avatar_lock_user_message, "Test user message.")
         self.assertEqual(other_user.avatar_lock_staff_message, "Test staff message.")
 
-        self.assertEqual(options['avatars'], other_user.avatars)
-        self.assertEqual(options['is_avatar_locked'], other_user.is_avatar_locked)
-        self.assertEqual(options['avatar_lock_user_message'], other_user.avatar_lock_user_message)
+        self.assertEqual(options["avatars"], other_user.avatars)
+        self.assertEqual(options["is_avatar_locked"], other_user.is_avatar_locked)
         self.assertEqual(
-            options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
+            options["avatar_lock_user_message"], other_user.avatar_lock_user_message
+        )
+        self.assertEqual(
+            options["avatar_lock_staff_message"], other_user.avatar_lock_staff_message
         )
 
         response = self.client.post(
             self.link,
-            json.dumps({
-                'is_avatar_locked': False,
-                'avatar_lock_user_message': None,
-                'avatar_lock_staff_message': None,
-            }),
+            json.dumps(
+                {
+                    "is_avatar_locked": False,
+                    "avatar_lock_user_message": None,
+                    "avatar_lock_staff_message": None,
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
@@ -417,61 +379,67 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         self.assertIsNone(other_user.avatar_lock_staff_message)
 
         options = response.json()
-        self.assertEqual(options['avatars'], other_user.avatars)
-        self.assertEqual(options['is_avatar_locked'], other_user.is_avatar_locked)
-        self.assertEqual(options['avatar_lock_user_message'], other_user.avatar_lock_user_message)
+        self.assertEqual(options["avatars"], other_user.avatars)
+        self.assertEqual(options["is_avatar_locked"], other_user.is_avatar_locked)
         self.assertEqual(
-            options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
+            options["avatar_lock_user_message"], other_user.avatar_lock_user_message
+        )
+        self.assertEqual(
+            options["avatar_lock_staff_message"], other_user.avatar_lock_staff_message
         )
 
         response = self.client.post(
             self.link,
-            json.dumps({
-                'is_avatar_locked': True,
-                'avatar_lock_user_message': '',
-                'avatar_lock_staff_message': '',
-            }),
+            json.dumps(
+                {
+                    "is_avatar_locked": True,
+                    "avatar_lock_user_message": "",
+                    "avatar_lock_staff_message": "",
+                }
+            ),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
         other_user = User.objects.get(pk=self.other_user.pk)
         self.assertTrue(other_user.is_avatar_locked)
-        self.assertEqual(other_user.avatar_lock_user_message, '')
-        self.assertEqual(other_user.avatar_lock_staff_message, '')
+        self.assertEqual(other_user.avatar_lock_user_message, "")
+        self.assertEqual(other_user.avatar_lock_staff_message, "")
 
         options = response.json()
-        self.assertEqual(options['avatars'], other_user.avatars)
-        self.assertEqual(options['is_avatar_locked'], other_user.is_avatar_locked)
-        self.assertEqual(options['avatar_lock_user_message'], other_user.avatar_lock_user_message)
+        self.assertEqual(options["avatars"], other_user.avatars)
+        self.assertEqual(options["is_avatar_locked"], other_user.is_avatar_locked)
+        self.assertEqual(
+            options["avatar_lock_user_message"], other_user.avatar_lock_user_message
+        )
         self.assertEqual(
-            options['avatar_lock_staff_message'], other_user.avatar_lock_staff_message
+            options["avatar_lock_staff_message"], other_user.avatar_lock_staff_message
         )
 
         response = self.client.post(
             self.link,
-            json.dumps({
-                'is_avatar_locked': False,
-            }),
+            json.dumps({"is_avatar_locked": False}),
             content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
         other_user = User.objects.get(pk=self.other_user.pk)
         self.assertFalse(other_user.is_avatar_locked)
-        self.assertEqual(other_user.avatar_lock_user_message, '')
-        self.assertEqual(other_user.avatar_lock_staff_message, '')
+        self.assertEqual(other_user.avatar_lock_user_message, "")
+        self.assertEqual(other_user.avatar_lock_staff_message, "")
 
         options = response.json()
-        self.assertEqual(options['avatars'], other_user.avatars)
-        self.assertEqual(options['is_avatar_locked'], other_user.is_avatar_locked)
-        self.assertEqual(options['avatar_lock_user_message'], other_user.avatar_lock_user_message)
+        self.assertEqual(options["avatars"], other_user.avatars)
+        self.assertEqual(options["is_avatar_locked"], other_user.is_avatar_locked)
+        self.assertEqual(
+            options["avatar_lock_user_message"], other_user.avatar_lock_user_message
+        )
         self.assertEqual(
-            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})
+    @patch_user_acl({"can_moderate_avatars": 1})
     def test_moderate_own_avatar(self):
         """moderate own avatar"""
-        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)

+ 37 - 69
misago/users/tests/test_user_changeemail_api.py

@@ -13,7 +13,7 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
 
     def setUp(self):
         super().setUp()
-        self.link = '/api/users/%s/change-email/' % self.user.pk
+        self.link = "/api/users/%s/change-email/" % self.user.pk
 
     def test_unsupported_methods(self):
         """api isn't supporting GET"""
@@ -26,98 +26,75 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'new_email': ["This field is required."],
-                'password': ["This field is required."],
-            }
+            response.json(),
+            {
+                "new_email": ["This field is required."],
+                "password": ["This field is required."],
+            },
         )
 
     def test_invalid_password(self):
         """api errors correctly for invalid password"""
         response = self.client.post(
-            self.link,
-            data={
-                'new_email': 'new@email.com',
-                'password': 'Lor3mIpsum',
-            },
+            self.link, data={"new_email": "new@email.com", "password": "Lor3mIpsum"}
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'password': ["Entered password is invalid."]
-        })
+        self.assertEqual(
+            response.json(), {"password": ["Entered password is invalid."]}
+        )
 
     def test_invalid_input(self):
         """api errors correctly for invalid input"""
         response = self.client.post(
-            self.link,
-            data={
-                'new_email': '',
-                'password': self.USER_PASSWORD,
-            },
+            self.link, data={"new_email": "", "password": self.USER_PASSWORD}
         )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'new_email': ["This field may not be blank."],
-        })
+        self.assertEqual(
+            response.json(), {"new_email": ["This field may not be blank."]}
+        )
 
         response = self.client.post(
-            self.link,
-            data={
-                'new_email': 'newmail',
-                'password': self.USER_PASSWORD,
-            },
+            self.link, data={"new_email": "newmail", "password": self.USER_PASSWORD}
         )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'new_email': ["Enter a valid email address."],
-        })
+        self.assertEqual(
+            response.json(), {"new_email": ["Enter a valid email address."]}
+        )
 
     def test_email_taken(self):
         """api validates email usage"""
-        UserModel.objects.create_user('BobBoberson', 'new@email.com', 'Pass.123')
+        UserModel.objects.create_user("BobBoberson", "new@email.com", "Pass.123")
 
         response = self.client.post(
             self.link,
-            data={
-                'new_email': 'new@email.com',
-                'password': self.USER_PASSWORD,
-            },
+            data={"new_email": "new@email.com", "password": self.USER_PASSWORD},
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'new_email': ["This e-mail address is not available."]
-        })
+        self.assertEqual(
+            response.json(), {"new_email": ["This e-mail address is not available."]}
+        )
 
     def test_change_email(self):
         """api allows users to change their e-mail addresses"""
-        new_email = 'new@email.com'
+        new_email = "new@email.com"
 
         response = self.client.post(
-            self.link,
-            data={
-                'new_email': new_email,
-                'password': self.USER_PASSWORD,
-            },
+            self.link, data={"new_email": new_email, "password": self.USER_PASSWORD}
         )
         self.assertEqual(response.status_code, 200)
 
-        self.assertIn('Confirm e-mail change', mail.outbox[0].subject)
+        self.assertIn("Confirm e-mail change", mail.outbox[0].subject)
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
-            if line.startswith('http://'):
-                token = line.rstrip('/').split('/')[-1]
+            if line.startswith("http://"):
+                token = line.rstrip("/").split("/")[-1]
                 break
         else:
             self.fail("E-mail sent didn't contain confirmation url")
 
         response = self.client.get(
-            reverse(
-                'misago:options-confirm-email-change',
-                kwargs={
-                    'token': token,
-                },
-            )
+            reverse("misago:options-confirm-email-change", kwargs={"token": token})
         )
 
         self.assertEqual(response.status_code, 200)
@@ -127,10 +104,10 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
 
     def test_change_email_user_password_whitespace(self):
         """api supports users with whitespace around their passwords"""
-        user_password = ' old password '
-        new_password = ' N3wP@55w0rd '
+        user_password = " old password "
+        new_password = " N3wP@55w0rd "
 
-        new_email = 'new@email.com'
+        new_email = "new@email.com"
 
         self.user.set_password(user_password)
         self.user.save()
@@ -138,29 +115,20 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
         self.login_user(self.user)
 
         response = self.client.post(
-            self.link,
-            data={
-                'new_email': new_email,
-                'password': user_password,
-            },
+            self.link, data={"new_email": new_email, "password": user_password}
         )
         self.assertEqual(response.status_code, 200)
 
-        self.assertIn('Confirm e-mail change', mail.outbox[0].subject)
+        self.assertIn("Confirm e-mail change", mail.outbox[0].subject)
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
-            if line.startswith('http://'):
-                token = line.rstrip('/').split('/')[-1]
+            if line.startswith("http://"):
+                token = line.rstrip("/").split("/")[-1]
                 break
         else:
             self.fail("E-mail sent didn't contain confirmation url")
 
         response = self.client.get(
-            reverse(
-                'misago:options-confirm-email-change',
-                kwargs={
-                    'token': token,
-                },
-            )
+            reverse("misago:options-confirm-email-change", kwargs={"token": token})
         )
 
         self.assertEqual(response.status_code, 200)

+ 34 - 54
misago/users/tests/test_user_changepassword_api.py

@@ -9,7 +9,7 @@ class UserChangePasswordTests(AuthenticatedUserTestCase):
 
     def setUp(self):
         super().setUp()
-        self.link = '/api/users/%s/change-password/' % self.user.pk
+        self.link = "/api/users/%s/change-password/" % self.user.pk
 
     def test_unsupported_methods(self):
         """api isn't supporting GET"""
@@ -22,85 +22,71 @@ class UserChangePasswordTests(AuthenticatedUserTestCase):
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'new_password': ["This field is required."],
-                'password': ["This field is required."],
-            }
+            response.json(),
+            {
+                "new_password": ["This field is required."],
+                "password": ["This field is required."],
+            },
         )
 
     def test_invalid_password(self):
         """api errors correctly for invalid password"""
         response = self.client.post(
-            self.link,
-            data={
-                'new_password': 'N3wP@55w0rd',
-                'password': 'Lor3mIpsum',
-            },
+            self.link, data={"new_password": "N3wP@55w0rd", "password": "Lor3mIpsum"}
         )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'password': ["Entered password is invalid."],
-        })
+        self.assertEqual(
+            response.json(), {"password": ["Entered password is invalid."]}
+        )
 
     def test_blank_input(self):
         """api errors correctly for blank input"""
         response = self.client.post(
-            self.link,
-            data={
-                'new_password': '',
-                'password': self.USER_PASSWORD,
-            },
+            self.link, data={"new_password": "", "password": self.USER_PASSWORD}
         )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'new_password': ["This field may not be blank."],
-        })
+        self.assertEqual(
+            response.json(), {"new_password": ["This field may not be blank."]}
+        )
 
     def test_short_new_pasword(self):
         """api errors correctly for short new password"""
         response = self.client.post(
-            self.link,
-            data={
-                'new_password': 'n',
-                'password': self.USER_PASSWORD,
-            },
+            self.link, data={"new_password": "n", "password": self.USER_PASSWORD}
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'new_password':
-                    ["This password is too short. It must contain at least 7 characters."],
-            }
+            response.json(),
+            {
+                "new_password": [
+                    "This password is too short. It must contain at least 7 characters."
+                ]
+            },
         )
 
     def test_change_password(self):
         """api allows users to change their passwords"""
-        new_password = 'N3wP@55w0rd'
+        new_password = "N3wP@55w0rd"
 
         response = self.client.post(
             self.link,
-            data={
-                'new_password': new_password,
-                'password': self.USER_PASSWORD,
-            },
+            data={"new_password": new_password, "password": self.USER_PASSWORD},
         )
         self.assertEqual(response.status_code, 200)
 
-        self.assertIn('Confirm password change', mail.outbox[0].subject)
+        self.assertIn("Confirm password change", mail.outbox[0].subject)
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
-            if line.startswith('http://'):
-                token = line.rstrip('/').split('/')[-1]
+            if line.startswith("http://"):
+                token = line.rstrip("/").split("/")[-1]
                 break
         else:
             self.fail("E-mail sent didn't contain confirmation url")
 
         response = self.client.get(
-            reverse('misago:options-confirm-password-change', kwargs={
-                'token': token,
-            })
+            reverse("misago:options-confirm-password-change", kwargs={"token": token})
         )
 
         self.assertEqual(response.status_code, 200)
@@ -110,8 +96,8 @@ class UserChangePasswordTests(AuthenticatedUserTestCase):
 
     def test_change_password_with_whitespaces(self):
         """api handles users with whitespaces around their passwords"""
-        old_password = ' old password '
-        new_password = ' N3wP@55w0rd '
+        old_password = " old password "
+        new_password = " N3wP@55w0rd "
 
         self.user.set_password(old_password)
         self.user.save()
@@ -119,26 +105,20 @@ class UserChangePasswordTests(AuthenticatedUserTestCase):
         self.login_user(self.user)
 
         response = self.client.post(
-            self.link,
-            data={
-                'new_password': new_password,
-                'password': old_password,
-            },
+            self.link, data={"new_password": new_password, "password": old_password}
         )
         self.assertEqual(response.status_code, 200)
 
-        self.assertIn('Confirm password change', mail.outbox[0].subject)
+        self.assertIn("Confirm password change", mail.outbox[0].subject)
         for line in [l.strip() for l in mail.outbox[0].body.splitlines()]:
-            if line.startswith('http://'):
-                token = line.rstrip('/').split('/')[-1]
+            if line.startswith("http://"):
+                token = line.rstrip("/").split("/")[-1]
                 break
         else:
             self.fail("E-mail sent didn't contain confirmation url")
 
         response = self.client.get(
-            reverse('misago:options-confirm-password-change', kwargs={
-                'token': token,
-            })
+            reverse("misago:options-confirm-password-change", kwargs={"token": token})
         )
 
         self.assertEqual(response.status_code, 200)

+ 189 - 225
misago/users/tests/test_user_create_api.py

@@ -15,10 +15,10 @@ class UserCreateTests(UserTestCase):
 
     def setUp(self):
         super().setUp()
-        
+
         Agreement.objects.invalidate_cache()
 
-        self.api_link = '/api/users/'
+        self.api_link = "/api/users/"
 
     def tearDown(self):
         Agreement.objects.invalidate_cache()
@@ -27,58 +27,63 @@ class UserCreateTests(UserTestCase):
         """empty request errors with code 400"""
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'username': ['This field is required.'],
-            'email': ['This field is required.'],
-            'password': ['This field is required.'],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "username": ["This field is required."],
+                "email": ["This field is required."],
+                "password": ["This field is required."],
+            },
+        )
 
     def test_invalid_data(self):
         """invalid request data errors with code 400"""
         response = self.client.post(
-            self.api_link,
-            'false',
-            content_type="application/json",
+            self.api_link, "false", content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'username': ['This field is required.'],
-            'email': ['This field is required.'],
-            'password': ['This field is required.'],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "username": ["This field is required."],
+                "email": ["This field is required."],
+                "password": ["This field is required."],
+            },
+        )
 
     def test_authenticated_request(self):
         """authentiated user request errors with code 403"""
         self.login_user(self.get_authenticated_user())
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This action is not available to signed in users."
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "This action is not available to signed in users."},
+        )
 
     @override_dynamic_settings(account_activation="closed")
     def test_registration_off_request(self):
         """registrations off request errors with code 403"""
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "New users registrations are currently closed."
-        })
+        self.assertEqual(
+            response.json(), {"detail": "New users registrations are currently closed."}
+        )
 
     def test_registration_validates_ip_ban(self):
         """api validates ip ban"""
         Ban.objects.create(
             check_type=Ban.IP,
-            banned_value='127.*',
+            banned_value="127.*",
             user_message="You can't register account like this.",
         )
 
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
             },
         )
 
@@ -88,7 +93,7 @@ class UserCreateTests(UserTestCase):
         """api validates ip registration-only ban"""
         Ban.objects.create(
             check_type=Ban.IP,
-            banned_value='127.*',
+            banned_value="127.*",
             user_message="You can't register account like this.",
             registration_only=True,
         )
@@ -96,17 +101,15 @@ class UserCreateTests(UserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
             },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                '__all__': ["You can't register account like this."],
-            }
+            response.json(), {"__all__": ["You can't register account like this."]}
         )
 
     def test_registration_validates_username(self):
@@ -116,44 +119,42 @@ class UserCreateTests(UserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'username': user.username,
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
+                "username": user.username,
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
             },
         )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'username': ["This username is not available."],
-        })
+        self.assertEqual(
+            response.json(), {"username": ["This username is not available."]}
+        )
 
     def test_registration_validates_username_ban(self):
         """api validates username ban"""
         Ban.objects.create(
-            banned_value='totally*',
+            banned_value="totally*",
             user_message="You can't register account like this.",
         )
 
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
             },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'username': ["You can't register account like this."],
-            }
+            response.json(), {"username": ["You can't register account like this."]}
         )
 
     def test_registration_validates_username_registration_ban(self):
         """api validates username registration-only ban"""
         Ban.objects.create(
-            banned_value='totally*',
+            banned_value="totally*",
             user_message="You can't register account like this.",
             registration_only=True,
         )
@@ -161,17 +162,15 @@ class UserCreateTests(UserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
             },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'username': ["You can't register account like this."],
-            }
+            response.json(), {"username": ["You can't register account like this."]}
         )
 
     def test_registration_validates_email(self):
@@ -181,44 +180,44 @@ class UserCreateTests(UserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': user.email,
-                'password': 'LoremP4ssword',
+                "username": "totallyNew",
+                "email": user.email,
+                "password": "LoremP4ssword",
             },
         )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'email': ["This e-mail address is not available."],
-        })
+        self.assertEqual(
+            response.json(), {"email": ["This e-mail address is not available."]}
+        )
 
     def test_registration_validates_email_ban(self):
         """api validates email ban"""
         Ban.objects.create(
             check_type=Ban.EMAIL,
-            banned_value='lorem*',
+            banned_value="lorem*",
             user_message="You can't register account like this.",
         )
 
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
             },
         )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'email': ["You can't register account like this."],
-        })
+        self.assertEqual(
+            response.json(), {"email": ["You can't register account like this."]}
+        )
 
     def test_registration_validates_email_registration_ban(self):
         """api validates email registration-only ban"""
         Ban.objects.create(
             check_type=Ban.EMAIL,
-            banned_value='lorem*',
+            banned_value="lorem*",
             user_message="You can't register account like this.",
             registration_only=True,
         )
@@ -226,208 +225,188 @@ class UserCreateTests(UserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
             },
         )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'email': ["You can't register account like this."],
-        })
+        self.assertEqual(
+            response.json(), {"email": ["You can't register account like this."]}
+        )
 
     def test_registration_requires_password(self):
         """api uses django's validate_password to validate registrations"""
         response = self.client.post(
             self.api_link,
-            data={
-                'username': 'Bob',
-                'email': 'loremipsum@dolor.met',
-                'password': '',
-            },
+            data={"username": "Bob", "email": "loremipsum@dolor.met", "password": ""},
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "password": ["This field is required."],
-        })
+        self.assertEqual(response.json(), {"password": ["This field is required."]})
 
     def test_registration_validates_password(self):
         """api uses django's validate_password to validate registrations"""
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'Bob',
-                'email': 'l.o.r.e.m.i.p.s.u.m@gmail.com',
-                'password': '123',
+                "username": "Bob",
+                "email": "l.o.r.e.m.i.p.s.u.m@gmail.com",
+                "password": "123",
             },
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "email": ["This email is not allowed."],
-            "password": [
-                "This password is too short. It must contain at least 7 characters.",
-                "This password is entirely numeric.",
-            ],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "email": ["This email is not allowed."],
+                "password": [
+                    "This password is too short. It must contain at least 7 characters.",
+                    "This password is entirely numeric.",
+                ],
+            },
+        )
 
     def test_registration_validates_password_similiarity(self):
         """api uses validate_password to validate registrations"""
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'BobBoberson',
-                'email': 'l.o.r.e.m.i.p.s.u.m@gmail.com',
-                'password': 'BobBoberson',
+                "username": "BobBoberson",
+                "email": "l.o.r.e.m.i.p.s.u.m@gmail.com",
+                "password": "BobBoberson",
             },
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "email": ["This email is not allowed."],
-            "password": ["The password is too similar to the username."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "email": ["This email is not allowed."],
+                "password": ["The password is too similar to the username."],
+            },
+        )
 
     @override_dynamic_settings(
-        captcha_type='qa',
-        qa_question='Test',
-        qa_answers='Lorem\nIpsum'
+        captcha_type="qa", qa_question="Test", qa_answers="Lorem\nIpsum"
     )
     def test_registration_validates_captcha(self):
         """api validates captcha"""
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
-                'captcha': 'dolor'
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
+                "captcha": "dolor",
             },
         )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(
-            response.json(), {
-                'captcha': ['Entered answer is incorrect.'],
-            }
-        )
+        self.assertEqual(response.json(), {"captcha": ["Entered answer is incorrect."]})
 
         # valid captcha
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
-                'captcha': 'ipSUM'
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
+                "captcha": "ipSUM",
             },
         )
 
         self.assertEqual(response.status_code, 200)
 
     @override_dynamic_settings(
-        captcha_type='qa',
-        qa_question='',
-        qa_answers='Lorem\n\nIpsum'
+        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': ''
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
+                "captcha": "",
             },
         )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(
-            response.json(), {
-                'captcha': ['Entered answer is incorrect.'],
-            }
-        )
+        self.assertEqual(response.json(), {"captcha": ["Entered answer is incorrect."]})
 
     def test_registration_check_agreement(self):
         """api checks agreement"""
         agreement = Agreement.objects.create(
-            type=Agreement.TYPE_TOS,
-            text="Lorem ipsum",
-            is_active=True,
+            type=Agreement.TYPE_TOS, text="Lorem ipsum", is_active=True
         )
 
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
             },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'terms_of_service': ['This agreement is required.'],
-            }
+            response.json(), {"terms_of_service": ["This agreement is required."]}
         )
 
         # invalid agreement id
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
-                'terms_of_service': agreement.id + 1,
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
+                "terms_of_service": agreement.id + 1,
             },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'terms_of_service': ['This agreement is required.'],
-            }
+            response.json(), {"terms_of_service": ["This agreement is required."]}
         )
 
         # valid agreement id
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
-                'terms_of_service': agreement.id,
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
+                "terms_of_service": agreement.id,
             },
         )
 
         self.assertEqual(response.status_code, 200)
-        
-        user = User.objects.get(email='loremipsum@dolor.met')
+
+        user = User.objects.get(email="loremipsum@dolor.met")
         self.assertEqual(user.agreements, [agreement.id])
         self.assertEqual(user.useragreement_set.count(), 1)
 
     def test_registration_ignore_inactive_agreement(self):
         """api ignores inactive agreement"""
         Agreement.objects.create(
-            type=Agreement.TYPE_TOS,
-            text="Lorem ipsum",
-            is_active=False,
+            type=Agreement.TYPE_TOS, text="Lorem ipsum", is_active=False
         )
 
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'totallyNew',
-                'email': 'loremipsum@dolor.met',
-                'password': 'LoremP4ssword',
-                'terms_of_service': '',
+                "username": "totallyNew",
+                "email": "loremipsum@dolor.met",
+                "password": "LoremP4ssword",
+                "terms_of_service": "",
             },
         )
 
         self.assertEqual(response.status_code, 200)
-        
-        user = User.objects.get(email='loremipsum@dolor.met')
+
+        user = User.objects.get(email="loremipsum@dolor.met")
         self.assertEqual(user.agreements, [])
         self.assertEqual(user.useragreement_set.count(), 0)
 
@@ -436,47 +415,47 @@ class UserCreateTests(UserTestCase):
         response = self.client.post(
             self.api_link,
             data={
-                'username': 'Bob',
-                'email': 'l.o.r.e.m.i.p.s.u.m@gmail.com',
-                'password': 'pas123',
+                "username": "Bob",
+                "email": "l.o.r.e.m.i.p.s.u.m@gmail.com",
+                "password": "pas123",
             },
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "email": ["This email is not allowed."],
-            "password": ["This password is too short. It must contain at least 7 characters."],
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "email": ["This email is not allowed."],
+                "password": [
+                    "This password is too short. It must contain at least 7 characters."
+                ],
+            },
+        )
 
     @override_dynamic_settings(account_activation="none")
     def test_registration_creates_active_user(self):
         """api creates active and signed in user on POST"""
         response = self.client.post(
             self.api_link,
-            data={
-                'username': 'Bob',
-                'email': 'bob@bob.com',
-                'password': 'pass123',
-            },
+            data={"username": "Bob", "email": "bob@bob.com", "password": "pass123"},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'activation': 'active',
-            'username': 'Bob',
-            'email': 'bob@bob.com',
-        })
+        self.assertEqual(
+            response.json(),
+            {"activation": "active", "username": "Bob", "email": "bob@bob.com"},
+        )
 
-        User.objects.get_by_username('Bob')
+        User.objects.get_by_username("Bob")
 
-        test_user = User.objects.get_by_email('bob@bob.com')
+        test_user = User.objects.get_by_email("bob@bob.com")
         self.assertEqual(Online.objects.filter(user=test_user).count(), 1)
 
-        self.assertTrue(test_user.check_password('pass123'))
-        
-        auth_json = self.client.get(reverse('misago:api:auth')).json()
-        self.assertTrue(auth_json['is_authenticated'])
-        self.assertEqual(auth_json['username'], 'Bob')
+        self.assertTrue(test_user.check_password("pass123"))
 
-        self.assertIn('Welcome', mail.outbox[0].subject)
+        auth_json = self.client.get(reverse("misago:api:auth")).json()
+        self.assertTrue(auth_json["is_authenticated"])
+        self.assertEqual(auth_json["username"], "Bob")
+
+        self.assertIn("Welcome", mail.outbox[0].subject)
 
         self.assertEqual(test_user.audittrail_set.count(), 1)
 
@@ -485,75 +464,60 @@ class UserCreateTests(UserTestCase):
         """api creates inactive user on POST"""
         response = self.client.post(
             self.api_link,
-            data={
-                'username': 'Bob',
-                'email': 'bob@bob.com',
-                'password': 'pass123',
-            },
+            data={"username": "Bob", "email": "bob@bob.com", "password": "pass123"},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'activation': 'user',
-            'username': 'Bob',
-            'email': 'bob@bob.com',
-        })
+        self.assertEqual(
+            response.json(),
+            {"activation": "user", "username": "Bob", "email": "bob@bob.com"},
+        )
 
-        auth_json = self.client.get(reverse('misago:api:auth')).json()
-        self.assertFalse(auth_json['is_authenticated'])
+        auth_json = self.client.get(reverse("misago:api:auth")).json()
+        self.assertFalse(auth_json["is_authenticated"])
 
-        User.objects.get_by_username('Bob')
-        User.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):
         """api creates admin activated user on POST"""
         response = self.client.post(
             self.api_link,
-            data={
-                'username': 'Bob',
-                'email': 'bob@bob.com',
-                'password': 'pass123',
-            },
+            data={"username": "Bob", "email": "bob@bob.com", "password": "pass123"},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'activation': 'admin',
-            'username': 'Bob',
-            'email': 'bob@bob.com',
-        })
+        self.assertEqual(
+            response.json(),
+            {"activation": "admin", "username": "Bob", "email": "bob@bob.com"},
+        )
 
-        auth_json = self.client.get(reverse('misago:api:auth')).json()
-        self.assertFalse(auth_json['is_authenticated'])
+        auth_json = self.client.get(reverse("misago:api:auth")).json()
+        self.assertFalse(auth_json["is_authenticated"])
 
-        User.objects.get_by_username('Bob')
-        User.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):
         """api creates user with spaces around password"""
         response = self.client.post(
             self.api_link,
-            data={
-                'username': 'Bob',
-                'email': 'bob@bob.com',
-                'password': ' pass123 ',
-            },
+            data={"username": "Bob", "email": "bob@bob.com", "password": " pass123 "},
         )
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'activation': 'active',
-            'username': 'Bob',
-            'email': 'bob@bob.com',
-        })
+        self.assertEqual(
+            response.json(),
+            {"activation": "active", "username": "Bob", "email": "bob@bob.com"},
+        )
 
-        User.objects.get_by_username('Bob')
+        User.objects.get_by_username("Bob")
 
-        test_user = User.objects.get_by_email('bob@bob.com')
+        test_user = User.objects.get_by_email("bob@bob.com")
         self.assertEqual(Online.objects.filter(user=test_user).count(), 1)
-        self.assertTrue(test_user.check_password(' pass123 '))
+        self.assertTrue(test_user.check_password(" pass123 "))
 
-        self.assertIn('Welcome', mail.outbox[0].subject)
+        self.assertIn("Welcome", mail.outbox[0].subject)

+ 9 - 5
misago/users/tests/test_user_creation.py

@@ -35,16 +35,16 @@ class UserCreationTests(TestCase):
     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")
@@ -73,8 +73,12 @@ class UserCreationTests(TestCase):
 
     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)
+            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)
+            user = User.objects.create_superuser(
+                "User", "test@example.com", is_superuser=False
+            )

+ 9 - 9
misago/users/tests/test_user_datadownloads_api.py

@@ -5,7 +5,7 @@ from misago.users.testutils import AuthenticatedUserTestCase
 class UserDataDownloadsApiTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
-        self.link = '/api/users/%s/data-downloads/' % self.user.pk
+        self.link = "/api/users/%s/data-downloads/" % self.user.pk
 
     def test_get_other_user_exports_anonymous(self):
         """requests to api fails if user is anonymous"""
@@ -13,21 +13,21 @@ class UserDataDownloadsApiTests(AuthenticatedUserTestCase):
 
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You have to sign in to perform this action.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You have to sign in to perform this action."}
+        )
 
     def test_get_other_user_exports(self):
         """requests to api fails if user tries to access other user"""
         other_user = self.get_superuser()
-        link = '/api/users/%s/data-downloads/' % other_user.pk
+        link = "/api/users/%s/data-downloads/" % other_user.pk
 
         response = self.client.get(link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You can't see other users data downloads.",
-        })
-        
+        self.assertEqual(
+            response.json(), {"detail": "You can't see other users data downloads."}
+        )
+
     def test_get_empty_list(self):
         """api returns empy list"""
         self.assertFalse(self.user.datadownload_set.exists())

+ 15 - 33
misago/users/tests/test_user_details_api.py

@@ -12,68 +12,50 @@ class UserDetailsApiTests(AuthenticatedUserTestCase):
     def test_api_has_no_showstoppers(self):
         """api outputs response for freshly created user"""
         response = self.client.get(
-            reverse(
-                'misago:api:user-details',
-                kwargs={
-                    'pk': self.user.pk,
-                }
-            )
+            reverse("misago:api:user-details", kwargs={"pk": self.user.pk})
         )
 
         self.assertEqual(response.status_code, 200)
-        self.assertTrue(response.json()['edit'])
+        self.assertTrue(response.json()["edit"])
 
     def test_api_has_no_showstoppers_old_user(self):
         """api outputs response for freshly created user"""
         self.user.profile_fields = {
-            'gender': 'f',
-            'bio': 'Lorem ipsum dolor met, sit amet elit, si vis pacem bellum.'
+            "gender": "f",
+            "bio": "Lorem ipsum dolor met, sit amet elit, si vis pacem bellum.",
         }
         self.user.save()
 
         response = self.client.get(
-            reverse(
-                'misago:api:user-details',
-                kwargs={
-                    'pk': self.user.pk,
-                }
-            )
+            reverse("misago:api:user-details", kwargs={"pk": self.user.pk})
         )
 
         self.assertEqual(response.status_code, 200)
-        self.assertTrue(response.json()['edit'])
+        self.assertTrue(response.json()["edit"])
 
     def test_other_user(self):
         """api handles scenario when its other user looking at profile"""
-        test_user = UserModel.objects.create_user('BobBoberson', 'bob@test.com', 'bob123456')
-
-        api_link = reverse(
-            'misago:api:user-details',
-            kwargs={
-                'pk': test_user.pk,
-            }
+        test_user = UserModel.objects.create_user(
+            "BobBoberson", "bob@test.com", "bob123456"
         )
 
+        api_link = reverse("misago:api:user-details", kwargs={"pk": test_user.pk})
+
         # moderator has permission to edit details
-        with patch_user_acl({'can_moderate_profile_details': True}):
+        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'])
+            self.assertTrue(response.json()["edit"])
 
         # non-moderator has no permission to edit details
-        with patch_user_acl({'can_moderate_profile_details': False}):
+        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'])
+            self.assertFalse(response.json()["edit"])
 
     def test_nonexistant_user(self):
         """api handles nonexistant users"""
-        api_link = reverse(
-            'misago:api:user-details',
-            kwargs={
-                'pk': self.user.pk + 123,
-            }
-        )
+        api_link = reverse("misago:api:user-details", kwargs={"pk": self.user.pk + 123})
 
         response = self.client.get(api_link)
         self.assertEqual(response.status_code, 404)

+ 32 - 49
misago/users/tests/test_user_editdetails_api.py

@@ -13,10 +13,7 @@ class UserEditDetailsApiTests(AuthenticatedUserTestCase):
         super().setUp()
 
         self.api_link = reverse(
-            'misago:api:user-edit-details',
-            kwargs={
-                'pk': self.user.pk,
-            }
+            "misago:api:user-edit-details", kwargs={"pk": self.user.pk}
         )
 
     def get_profile_fields(self):
@@ -34,32 +31,26 @@ class UserEditDetailsApiTests(AuthenticatedUserTestCase):
 
     def test_other_user(self):
         """api handles scenario when its other user looking at profile"""
-        test_user = UserModel.objects.create_user('BobBoberson', 'bob@test.com', 'bob123456')
-
-        api_link = reverse(
-            'misago:api:user-edit-details',
-            kwargs={
-                'pk': test_user.pk,
-            }
+        test_user = UserModel.objects.create_user(
+            "BobBoberson", "bob@test.com", "bob123456"
         )
 
+        api_link = reverse("misago:api:user-edit-details", kwargs={"pk": test_user.pk})
+
         # moderator has permission to edit details
-        with patch_user_acl({'can_moderate_profile_details': True}):
+        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
-        with patch_user_acl({'can_moderate_profile_details': False}):
+        with patch_user_acl({"can_moderate_profile_details": False}):
             response = self.client.get(api_link)
             self.assertEqual(response.status_code, 403)
 
     def test_nonexistant_user(self):
         """api handles nonexistant users"""
         api_link = reverse(
-            'misago:api:user-edit-details',
-            kwargs={
-                'pk': self.user.pk + 123,
-            }
+            "misago:api:user-edit-details", kwargs={"pk": self.user.pk + 123}
         )
 
         response = self.client.get(api_link)
@@ -67,72 +58,64 @@ class UserEditDetailsApiTests(AuthenticatedUserTestCase):
 
     def test_api_updates_text_field(self):
         """api updates text field"""
-        response = self.client.post(self.api_link, data={
-            'bio': 'I have some, as is tradition.'
-        })
+        response = self.client.post(
+            self.api_link, data={"bio": "I have some, as is tradition."}
+        )
         self.assertEqual(response.status_code, 200)
 
         profile_fields = self.get_profile_fields()
-        self.assertEqual(profile_fields['bio'], 'I have some, as is tradition.')
+        self.assertEqual(profile_fields["bio"], "I have some, as is tradition.")
 
         response_json = response.json()
-        self.assertEqual(response_json['id'], self.user.id)
-        self.assertTrue(response_json['edit'])
-        self.assertTrue(response_json['groups'])
+        self.assertEqual(response_json["id"], self.user.id)
+        self.assertTrue(response_json["edit"])
+        self.assertTrue(response_json["groups"])
 
     def test_api_updates_select_field(self):
         """api updates select field"""
-        response = self.client.post(self.api_link, data={
-            'gender': 'female',
-        })
+        response = self.client.post(self.api_link, data={"gender": "female"})
 
         self.assertEqual(response.status_code, 200)
 
         profile_fields = self.get_profile_fields()
-        self.assertEqual(profile_fields['gender'], 'female')
+        self.assertEqual(profile_fields["gender"], "female")
 
         response_json = response.json()
-        self.assertEqual(response_json['id'], self.user.id)
-        self.assertTrue(response_json['edit'])
-        self.assertTrue(response_json['groups'])
+        self.assertEqual(response_json["id"], self.user.id)
+        self.assertTrue(response_json["edit"])
+        self.assertTrue(response_json["groups"])
 
     def test_api_validates_url_field(self):
         """api runs basic validation against url fields"""
-        response = self.client.post(self.api_link, data={
-            'website': 'noturl',
-        })
+        response = self.client.post(self.api_link, data={"website": "noturl"})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {'website': ['Enter a valid URL.']})
+        self.assertEqual(response.json(), {"website": ["Enter a valid URL."]})
 
     def test_api_cleans_url_field(self):
         """api cleans url fields"""
-        response = self.client.post(self.api_link, data={
-            'website': 'onet.pl',
-        })
+        response = self.client.post(self.api_link, data={"website": "onet.pl"})
 
         self.assertEqual(response.status_code, 200)
 
         profile_fields = self.get_profile_fields()
-        self.assertEqual(profile_fields['website'], 'http://onet.pl')
+        self.assertEqual(profile_fields["website"], "http://onet.pl")
 
         response_json = response.json()
-        self.assertEqual(response_json['id'], self.user.id)
-        self.assertTrue(response_json['edit'])
-        self.assertTrue(response_json['groups'])
+        self.assertEqual(response_json["id"], self.user.id)
+        self.assertTrue(response_json["edit"])
+        self.assertTrue(response_json["groups"])
 
     def test_api_custom_cleans_url_field(self):
         """api calls fields clean method"""
-        response = self.client.post(self.api_link, data={
-            'twitter': '@Weebl',
-        })
+        response = self.client.post(self.api_link, data={"twitter": "@Weebl"})
 
         self.assertEqual(response.status_code, 200)
 
         profile_fields = self.get_profile_fields()
-        self.assertEqual(profile_fields['twitter'], 'Weebl')
+        self.assertEqual(profile_fields["twitter"], "Weebl")
 
         response_json = response.json()
-        self.assertEqual(response_json['id'], self.user.id)
-        self.assertTrue(response_json['edit'])
-        self.assertTrue(response_json['groups'])
+        self.assertEqual(response_json["id"], self.user.id)
+        self.assertTrue(response_json["edit"])
+        self.assertTrue(response_json["groups"])

+ 37 - 89
misago/users/tests/test_user_feeds_api.py

@@ -8,29 +8,17 @@ class UserThreadsApiTests(ThreadsApiTestCase):
     def setUp(self):
         super().setUp()
 
-        self.api_link = reverse(
-            'misago:api:user-threads', kwargs={
-                'pk': self.user.pk,
-            }
-        )
+        self.api_link = reverse("misago:api:user-threads", kwargs={"pk": self.user.pk})
 
     def test_invalid_user_id(self):
         """api validates user id"""
-        link = reverse(
-            'misago:api:user-threads', kwargs={
-                'pk': 'abcd',
-            }
-        )
+        link = reverse("misago:api:user-threads", kwargs={"pk": "abcd"})
         response = self.client.get(link)
         self.assertEqual(response.status_code, 404)
 
     def test_nonexistant_user_id(self):
         """api validates that user for id exists"""
-        link = reverse(
-            'misago:api:user-threads', kwargs={
-                'pk': self.user.pk + 1,
-            }
-        )
+        link = reverse("misago:api:user-threads", kwargs={"pk": self.user.pk + 1})
         response = self.client.get(link)
         self.assertEqual(response.status_code, 404)
 
@@ -38,7 +26,7 @@ class UserThreadsApiTests(ThreadsApiTestCase):
         """api has no showstopers on empty response"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 0)
+        self.assertEqual(response.json()["count"], 0)
 
     def test_user_post(self):
         """user post doesn't show in feed because its not first post in thread"""
@@ -46,77 +34,55 @@ class UserThreadsApiTests(ThreadsApiTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 0)
+        self.assertEqual(response.json()["count"], 0)
 
     def test_user_event(self):
         """events don't show in feeds at all"""
-        testutils.reply_thread(
-            self.thread,
-            poster=self.user,
-            is_event=True,
-        )
+        testutils.reply_thread(self.thread, poster=self.user, is_event=True)
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 0)
+        self.assertEqual(response.json()["count"], 0)
 
     def test_user_thread(self):
         """user thread shows in feed"""
-        thread = testutils.post_thread(
-            category=self.category,
-            poster=self.user,
-        )
+        thread = testutils.post_thread(category=self.category, poster=self.user)
 
         # this post will not show in feed
         testutils.reply_thread(thread, poster=self.user)
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 1)
-        self.assertEqual(response.json()['results'][0]['id'], thread.first_post_id)
+        self.assertEqual(response.json()["count"], 1)
+        self.assertEqual(response.json()["results"][0]["id"], thread.first_post_id)
 
     def test_user_thread_anonymous(self):
         """user thread shows in feed requested by unauthenticated user"""
-        thread = testutils.post_thread(
-            category=self.category,
-            poster=self.user,
-        )
+        thread = testutils.post_thread(category=self.category, poster=self.user)
 
         self.logout_user()
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 1)
-        self.assertEqual(response.json()['results'][0]['id'], thread.first_post_id)
+        self.assertEqual(response.json()["count"], 1)
+        self.assertEqual(response.json()["results"][0]["id"], thread.first_post_id)
 
 
 class UserPostsApiTests(ThreadsApiTestCase):
     def setUp(self):
         super().setUp()
 
-        self.api_link = reverse(
-            'misago:api:user-posts', kwargs={
-                'pk': self.user.pk,
-            }
-        )
+        self.api_link = reverse("misago:api:user-posts", kwargs={"pk": self.user.pk})
 
     def test_invalid_user_id(self):
         """api validates user id"""
-        link = reverse(
-            'misago:api:user-posts', kwargs={
-                'pk': 'abcd',
-            }
-        )
+        link = reverse("misago:api:user-posts", kwargs={"pk": "abcd"})
         response = self.client.get(link)
         self.assertEqual(response.status_code, 404)
 
     def test_nonexistant_user_id(self):
         """api validates that user for id exists"""
-        link = reverse(
-            'misago:api:user-posts', kwargs={
-                'pk': self.user.pk + 1,
-            }
-        )
+        link = reverse("misago:api:user-posts", kwargs={"pk": self.user.pk + 1})
         response = self.client.get(link)
         self.assertEqual(response.status_code, 404)
 
@@ -124,43 +90,31 @@ class UserPostsApiTests(ThreadsApiTestCase):
         """api has no showstopers on empty response"""
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 0)
+        self.assertEqual(response.json()["count"], 0)
 
     def test_user_event(self):
         """events don't show in feeds at all"""
-        testutils.reply_thread(
-            self.thread,
-            poster=self.user,
-            is_event=True,
-        )
+        testutils.reply_thread(self.thread, poster=self.user, is_event=True)
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 0)
+        self.assertEqual(response.json()["count"], 0)
 
     def test_user_hidden_post(self):
         """hidden posts don't show in feeds at all"""
-        testutils.reply_thread(
-            self.thread,
-            poster=self.user,
-            is_hidden=True,
-        )
+        testutils.reply_thread(self.thread, poster=self.user, is_hidden=True)
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 0)
+        self.assertEqual(response.json()["count"], 0)
 
     def test_user_unapproved_post(self):
         """unapproved posts don't show in feeds at all"""
-        testutils.reply_thread(
-            self.thread,
-            poster=self.user,
-            is_unapproved=True,
-        )
+        testutils.reply_thread(self.thread, poster=self.user, is_unapproved=True)
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 0)
+        self.assertEqual(response.json()["count"], 0)
 
     def test_user_posts(self):
         """user posts show in feed"""
@@ -169,23 +123,20 @@ class UserPostsApiTests(ThreadsApiTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 2)
-        self.assertEqual(response.json()['results'][0]['id'], other_post.pk)
-        self.assertEqual(response.json()['results'][1]['id'], post.pk)
+        self.assertEqual(response.json()["count"], 2)
+        self.assertEqual(response.json()["results"][0]["id"], other_post.pk)
+        self.assertEqual(response.json()["results"][1]["id"], post.pk)
 
     def test_user_thread(self):
         """user thread shows in feed"""
-        thread = testutils.post_thread(
-            category=self.category,
-            poster=self.user,
-        )
+        thread = testutils.post_thread(category=self.category, poster=self.user)
         post = testutils.reply_thread(thread, poster=self.user)
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 2)
-        self.assertEqual(response.json()['results'][0]['id'], post.pk)
-        self.assertEqual(response.json()['results'][1]['id'], thread.first_post_id)
+        self.assertEqual(response.json()["count"], 2)
+        self.assertEqual(response.json()["results"][0]["id"], post.pk)
+        self.assertEqual(response.json()["results"][1]["id"], thread.first_post_id)
 
     def test_user_post_anonymous(self):
         """user post shows in feed requested by unauthenticated user"""
@@ -196,22 +147,19 @@ class UserPostsApiTests(ThreadsApiTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 2)
-        self.assertEqual(response.json()['results'][0]['id'], other_post.pk)
-        self.assertEqual(response.json()['results'][1]['id'], post.pk)
+        self.assertEqual(response.json()["count"], 2)
+        self.assertEqual(response.json()["results"][0]["id"], other_post.pk)
+        self.assertEqual(response.json()["results"][1]["id"], post.pk)
 
     def test_user_thread_anonymous(self):
         """user thread shows in feed requested by unauthenticated user"""
-        thread = testutils.post_thread(
-            category=self.category,
-            poster=self.user,
-        )
+        thread = testutils.post_thread(category=self.category, poster=self.user)
         post = testutils.reply_thread(thread, poster=self.user)
 
         self.logout_user()
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['count'], 2)
-        self.assertEqual(response.json()['results'][0]['id'], post.pk)
-        self.assertEqual(response.json()['results'][1]['id'], thread.first_post_id)
+        self.assertEqual(response.json()["count"], 2)
+        self.assertEqual(response.json()["results"][0]["id"], post.pk)
+        self.assertEqual(response.json()["results"][1]["id"], thread.first_post_id)

+ 11 - 11
misago/users/tests/test_user_middleware.py

@@ -9,8 +9,8 @@ class UserMiddlewareTest(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.api_link = reverse('misago:api:auth')
-        self.test_link = reverse('misago:index')
+        self.api_link = reverse("misago:api:auth")
+        self.test_link = reverse("misago:index")
 
     def test_banned_user(self):
         """middleware handles user that has been banned in meantime"""
@@ -21,7 +21,7 @@ class UserMiddlewareTest(AuthenticatedUserTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertIsNone(response.json()['id'])
+        self.assertIsNone(response.json()["id"])
 
     def test_banned_staff(self):
         """middleware handles staff user that has been banned in meantime"""
@@ -35,44 +35,44 @@ class UserMiddlewareTest(AuthenticatedUserTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['id'], self.user.pk)
+        self.assertEqual(response.json()["id"], self.user.pk)
 
     def test_registration_only_ban(self):
         """middleware ignores registration only bans"""
         Ban.objects.create(
             check_type=Ban.USERNAME,
-            banned_value='%s*' % self.user.username[:3],
+            banned_value="%s*" % self.user.username[:3],
             registration_only=True,
         )
-        
+
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 200)
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['id'], self.user.pk)
+        self.assertEqual(response.json()["id"], self.user.pk)
 
     def test_ip_banned_user(self):
         """middleware handles user that has been banned in meantime"""
-        ban_ip('127.0.0.1')
+        ban_ip("127.0.0.1")
 
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 200)
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertIsNone(response.json()['id'])
+        self.assertIsNone(response.json()["id"])
 
     def test_ip_banned_staff(self):
         """middleware handles staff user that has been banned in meantime"""
         self.user.is_staff = True
         self.user.save()
 
-        ban_ip('127.0.0.1')
+        ban_ip("127.0.0.1")
 
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 200)
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json()['id'], self.user.pk)
+        self.assertEqual(response.json()["id"], self.user.pk)

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

@@ -12,7 +12,7 @@ from misago.users.models import Avatar, User
 class UserModelTests(TestCase):
     def test_anonymize_data(self):
         """anonymize_data sets username and slug to one defined in settings"""
-        user = User.objects.create_user('Bob', 'bob@example.com', 'Pass.123')
+        user = User.objects.create_user("Bob", "bob@example.com", "Pass.123")
 
         user.anonymize_data()
         self.assertEqual(user.username, settings.MISAGO_ANONYMOUS_USERNAME)
@@ -20,7 +20,7 @@ class UserModelTests(TestCase):
 
     def test_delete_avatar_on_delete(self):
         """account deletion for user also deletes their avatar file"""
-        user = User.objects.create_user('Bob', 'bob@example.com', 'Pass.123')
+        user = User.objects.create_user("Bob", "bob@example.com", "Pass.123")
         dynamic.set_avatar(user)
         user.save()
 
@@ -31,7 +31,7 @@ class UserModelTests(TestCase):
             self.assertTrue(avatar_path.is_file())
             user_avatars.append(avatar)
         self.assertNotEqual(user_avatars, [])
-        
+
         user.delete()
 
         for removed_avatar in user_avatars:
@@ -46,25 +46,25 @@ class UserModelTests(TestCase):
         """set_username sets username and slug on model"""
         user = User()
 
-        user.set_username('Boberson')
-        self.assertEqual(user.username, 'Boberson')
-        self.assertEqual(user.slug, 'boberson')
+        user.set_username("Boberson")
+        self.assertEqual(user.username, "Boberson")
+        self.assertEqual(user.slug, "boberson")
 
-        self.assertEqual(user.get_username(), 'Boberson')
-        self.assertEqual(user.get_full_name(), 'Boberson')
-        self.assertEqual(user.get_short_name(), 'Boberson')
+        self.assertEqual(user.get_username(), "Boberson")
+        self.assertEqual(user.get_full_name(), "Boberson")
+        self.assertEqual(user.get_short_name(), "Boberson")
 
     def test_set_email(self):
         """set_email sets email and hash on model"""
         user = User()
 
-        user.set_email('bOb@TEst.com')
-        self.assertEqual(user.email, 'bOb@test.com')
+        user.set_email("bOb@TEst.com")
+        self.assertEqual(user.email, "bOb@test.com")
         self.assertTrue(user.email_hash)
 
     def test_mark_for_delete(self):
         """mark_for_delete deactivates user and sets is_deleting_account flag"""
-        user = User.objects.create_user('Bob', 'bob@example.com', 'Pass.123')
+        user = User.objects.create_user("Bob", "bob@example.com", "Pass.123")
         user.mark_for_delete()
         self.assertFalse(user.is_active)
         self.assertTrue(user.is_deleting_account)
@@ -75,8 +75,8 @@ class UserModelTests(TestCase):
 
     def test_get_real_name(self):
         """get_real_name returns user-set real name or none"""
-        user = User.objects.create_user('Bob', 'bob@example.com', 'Pass.123')
+        user = User.objects.create_user("Bob", "bob@example.com", "Pass.123")
         self.assertIsNone(user.get_real_name())
 
-        user.profile_fields['real_name'] = 'Bob Boberson'
-        self.assertEqual(user.get_real_name(), 'Bob Boberson')
+        user.profile_fields["real_name"] = "Bob Boberson"
+        self.assertEqual(user.get_real_name(), "Bob Boberson")

+ 17 - 17
misago/users/tests/test_user_requestdatadownload_api.py

@@ -7,7 +7,7 @@ from misago.users.testutils import AuthenticatedUserTestCase
 class UserRequestDataDownload(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
-        self.link = '/api/users/%s/request-data-download/' % self.user.pk
+        self.link = "/api/users/%s/request-data-download/" % self.user.pk
 
     def test_request_other_user_download_anonymous(self):
         """requests to api fails if user is anonymous"""
@@ -15,29 +15,28 @@ class UserRequestDataDownload(AuthenticatedUserTestCase):
 
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "This action is not available to guests.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
 
     def test_request_other_user_download(self):
         """requests to api fails if user tries to access other user"""
         other_user = self.get_superuser()
-        link = '/api/users/%s/request-data-download/' % other_user.pk
+        link = "/api/users/%s/request-data-download/" % other_user.pk
 
         response = self.client.post(link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You can't request data downloads for other users.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't request data downloads for other users."},
+        )
 
     @override_settings(MISAGO_ENABLE_DOWNLOAD_OWN_DATA=False)
     def test_request_download_disabled(self):
         """request to api fails if own data downloads are disabled"""
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You can't download your data.",
-        })
+        self.assertEqual(response.json(), {"detail": "You can't download your data."})
 
     def test_request_download_in_progress(self):
         """request to api fails if user has already requested data download"""
@@ -45,16 +44,17 @@ class UserRequestDataDownload(AuthenticatedUserTestCase):
 
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You can't have more than one data download request at single time.",
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "You can't have more than one data download request at single time."
+            },
+        )
 
     def test_request_download(self):
         """request to api succeeds"""
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), {
-            'detail': "ok",
-        })
+        self.assertEqual(response.json(), {"detail": "ok"})
 
         self.assertTrue(self.user.datadownload_set.exists())

+ 30 - 41
misago/users/tests/test_user_signature_api.py

@@ -7,32 +7,36 @@ class UserSignatureTests(AuthenticatedUserTestCase):
 
     def setUp(self):
         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})
+    @patch_user_acl({"can_have_signature": 0})
     def test_signature_no_permission(self):
         """edit signature api with no ACL returns 403"""
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You don't have permission to change signature.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You don't have permission to change signature."},
+        )
 
-    @patch_user_acl({'can_have_signature': 1})
+    @patch_user_acl({"can_have_signature": 1})
     def test_signature_locked(self):
         """locked edit signature returns 403"""
         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()
 
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "Your signature is locked. You can't change it.",
-            "reason": "<p>Your siggy is banned.</p>",
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "Your signature is locked. You can't change it.",
+                "reason": "<p>Your siggy is banned.</p>",
+            },
+        )
 
-    @patch_user_acl({'can_have_signature': 1})
+    @patch_user_acl({"can_have_signature": 1})
     def test_get_signature(self):
         """GET to api returns json with no signature"""
         self.user.is_signature_locked = False
@@ -41,58 +45,43 @@ class UserSignatureTests(AuthenticatedUserTestCase):
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
-        self.assertFalse(response.json()['signature'])
+        self.assertFalse(response.json()["signature"])
 
-    @patch_user_acl({'can_have_signature': 1})
+    @patch_user_acl({"can_have_signature": 1})
     def test_post_empty_signature(self):
         """empty POST empties user signature"""
         self.user.is_signature_locked = False
         self.user.save()
 
-        response = self.client.post(
-            self.link,
-            data={
-                'signature': '',
-            },
-        )
+        response = self.client.post(self.link, data={"signature": ""})
         self.assertEqual(response.status_code, 200)
 
-        self.assertFalse(response.json()['signature'])
+        self.assertFalse(response.json()["signature"])
 
-    @patch_user_acl({'can_have_signature': 1})
+    @patch_user_acl({"can_have_signature": 1})
     def test_post_too_long_signature(self):
         """too long new signature errors"""
         self.user.is_signature_locked = False
         self.user.save()
 
-        response = self.client.post(
-            self.link,
-            data={
-                'signature': 'abcd' * 1000,
-            },
-        )
+        response = self.client.post(self.link, data={"signature": "abcd" * 1000})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Signature is too long.",
-        })
+        self.assertEqual(response.json(), {"detail": "Signature is too long."})
 
-    @patch_user_acl({'can_have_signature': 1})
+    @patch_user_acl({"can_have_signature": 1})
     def test_post_good_signature(self):
         """POST with good signature changes user signature"""
         self.user.is_signature_locked = False
         self.user.save()
 
-        response = self.client.post(
-            self.link,
-            data={
-                'signature': 'Hello, **bros**!',
-            },
-        )
+        response = self.client.post(self.link, data={"signature": "Hello, **bros**!"})
         self.assertEqual(response.status_code, 200)
         self.assertEqual(
-            response.json()['signature']['html'], '<p>Hello, <strong>bros</strong>!</p>'
+            response.json()["signature"]["html"], "<p>Hello, <strong>bros</strong>!</p>"
         )
-        self.assertEqual(response.json()['signature']['plain'], 'Hello, **bros**!')
+        self.assertEqual(response.json()["signature"]["plain"], "Hello, **bros**!")
 
         self.reload_user()
-        self.assertEqual(self.user.signature_parsed, '<p>Hello, <strong>bros</strong>!</p>')
+        self.assertEqual(
+            self.user.signature_parsed, "<p>Hello, <strong>bros</strong>!</p>"
+        )

+ 57 - 88
misago/users/tests/test_user_username_api.py

@@ -15,7 +15,7 @@ class UserUsernameTests(AuthenticatedUserTestCase):
 
     def setUp(self):
         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):
@@ -25,81 +25,67 @@ class UserUsernameTests(AuthenticatedUserTestCase):
 
         response_json = response.json()
 
-        self.assertIsNotNone(response_json['changes_left'])
-        self.assertEqual(response_json['length_min'], 2)
-        self.assertEqual(response_json['length_max'], 4)
-        self.assertIsNone(response_json['next_on'])
+        self.assertIsNotNone(response_json["changes_left"])
+        self.assertEqual(response_json["length_min"], 2)
+        self.assertEqual(response_json["length_max"], 4)
+        self.assertIsNone(response_json["next_on"])
 
-        for i in range(response_json['changes_left']):
-            self.user.set_username('NewName%s' % i, self.user)
+        for i in range(response_json["changes_left"]):
+            self.user.set_username("NewName%s" % i, self.user)
 
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
-        self.assertEqual(response_json['changes_left'], 0)
-        self.assertIsNotNone(response_json['next_on'])
+        self.assertEqual(response_json["changes_left"], 0)
+        self.assertIsNotNone(response_json["next_on"])
 
     def test_change_username_no_changes_left(self):
         """api returns error 400 if there are no username changes left"""
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
-        for i in range(response.json()['changes_left']):
-            self.user.set_username('NewName%s' % i, self.user)
+        for i in range(response.json()["changes_left"]):
+            self.user.set_username("NewName%s" % i, self.user)
 
         response = self.client.get(self.link)
-        self.assertEqual(response.json()['changes_left'], 0)
+        self.assertEqual(response.json()["changes_left"], 0)
 
-        response = self.client.post(
-            self.link,
-            data={
-                'username': 'Pointless',
-            },
-        )
+        response = self.client.post(self.link, data={"username": "Pointless"})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json()["detail"], "You can't change your username now.")
-        self.assertTrue(self.user.username != 'Pointless')
+        self.assertEqual(
+            response.json()["detail"], "You can't change your username now."
+        )
+        self.assertTrue(self.user.username != "Pointless")
 
     def test_change_username_no_input(self):
         """api returns error 400 if new username is empty"""
         response = self.client.post(self.link, data={})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": 'Enter new username.',
-        })
+        self.assertEqual(response.json(), {"detail": "Enter new username."})
 
     def test_change_username_invalid_name(self):
         """api returns error 400 if new username is wrong"""
-        response = self.client.post(
-            self.link,
-            data={
-                'username': '####',
-            },
-        )
+        response = self.client.post(self.link, data={"username": "####"})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Username can only contain latin alphabet letters and digits.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Username can only contain latin alphabet letters and digits."},
+        )
 
     def test_change_username(self):
         """api changes username and records change"""
         response = self.client.get(self.link)
-        changes_left = response.json()['changes_left']
+        changes_left = response.json()["changes_left"]
 
         old_username = self.user.username
-        new_username = 'NewUsernamu'
+        new_username = "NewUsernamu"
 
-        response = self.client.post(
-            self.link,
-            data={
-                'username': new_username,
-            },
-        )
+        response = self.client.post(self.link, data={"username": new_username})
 
         self.assertEqual(response.status_code, 200)
-        options = response.json()['options']
-        self.assertEqual(changes_left, options['changes_left'] + 1)
+        options = response.json()["options"]
+        self.assertEqual(changes_left, options["changes_left"] + 1)
 
         self.reload_user()
         self.assertEqual(self.user.username, new_username)
@@ -114,26 +100,24 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.other_user = UserModel.objects.create_user("OtherUser", "other@user.com", "pass123")
+        self.other_user = UserModel.objects.create_user(
+            "OtherUser", "other@user.com", "pass123"
+        )
 
-        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})
+    @patch_user_acl({"can_rename_users": 0})
     def test_no_permission(self):
         """no permission to moderate username"""
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't rename users.",
-        })
+        self.assertEqual(response.json(), {"detail": "You can't rename users."})
 
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't rename users.",
-        })
+        self.assertEqual(response.json(), {"detail": "You can't rename users."})
 
-    @patch_user_acl({'can_rename_users': 1})
+    @patch_user_acl({"can_rename_users": 1})
     @override_dynamic_settings(username_length_min=3, username_length_max=12)
     def test_moderate_username(self):
         """moderate username"""
@@ -141,66 +125,51 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
         self.assertEqual(response.status_code, 200)
 
         options = response.json()
-        self.assertEqual(options['length_min'], 3)
-        self.assertEqual(options['length_max'], 12)
+        self.assertEqual(options["length_min"], 3)
+        self.assertEqual(options["length_max"], 12)
 
         response = self.client.post(
-            self.link,
-            json.dumps({
-                'username': '',
-            }),
-            content_type='application/json',
+            self.link, json.dumps({"username": ""}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Enter new username.",
-        })
+        self.assertEqual(response.json(), {"detail": "Enter new username."})
 
         response = self.client.post(
-            self.link,
-            json.dumps({
-                'username': '$$$',
-            }),
-            content_type='application/json',
+            self.link, json.dumps({"username": "$$$"}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Username can only contain latin alphabet letters and digits.",
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "Username can only contain latin alphabet letters and digits."},
+        )
 
         response = self.client.post(
-            self.link,
-            json.dumps({
-                'username': 'a',
-            }),
-            content_type='application/json',
+            self.link, json.dumps({"username": "a"}), content_type="application/json"
         )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            "detail": "Username must be at least 3 characters long.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "Username must be at least 3 characters long."}
+        )
 
         response = self.client.post(
             self.link,
-            json.dumps({
-                'username': 'BobBoberson',
-            }),
-            content_type='application/json',
+            json.dumps({"username": "BobBoberson"}),
+            content_type="application/json",
         )
 
         self.assertEqual(response.status_code, 200)
 
         other_user = UserModel.objects.get(pk=self.other_user.pk)
 
-        self.assertEqual('BobBoberson', other_user.username)
-        self.assertEqual('bobboberson', other_user.slug)
+        self.assertEqual("BobBoberson", other_user.username)
+        self.assertEqual("bobboberson", other_user.slug)
 
         options = response.json()
-        self.assertEqual(options['username'], other_user.username)
-        self.assertEqual(options['slug'], other_user.slug)
+        self.assertEqual(options["username"], other_user.username)
+        self.assertEqual(options["slug"], other_user.slug)
 
-    @patch_user_acl({'can_rename_users': 1})
+    @patch_user_acl({"can_rename_users": 1})
     def test_moderate_own_username(self):
         """moderate own username"""
-        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)

+ 409 - 521
misago/users/tests/test_useradmin_views.py

@@ -17,50 +17,50 @@ User = get_user_model()
 
 
 class UserAdminViewsTests(AdminTestCase):
-    AJAX_HEADER = {'HTTP_X_REQUESTED_WITH': 'XMLHttpRequest'}
+    AJAX_HEADER = {"HTTP_X_REQUESTED_WITH": "XMLHttpRequest"}
 
     def test_link_registered(self):
         """admin index view contains users link"""
-        response = self.client.get(reverse('misago:admin:index'))
+        response = self.client.get(reverse("misago:admin:index"))
 
-        self.assertContains(response, reverse('misago:admin:users:accounts:index'))
+        self.assertContains(response, reverse("misago:admin:users:accounts:index"))
 
     def test_list_view(self):
         """users list view returns 200"""
-        response = self.client.get(reverse('misago:admin:users:accounts:index'))
+        response = self.client.get(reverse("misago:admin:users:accounts:index"))
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.user.username)
 
     def test_list_search(self):
         """users list is searchable"""
-        response = self.client.get(reverse('misago:admin:users:accounts:index'))
+        response = self.client.get(reverse("misago:admin:users:accounts:index"))
         self.assertEqual(response.status_code, 302)
 
-        link_base = response['location']
+        link_base = response["location"]
         response = self.client.get(link_base)
         self.assertEqual(response.status_code, 200)
 
-        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')
+        user_a = create_test_user("Tyrael", "t123@test.com")
+        user_b = create_test_user("Tyrion", "t321@test.com")
+        user_c = create_test_user("Karen", "t432@test.com")
 
         # Search both
-        response = self.client.get('%s&username=tyr' % link_base)
+        response = self.client.get("%s&username=tyr" % link_base)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, user_a.username)
         self.assertContains(response, user_b.username)
 
         # Search tyrion
-        response = self.client.get('%s&username=tyrion' % link_base)
+        response = self.client.get("%s&username=tyrion" % link_base)
         self.assertEqual(response.status_code, 200)
         self.assertNotContains(response, user_a.username)
         self.assertContains(response, user_b.username)
 
         # Search tyrael
-        response = self.client.get('%s&email=t123@test.com' % link_base)
+        response = self.client.get("%s&email=t123@test.com" % link_base)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, user_a.username)
         self.assertNotContains(response, user_b.username)
@@ -69,52 +69,44 @@ class UserAdminViewsTests(AdminTestCase):
         user_c.is_active = False
         user_c.save()
 
-        response = self.client.get('%s&disabled=1' % link_base)
+        response = self.client.get("%s&disabled=1" % link_base)
         self.assertEqual(response.status_code, 200)
         self.assertNotContains(response, user_a.username)
         self.assertNotContains(response, user_b.username)
-        self.assertContains(response, '<del>%s</del>' % user_c.username)
+        self.assertContains(response, "<del>%s</del>" % user_c.username)
 
         # Search requested own account delete
         user_c.is_deleting_account = True
         user_c.save()
 
-        response = self.client.get('%s&is_deleting_account=1' % link_base)
+        response = self.client.get("%s&is_deleting_account=1" % link_base)
         self.assertEqual(response.status_code, 200)
         self.assertNotContains(response, user_a.username)
         self.assertNotContains(response, user_b.username)
-        self.assertContains(response, '<del>%s</del>' % user_c.username)
-        
-        response = self.client.get('%s&disabled=1' % link_base)
+        self.assertContains(response, "<del>%s</del>" % user_c.username)
+
+        response = self.client.get("%s&disabled=1" % link_base)
         self.assertEqual(response.status_code, 200)
         self.assertNotContains(response, user_a.username)
         self.assertNotContains(response, user_b.username)
-        self.assertContains(response, '<del>%s</del>' % user_c.username)
+        self.assertContains(response, "<del>%s</del>" % user_c.username)
 
     def test_mass_activation(self):
         """users list activates multiple users"""
         user_pks = []
         for i in range(10):
             test_user = create_test_user(
-                'Bob%s' % i,
-                'bob%s@test.com' % i,
-                requires_activation=1,
+                "Bob%s" % i, "bob%s@test.com" % i, requires_activation=1
             )
             user_pks.append(test_user.pk)
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'activate',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "activate", "selected_items": user_pks},
         )
         self.assertEqual(response.status_code, 302)
 
-        inactive_qs = User.objects.filter(
-            id__in=user_pks,
-            requires_activation=1,
-        )
+        inactive_qs = User.objects.filter(id__in=user_pks, requires_activation=1)
         self.assertEqual(inactive_qs.count(), 0)
         self.assertIn("has been activated", mail.outbox[0].subject)
 
@@ -123,30 +115,25 @@ class UserAdminViewsTests(AdminTestCase):
         user_pks = []
         for i in range(10):
             test_user = create_test_user(
-                'Bob%s' % i,
-                'bob%s@test.com' % i,
-                requires_activation=1,
+                "Bob%s" % i, "bob%s@test.com" % i, requires_activation=1
             )
             user_pks.append(test_user.pk)
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'ban',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "ban", "selected_items": user_pks},
         )
         self.assertNotContains(response, 'value="ip"')
         self.assertNotContains(response, 'value="ip_first"')
         self.assertNotContains(response, 'value="ip_two"')
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
+            reverse("misago:admin:users:accounts:index"),
             data={
-                'action': 'ban',
-                'selected_items': user_pks,
-                'ban_type': ['usernames', 'emails', 'domains'],
-                'finalize': '',
+                "action": "ban",
+                "selected_items": user_pks,
+                "ban_type": ["usernames", "emails", "domains"],
+                "finalize": "",
             },
         )
         self.assertEqual(response.status_code, 302)
@@ -157,31 +144,35 @@ class UserAdminViewsTests(AdminTestCase):
         user_pks = []
         for i in range(10):
             test_user = create_test_user(
-                'Bob%s' % i,
-                'bob%s@test.com' % i,
-                joined_from_ip='73.95.67.27',
+                "Bob%s" % i,
+                "bob%s@test.com" % i,
+                joined_from_ip="73.95.67.27",
                 requires_activation=1,
             )
             user_pks.append(test_user.pk)
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'ban',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "ban", "selected_items": user_pks},
         )
         self.assertContains(response, 'value="ip"')
         self.assertContains(response, 'value="ip_first"')
         self.assertContains(response, 'value="ip_two"')
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
+            reverse("misago:admin:users:accounts:index"),
             data={
-                'action': 'ban',
-                'selected_items': user_pks,
-                'ban_type': ['usernames', 'emails', 'domains', 'ip', 'ip_first', 'ip_two'],
-                'finalize': '',
+                "action": "ban",
+                "selected_items": user_pks,
+                "ban_type": [
+                    "usernames",
+                    "emails",
+                    "domains",
+                    "ip",
+                    "ip_first",
+                    "ip_two",
+                ],
+                "finalize": "",
             },
         )
         self.assertEqual(response.status_code, 302)
@@ -192,85 +183,70 @@ class UserAdminViewsTests(AdminTestCase):
         user_pks = []
         for i in range(10):
             test_user = create_test_user(
-                'Bob%s' % i,
-                'bob%s@test.com' % i,
-                requires_activation=1,
+                "Bob%s" % i, "bob%s@test.com" % i, requires_activation=1
             )
             user_pks.append(test_user.pk)
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'request_data_download',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "request_data_download", "selected_items": user_pks},
         )
         self.assertEqual(response.status_code, 302)
 
-        self.assertEqual(DataDownload.objects.filter(user_id__in=user_pks).count(), len(user_pks))
+        self.assertEqual(
+            DataDownload.objects.filter(user_id__in=user_pks).count(), len(user_pks)
+        )
 
     def test_mass_request_data_download_avoid_excessive_downloads(self):
         """users list avoids excessive data download requests for multiple users"""
         user_pks = []
         for i in range(10):
             test_user = create_test_user(
-                'Bob%s' % i,
-                'bob%s@test.com' % i,
-                requires_activation=1,
+                "Bob%s" % i, "bob%s@test.com" % i, requires_activation=1
             )
             request_user_data_download(test_user)
             user_pks.append(test_user.pk)
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'v',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "v", "selected_items": user_pks},
         )
         self.assertEqual(response.status_code, 302)
 
-        self.assertEqual(DataDownload.objects.filter(user_id__in=user_pks).count(), len(user_pks))
+        self.assertEqual(
+            DataDownload.objects.filter(user_id__in=user_pks).count(), len(user_pks)
+        )
 
     def test_mass_delete_accounts_self(self):
         """its impossible to delete oneself"""
         user_pks = [self.user.pk]
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'delete_accounts',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "delete_accounts", "selected_items": user_pks},
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, "delete yourself")
 
     def test_mass_delete_accounts_admin(self):
         """its impossible to delete admin account"""
         user_pks = []
         for i in range(10):
-            test_user = create_test_user(
-                'Bob%s' % i,
-                'bob%s@test.com' % i,
-            )
+            test_user = create_test_user("Bob%s" % i, "bob%s@test.com" % i)
             user_pks.append(test_user.pk)
 
             test_user.is_staff = True
             test_user.save()
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'delete_accounts',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "delete_accounts", "selected_items": user_pks},
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "be deleted.")
 
@@ -280,25 +256,19 @@ class UserAdminViewsTests(AdminTestCase):
         """its impossible to delete superadmin account"""
         user_pks = []
         for i in range(10):
-            test_user = create_test_user(
-                'Bob%s' % i,
-                'bob%s@test.com' % i,
-            )
+            test_user = create_test_user("Bob%s" % i, "bob%s@test.com" % i)
             user_pks.append(test_user.pk)
 
             test_user.is_superuser = True
             test_user.save()
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'delete_accounts',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "delete_accounts", "selected_items": user_pks},
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "be deleted.")
 
@@ -310,26 +280,19 @@ class UserAdminViewsTests(AdminTestCase):
         user_pks = []
         for i in range(10):
             test_user = create_test_user(
-                'Bob%s' % i,
-                'bob%s@test.com' % i,
-                requires_activation=0,
+                "Bob%s" % i, "bob%s@test.com" % i, requires_activation=0
             )
             user_pks.append(test_user.pk)
 
         # create 10 more users that won't be deleted
         for i in range(10):
             test_user = create_test_user(
-                'Weebl%s' % i,
-                'weebl%s@test.com' % i,
-                requires_activation=0,
+                "Weebl%s" % i, "weebl%s@test.com" % i, requires_activation=0
             )
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'delete_accounts',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "delete_accounts", "selected_items": user_pks},
         )
         self.assertEqual(response.status_code, 302)
         self.assertEqual(User.objects.count(), 11)
@@ -339,40 +302,31 @@ class UserAdminViewsTests(AdminTestCase):
         user_pks = [self.user.pk]
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'delete_all',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "delete_all", "selected_items": user_pks},
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, "delete yourself")
 
     def test_mass_delete_all_admin(self):
         """its impossible to delete admin account and content"""
         user_pks = []
         for i in range(10):
-            test_user = create_test_user(
-                'Bob%s' % i,
-                'bob%s@test.com' % i,
-            )
+            test_user = create_test_user("Bob%s" % i, "bob%s@test.com" % i)
             user_pks.append(test_user.pk)
 
             test_user.is_staff = True
             test_user.save()
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'delete_all',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "delete_all", "selected_items": user_pks},
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "be deleted.")
 
@@ -382,25 +336,19 @@ class UserAdminViewsTests(AdminTestCase):
         """its impossible to delete superadmin account and content"""
         user_pks = []
         for i in range(10):
-            test_user = create_test_user(
-                'Bob%s' % i,
-                'bob%s@test.com' % i,
-            )
+            test_user = create_test_user("Bob%s" % i, "bob%s@test.com" % i)
             user_pks.append(test_user.pk)
 
             test_user.is_superuser = True
             test_user.save()
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'delete_all',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "delete_all", "selected_items": user_pks},
         )
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(response['location'])
+        response = self.client.get(response["location"])
         self.assertContains(response, "is admin and can")
         self.assertContains(response, "be deleted.")
 
@@ -411,83 +359,76 @@ class UserAdminViewsTests(AdminTestCase):
         user_pks = []
         for i in range(10):
             test_user = create_test_user(
-                'Bob%s' % i,
-                'bob%s@test.com' % i,
-                requires_activation=1,
+                "Bob%s" % i, "bob%s@test.com" % i, requires_activation=1
             )
             user_pks.append(test_user.pk)
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:index'),
-            data={
-                'action': 'delete_all',
-                'selected_items': user_pks,
-            }
+            reverse("misago:admin:users:accounts:index"),
+            data={"action": "delete_all", "selected_items": user_pks},
         )
         self.assertEqual(response.status_code, 200)
-         # asser that no user has been deleted, because actuall deleting happens in
-         # dedicated views called via ajax from JavaScript
+        # asser that no user has been deleted, because actuall deleting happens in
+        # dedicated views called via ajax from JavaScript
         self.assertEqual(User.objects.count(), 11)
 
     def test_new_view(self):
         """new user view creates account"""
-        response = self.client.get(reverse('misago:admin:users:accounts:new'))
+        response = self.client.get(reverse("misago:admin:users:accounts:new"))
         self.assertEqual(response.status_code, 200)
 
         default_rank = Rank.objects.get_default()
-        authenticated_role = Role.objects.get(special_role='authenticated')
+        authenticated_role = Role.objects.get(special_role="authenticated")
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:new'),
+            reverse("misago:admin:users:accounts:new"),
             data={
-                'username': 'Bawww',
-                'rank': str(default_rank.pk),
-                'roles': str(authenticated_role.pk),
-                'email': 'reg@stered.com',
-                'new_password': 'pass123',
-                'staff_level': '0',
-            }
+                "username": "Bawww",
+                "rank": str(default_rank.pk),
+                "roles": str(authenticated_role.pk),
+                "email": "reg@stered.com",
+                "new_password": "pass123",
+                "staff_level": "0",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
-        User.objects.get_by_username('Bawww')
-        test_user = User.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_new_view_password_with_whitespaces(self):
         """new user view creates account with whitespaces password"""
-        response = self.client.get(reverse('misago:admin:users:accounts:new'))
+        response = self.client.get(reverse("misago:admin:users:accounts:new"))
         self.assertEqual(response.status_code, 200)
 
         default_rank = Rank.objects.get_default()
-        authenticated_role = Role.objects.get(special_role='authenticated')
+        authenticated_role = Role.objects.get(special_role="authenticated")
 
         response = self.client.post(
-            reverse('misago:admin:users:accounts:new'),
+            reverse("misago:admin:users:accounts:new"),
             data={
-                'username': 'Bawww',
-                'rank': str(default_rank.pk),
-                'roles': str(authenticated_role.pk),
-                'email': 'reg@stered.com',
-                'new_password': ' pass123 ',
-                'staff_level': '0',
-            }
+                "username": "Bawww",
+                "rank": str(default_rank.pk),
+                "roles": str(authenticated_role.pk),
+                "email": "reg@stered.com",
+                "new_password": " pass123 ",
+                "staff_level": "0",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
-        User.objects.get_by_username('Bawww')
-        test_user = User.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):
         """edit user view changes account"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -496,31 +437,31 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bawww',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'new_password': 'newpass123',
-                'staff_level': '0',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Bawww",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "new_password": "newpass123",
+                "staff_level": "0",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         updated_user = User.objects.get(pk=test_user.pk)
-        self.assertTrue(updated_user.check_password('newpass123'))
-        self.assertEqual(updated_user.username, 'Bawww')
-        self.assertEqual(updated_user.slug, 'bawww')
+        self.assertTrue(updated_user.check_password("newpass123"))
+        self.assertEqual(updated_user.username, "Bawww")
+        self.assertEqual(updated_user.slug, "bawww")
 
-        User.objects.get_by_username('Bawww')
-        User.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):
         """
@@ -528,11 +469,9 @@ class UserAdminViewsTests(AdminTestCase):
 
         This is regression test for issue #640
         """
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -541,34 +480,32 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bob',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Bob",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         updated_user = User.objects.get(pk=test_user.pk)
-        self.assertEqual(updated_user.username, 'Bob')
-        self.assertEqual(updated_user.slug, 'bob')
+        self.assertEqual(updated_user.username, "Bob")
+        self.assertEqual(updated_user.slug, "bob")
         self.assertEqual(updated_user.namechanges.count(), 0)
 
     def test_edit_change_password_whitespaces(self):
         """edit user view changes account password to include whitespaces"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -577,39 +514,37 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bawww',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'new_password': ' newpass123 ',
-                'staff_level': '0',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Bawww",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "new_password": " newpass123 ",
+                "staff_level": "0",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
         updated_user = User.objects.get(pk=test_user.pk)
-        self.assertTrue(updated_user.check_password(' newpass123 '))
-        self.assertEqual(updated_user.username, 'Bawww')
-        self.assertEqual(updated_user.slug, 'bawww')
+        self.assertTrue(updated_user.check_password(" newpass123 "))
+        self.assertEqual(updated_user.username, "Bawww")
+        self.assertEqual(updated_user.slug, "bawww")
 
-        User.objects.get_by_username('Bawww')
-        User.objects.get_by_email('reg@stered.com')
+        User.objects.get_by_username("Bawww")
+        User.objects.get_by_email("reg@stered.com")
 
     def test_edit_make_admin(self):
         """edit user view allows super admin to make other user admin"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -619,21 +554,21 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bawww',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'is_staff': '1',
-                'is_superuser': '0',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Bawww",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "is_staff": "1",
+                "is_superuser": "0",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
@@ -643,11 +578,9 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_edit_make_superadmin_admin(self):
         """edit user view allows super admin to make other user super admin"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -657,21 +590,21 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bawww',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'is_staff': '0',
-                'is_superuser': '1',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Bawww",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "is_staff": "0",
+                "is_superuser": "1",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
@@ -682,16 +615,11 @@ class UserAdminViewsTests(AdminTestCase):
     def test_edit_denote_superadmin(self):
         """edit user view allows super admin to denote other super admin"""
         test_user = create_test_user(
-            'Bob',
-            'bob@test.com',
-            is_staff=True,
-            is_superuser=True,
+            "Bob", "bob@test.com", is_staff=True, is_superuser=True
         )
 
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -701,21 +629,21 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bawww',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'is_staff': '0',
-                'is_superuser': '0',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Bawww",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "is_staff": "0",
+                "is_superuser": "0",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
@@ -728,11 +656,9 @@ class UserAdminViewsTests(AdminTestCase):
         self.user.is_superuser = False
         self.user.save()
 
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -742,21 +668,21 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bawww',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'is_staff': '1',
-                'is_superuser': '1',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-            }
+                "username": "Bawww",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "is_staff": "1",
+                "is_superuser": "1",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
@@ -769,11 +695,9 @@ class UserAdminViewsTests(AdminTestCase):
         self.user.is_superuser = False
         self.user.save()
 
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -783,23 +707,23 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bawww',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'is_staff': '0',
-                'is_superuser': '0',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-                'is_active': '0',
-                'is_active_staff_message': "Disabled in test!"
-            }
+                "username": "Bawww",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "is_staff": "0",
+                "is_superuser": "0",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+                "is_active": "0",
+                "is_active_staff_message": "Disabled in test!",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
@@ -812,15 +736,13 @@ class UserAdminViewsTests(AdminTestCase):
         self.user.is_superuser = True
         self.user.save()
 
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
 
         test_user.is_staff = True
         test_user.save()
 
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -830,23 +752,23 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bawww',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'is_staff': '1',
-                'is_superuser': '0',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-                'is_active': '0',
-                'is_active_staff_message': "Disabled in test!"
-            }
+                "username": "Bawww",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "is_staff": "1",
+                "is_superuser": "0",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+                "is_active": "0",
+                "is_active_staff_message": "Disabled in test!",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
@@ -859,15 +781,13 @@ class UserAdminViewsTests(AdminTestCase):
         self.user.is_superuser = False
         self.user.save()
 
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
 
         test_user.is_staff = True
         test_user.save()
 
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -877,23 +797,23 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bawww',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'is_staff': '1',
-                'is_superuser': '0',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-                'is_active': '0',
-                'is_active_staff_message': "Disabled in test!"
-            }
+                "username": "Bawww",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "is_staff": "1",
+                "is_superuser": "0",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+                "is_active": "0",
+                "is_active_staff_message": "Disabled in test!",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
@@ -903,13 +823,11 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_edit_is_deleting_account_cant_reactivate(self):
         """users deleting own accounts can't be reactivated"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_user.mark_for_delete()
 
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -919,22 +837,22 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bawww',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'is_staff': '1',
-                'is_superuser': '0',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-                'is_active': '1',
-            }
+                "username": "Bawww",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "is_staff": "1",
+                "is_superuser": "0",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+                "is_active": "1",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
@@ -944,13 +862,11 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_edit_unusable_password(self):
         """admin edit form handles unusable passwords and lets setting new password"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         self.assertFalse(test_user.has_usable_password())
 
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -959,23 +875,23 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bawww',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'new_password': 'pass123',
-                'is_staff': '1',
-                'is_superuser': '0',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-                'is_active': '1',
-            }
+                "username": "Bawww",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "new_password": "pass123",
+                "is_staff": "1",
+                "is_superuser": "0",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+                "is_active": "1",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
@@ -984,13 +900,11 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_edit_keep_unusable_password(self):
         """admin edit form handles unusable passwords and lets admin leave them unchanged"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         self.assertFalse(test_user.has_usable_password())
 
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.get(test_link)
@@ -999,22 +913,22 @@ class UserAdminViewsTests(AdminTestCase):
         response = self.client.post(
             test_link,
             data={
-                'username': 'Bawww',
-                'rank': str(test_user.rank_id),
-                'roles': str(test_user.roles.all()[0].pk),
-                'email': 'reg@stered.com',
-                'is_staff': '1',
-                'is_superuser': '0',
-                'signature': 'Hello world!',
-                'is_signature_locked': '1',
-                'is_hiding_presence': '0',
-                'limits_private_thread_invites_to': '0',
-                'signature_lock_staff_message': 'Staff message',
-                'signature_lock_user_message': 'User message',
-                'subscribe_to_started_threads': '2',
-                'subscribe_to_replied_threads': '2',
-                'is_active': '1',
-            }
+                "username": "Bawww",
+                "rank": str(test_user.rank_id),
+                "roles": str(test_user.roles.all()[0].pk),
+                "email": "reg@stered.com",
+                "is_staff": "1",
+                "is_superuser": "0",
+                "signature": "Hello world!",
+                "is_signature_locked": "1",
+                "is_hiding_presence": "0",
+                "limits_private_thread_invites_to": "0",
+                "signature_lock_staff_message": "Staff message",
+                "signature_lock_user_message": "User message",
+                "subscribe_to_started_threads": "2",
+                "subscribe_to_replied_threads": "2",
+                "is_active": "1",
+            },
         )
         self.assertEqual(response.status_code, 302)
 
@@ -1023,11 +937,9 @@ class UserAdminViewsTests(AdminTestCase):
 
     def test_edit_agreements_list(self):
         """edit view displays list of user's agreements"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_link = reverse(
-            'misago:admin:users:accounts:edit', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:edit", kwargs={"pk": test_user.pk}
         )
 
         agreement = Agreement.objects.create(
@@ -1050,60 +962,52 @@ class UserAdminViewsTests(AdminTestCase):
     def test_delete_threads_view_self(self):
         """delete user threads view validates if user deletes self"""
         test_link = reverse(
-            'misago:admin:users:accounts:delete-threads', kwargs={
-                'pk': self.user.pk,
-            }
+            "misago:admin:users:accounts:delete-threads", kwargs={"pk": self.user.pk}
         )
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:index'))
-        self.assertContains(response, "delete yourself");
+        response = self.client.get(reverse("misago:admin:index"))
+        self.assertContains(response, "delete yourself")
 
     def test_delete_threads_view_staff(self):
         """delete user threads view validates if user deletes staff"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_user.is_staff = True
         test_user.save()
 
         test_link = reverse(
-            'misago:admin:users:accounts:delete-threads', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:delete-threads", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:index'))
-        self.assertContains(response, "is admin and");
+        response = self.client.get(reverse("misago:admin:index"))
+        self.assertContains(response, "is admin and")
 
     def test_delete_threads_view_superuser(self):
         """delete user threads view validates if user deletes superuser"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_user.is_superuser = True
         test_user.save()
 
         test_link = reverse(
-            'misago:admin:users:accounts:delete-threads', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:delete-threads", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:index'))
-        self.assertContains(response, "is admin and");
+        response = self.client.get(reverse("misago:admin:index"))
+        self.assertContains(response, "is admin and")
 
     def test_delete_threads_view(self):
         """delete user threads view deletes threads"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_link = reverse(
-            'misago:admin:users:accounts:delete-threads', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:delete-threads", kwargs={"pk": test_user.pk}
         )
 
         category = Category.objects.all_categories()[:1][0]
@@ -1113,73 +1017,65 @@ class UserAdminViewsTests(AdminTestCase):
         self.assertEqual(response.status_code, 200)
 
         response_dict = response.json()
-        self.assertEqual(response_dict['deleted_count'], 10)
-        self.assertFalse(response_dict['is_completed'])
+        self.assertEqual(response_dict["deleted_count"], 10)
+        self.assertFalse(response_dict["is_completed"])
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 200)
 
         response_dict = response.json()
-        self.assertEqual(response_dict['deleted_count'], 0)
-        self.assertTrue(response_dict['is_completed'])
+        self.assertEqual(response_dict["deleted_count"], 0)
+        self.assertTrue(response_dict["is_completed"])
 
     def test_delete_posts_view_self(self):
         """delete user posts view validates if user deletes self"""
         test_link = reverse(
-            'misago:admin:users:accounts:delete-posts', kwargs={
-                'pk': self.user.pk,
-            }
+            "misago:admin:users:accounts:delete-posts", kwargs={"pk": self.user.pk}
         )
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:index'))
-        self.assertContains(response, "delete yourself");
+        response = self.client.get(reverse("misago:admin:index"))
+        self.assertContains(response, "delete yourself")
 
     def test_delete_posts_view_staff(self):
         """delete user posts view validates if user deletes staff"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_user.is_staff = True
         test_user.save()
 
         test_link = reverse(
-            'misago:admin:users:accounts:delete-posts', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:delete-posts", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:index'))
-        self.assertContains(response, "is admin and");
+        response = self.client.get(reverse("misago:admin:index"))
+        self.assertContains(response, "is admin and")
 
     def test_delete_posts_view_superuser(self):
         """delete user posts view validates if user deletes superuser"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_user.is_superuser = True
         test_user.save()
 
         test_link = reverse(
-            'misago:admin:users:accounts:delete-posts', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:delete-posts", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:index'))
-        self.assertContains(response, "is admin and");
+        response = self.client.get(reverse("misago:admin:index"))
+        self.assertContains(response, "is admin and")
 
     def test_delete_posts_view(self):
         """delete user posts view deletes posts"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_link = reverse(
-            'misago:admin:users:accounts:delete-posts', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:delete-posts", kwargs={"pk": test_user.pk}
         )
 
         category = Category.objects.all_categories()[:1][0]
@@ -1190,77 +1086,69 @@ class UserAdminViewsTests(AdminTestCase):
         self.assertEqual(response.status_code, 200)
 
         response_dict = response.json()
-        self.assertEqual(response_dict['deleted_count'], 10)
-        self.assertFalse(response_dict['is_completed'])
+        self.assertEqual(response_dict["deleted_count"], 10)
+        self.assertFalse(response_dict["is_completed"])
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 200)
 
         response_dict = response.json()
-        self.assertEqual(response_dict['deleted_count'], 0)
-        self.assertTrue(response_dict['is_completed'])
+        self.assertEqual(response_dict["deleted_count"], 0)
+        self.assertTrue(response_dict["is_completed"])
 
     def test_delete_account_view_self(self):
         """delete user account view validates if user deletes self"""
         test_link = reverse(
-            'misago:admin:users:accounts:delete-account', kwargs={
-                'pk': self.user.pk,
-            }
+            "misago:admin:users:accounts:delete-account", kwargs={"pk": self.user.pk}
         )
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:index'))
-        self.assertContains(response, "delete yourself");
+        response = self.client.get(reverse("misago:admin:index"))
+        self.assertContains(response, "delete yourself")
 
     def test_delete_account_view_staff(self):
         """delete user account view validates if user deletes staff"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_user.is_staff = True
         test_user.save()
 
         test_link = reverse(
-            'misago:admin:users:accounts:delete-account', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:delete-account", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:index'))
-        self.assertContains(response, "is admin and");
+        response = self.client.get(reverse("misago:admin:index"))
+        self.assertContains(response, "is admin and")
 
     def test_delete_account_view_superuser(self):
         """delete user account view validates if user deletes superuser"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_user.is_superuser = True
         test_user.save()
 
         test_link = reverse(
-            'misago:admin:users:accounts:delete-account', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:delete-account", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 302)
 
-        response = self.client.get(reverse('misago:admin:index'))
-        self.assertContains(response, "is admin and");
+        response = self.client.get(reverse("misago:admin:index"))
+        self.assertContains(response, "is admin and")
 
     def test_delete_account_view(self):
         """delete user account view deletes user account"""
-        test_user = create_test_user('Bob', 'bob@test.com')
+        test_user = create_test_user("Bob", "bob@test.com")
         test_link = reverse(
-            'misago:admin:users:accounts:delete-account', kwargs={
-                'pk': test_user.pk,
-            }
+            "misago:admin:users:accounts:delete-account", kwargs={"pk": test_user.pk}
         )
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 200)
 
         response_dict = response.json()
-        self.assertTrue(response_dict['is_completed'])
+        self.assertTrue(response_dict["is_completed"])

+ 26 - 22
misago/users/tests/test_usernamechanges_api.py

@@ -5,54 +5,58 @@ from misago.users.testutils import AuthenticatedUserTestCase
 class UsernameChangesApiTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
-        self.link = '/api/username-changes/'
+        self.link = "/api/username-changes/"
 
-    @patch_user_acl({'can_see_users_name_history': False})
+    @patch_user_acl({"can_see_users_name_history": False})
     def test_user_can_always_see_his_name_changes(self):
         """list returns own username changes"""
-        self.user.set_username('NewUsername', self.user)
-        response = self.client.get('%s?user=%s' % (self.link, self.user.pk))
+        self.user.set_username("NewUsername", self.user)
+        response = self.client.get("%s?user=%s" % (self.link, self.user.pk))
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, self.user.username)
 
-    @patch_user_acl({'can_see_users_name_history': True})
+    @patch_user_acl({"can_see_users_name_history": True})
     def test_list_handles_invalid_filter(self):
         """list raises 404 for invalid filter"""
-        self.user.set_username('NewUsername', self.user)
-        response = self.client.get('%s?user=abcd' % self.link)
+        self.user.set_username("NewUsername", self.user)
+        response = self.client.get("%s?user=abcd" % self.link)
         self.assertEqual(response.status_code, 404)
 
-    @patch_user_acl({'can_see_users_name_history': True})
+    @patch_user_acl({"can_see_users_name_history": True})
     def test_list_handles_nonexisting_user(self):
         """list raises 404 for invalid user id"""
-        self.user.set_username('NewUsername', self.user)
-        response = self.client.get('%s?user=142141' % self.link)
+        self.user.set_username("NewUsername", self.user)
+        response = self.client.get("%s?user=142141" % self.link)
         self.assertEqual(response.status_code, 404)
 
-    @patch_user_acl({'can_see_users_name_history': False})
+    @patch_user_acl({"can_see_users_name_history": False})
     def test_list_handles_search(self):
         """list returns found username changes"""
-        self.user.set_username('NewUsername', self.user)
+        self.user.set_username("NewUsername", self.user)
 
-        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.assertContains(response, self.user.username)
 
-        response = self.client.get('%s?user=%s&search=usernew' % (self.link, self.user.pk))
+        response = self.client.get(
+            "%s?user=%s&search=usernew" % (self.link, self.user.pk)
+        )
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json()["count"], 0)
 
-    @patch_user_acl({'can_see_users_name_history': False})
+    @patch_user_acl({"can_see_users_name_history": False})
     def test_list_denies_permission(self):
         """list denies permission for other user (or all) if no access"""
-        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.json(), {
-            "detail": "You don't have permission to see other users name history."
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You don't have permission to see other users name history."},
+        )
 
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You don't have permission to see other users name history."
-        })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You don't have permission to see other users name history."},
+        )

+ 154 - 194
misago/users/tests/test_users_api.py

@@ -23,7 +23,7 @@ class ActivePostersListTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.link = '/api/users/?list=active'
+        self.link = "/api/users/?list=active"
 
         self.category = Category.objects.all_categories()[:1][0]
         self.category.labels = []
@@ -67,7 +67,7 @@ class FollowersListTests(AuthenticatedUserTestCase):
 
     def setUp(self):
         super().setUp()
-        self.link = '/api/users/%s/followers/'
+        self.link = "/api/users/%s/followers/"
 
     def test_nonexistent_user(self):
         """list for non-existing user returns 404"""
@@ -82,9 +82,7 @@ class FollowersListTests(AuthenticatedUserTestCase):
     def test_filled_list(self):
         """user with followers returns 200"""
         test_follower = User.objects.create_user(
-            "TestFollower",
-            "test@follower.com",
-            self.USER_PASSWORD,
+            "TestFollower", "test@follower.com", self.USER_PASSWORD
         )
         self.user.followed_by.add(test_follower)
 
@@ -95,15 +93,13 @@ class FollowersListTests(AuthenticatedUserTestCase):
     def test_filled_list_search(self):
         """followers list is searchable"""
         test_follower = User.objects.create_user(
-            "TestFollower",
-            "test@follower.com",
-            self.USER_PASSWORD,
+            "TestFollower", "test@follower.com", self.USER_PASSWORD
         )
         self.user.followed_by.add(test_follower)
 
         api_link = self.link % self.user.pk
 
-        response = self.client.get('%s?search=%s' % (api_link, 'test'))
+        response = self.client.get("%s?search=%s" % (api_link, "test"))
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, test_follower.username)
 
@@ -113,7 +109,7 @@ class FollowsListTests(AuthenticatedUserTestCase):
 
     def setUp(self):
         super().setUp()
-        self.link = '/api/users/%s/follows/'
+        self.link = "/api/users/%s/follows/"
 
     def test_nonexistent_user(self):
         """list for non-existing user returns 404"""
@@ -128,9 +124,7 @@ class FollowsListTests(AuthenticatedUserTestCase):
     def test_filled_list(self):
         """user with follows returns 200"""
         test_follower = User.objects.create_user(
-            "TestFollower",
-            "test@follower.com",
-            self.USER_PASSWORD,
+            "TestFollower", "test@follower.com", self.USER_PASSWORD
         )
         self.user.follows.add(test_follower)
 
@@ -141,15 +135,13 @@ class FollowsListTests(AuthenticatedUserTestCase):
     def test_filled_list_search(self):
         """follows list is searchable"""
         test_follower = User.objects.create_user(
-            "TestFollower",
-            "test@follower.com",
-            self.USER_PASSWORD,
+            "TestFollower", "test@follower.com", self.USER_PASSWORD
         )
         self.user.follows.add(test_follower)
 
         api_link = self.link % self.user.pk
 
-        response = self.client.get('%s?search=%s' % (api_link, 'test'))
+        response = self.client.get("%s?search=%s" % (api_link, "test"))
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, test_follower.username)
 
@@ -159,7 +151,7 @@ class RankListTests(AuthenticatedUserTestCase):
 
     def setUp(self):
         super().setUp()
-        self.link = '/api/users/?rank=%s'
+        self.link = "/api/users/?rank=%s"
 
     def test_nonexistent_rank(self):
         """list for non-existing rank returns 404"""
@@ -168,11 +160,7 @@ class RankListTests(AuthenticatedUserTestCase):
 
     def test_empty_list(self):
         """tab rank without members returns 200"""
-        test_rank = Rank.objects.create(
-            name="Test rank",
-            slug="test-rank",
-            is_tab=True,
-        )
+        test_rank = Rank.objects.create(name="Test rank", slug="test-rank", is_tab=True)
 
         response = self.client.get(self.link % test_rank.pk)
         self.assertEqual(response.status_code, 200)
@@ -189,7 +177,7 @@ class RankListTests(AuthenticatedUserTestCase):
         """rank list is not searchable"""
         api_link = self.link % self.user.rank.pk
 
-        response = self.client.get('%s&name=%s' % (api_link, 'test'))
+        response = self.client.get("%s&name=%s" % (api_link, "test"))
         self.assertEqual(response.status_code, 404)
 
     def test_filled_list(self):
@@ -203,18 +191,10 @@ class RankListTests(AuthenticatedUserTestCase):
 
     def test_disabled_users(self):
         """api follows disabled users visibility"""
-        test_rank = Rank.objects.create(
-            name="Test rank",
-            slug="test-rank",
-            is_tab=True,
-        )
+        test_rank = Rank.objects.create(name="Test rank", slug="test-rank", is_tab=True)
 
         test_user = User.objects.create_user(
-            'Visible',
-            'visible@te.com',
-            'Pass.123',
-            rank=test_rank,
-            is_active=False,
+            "Visible", "visible@te.com", "Pass.123", rank=test_rank, is_active=False
         )
 
         response = self.client.get(self.link % test_rank.pk)
@@ -233,11 +213,11 @@ class SearchNamesListTests(AuthenticatedUserTestCase):
 
     def setUp(self):
         super().setUp()
-        self.link = '/api/users/?&name='
+        self.link = "/api/users/?&name="
 
     def test_empty_list(self):
         """empty list returns 404"""
-        response = self.client.get(self.link + 'this-user-is-fake')
+        response = self.client.get(self.link + "this-user-is-fake")
         self.assertEqual(response.status_code, 404)
 
     def test_filled_list(self):
@@ -250,12 +230,8 @@ class UserRetrieveTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.test_user = User.objects.create_user('Tyrael', 't123@test.com', 'pass123')
-        self.link = reverse(
-            'misago:api:user-detail', kwargs={
-                'pk': self.test_user.pk,
-            }
-        )
+        self.test_user = User.objects.create_user("Tyrael", "t123@test.com", "pass123")
+        self.link = reverse("misago:api:user-detail", kwargs={"pk": self.test_user.pk})
 
     def test_get_user(self):
         """api user retrieve endpoint has no showstoppers"""
@@ -285,7 +261,7 @@ class UserForumOptionsTests(AuthenticatedUserTestCase):
 
     def setUp(self):
         super().setUp()
-        self.link = '/api/users/%s/forum-options/' % self.user.pk
+        self.link = "/api/users/%s/forum-options/" % self.user.pk
 
     def test_empty_request(self):
         """empty request is handled"""
@@ -293,17 +269,12 @@ class UserForumOptionsTests(AuthenticatedUserTestCase):
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'limits_private_thread_invites_to': [
-                    'This field is required.',
-                ],
-                'subscribe_to_started_threads': [
-                    'This field is required.',
-                ],
-                'subscribe_to_replied_threads': [
-                    'This field is required.',
-                ],
-            }
+            response.json(),
+            {
+                "limits_private_thread_invites_to": ["This field is required."],
+                "subscribe_to_started_threads": ["This field is required."],
+                "subscribe_to_replied_threads": ["This field is required."],
+            },
         )
 
     def test_change_forum_invalid_ranges(self):
@@ -311,25 +282,20 @@ class UserForumOptionsTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.link,
             data={
-                'limits_private_thread_invites_to': 541,
-                'subscribe_to_started_threads': 44,
-                'subscribe_to_replied_threads': 321,
-            }
+                "limits_private_thread_invites_to": 541,
+                "subscribe_to_started_threads": 44,
+                "subscribe_to_replied_threads": 321,
+            },
         )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(
-            response.json(), {
-                'limits_private_thread_invites_to': [
-                    '"541" is not a valid choice.',
-                ],
-                'subscribe_to_started_threads': [
-                    '"44" is not a valid choice.',
-                ],
-                'subscribe_to_replied_threads': [
-                    '"321" is not a valid choice.',
-                ],
-            }
+            response.json(),
+            {
+                "limits_private_thread_invites_to": ['"541" is not a valid choice.'],
+                "subscribe_to_started_threads": ['"44" is not a valid choice.'],
+                "subscribe_to_replied_threads": ['"321" is not a valid choice.'],
+            },
         )
 
     def test_change_forum_options(self):
@@ -337,10 +303,10 @@ class UserForumOptionsTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.link,
             data={
-                'limits_private_thread_invites_to': 1,
-                'subscribe_to_started_threads': 2,
-                'subscribe_to_replied_threads': 1,
-            }
+                "limits_private_thread_invites_to": 1,
+                "subscribe_to_started_threads": 2,
+                "subscribe_to_replied_threads": 1,
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -354,11 +320,11 @@ class UserForumOptionsTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.link,
             data={
-                'is_hiding_presence': True,
-                'limits_private_thread_invites_to': 1,
-                'subscribe_to_started_threads': 2,
-                'subscribe_to_replied_threads': 1,
-            }
+                "is_hiding_presence": True,
+                "limits_private_thread_invites_to": 1,
+                "subscribe_to_started_threads": 2,
+                "subscribe_to_replied_threads": 1,
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -372,11 +338,11 @@ class UserForumOptionsTests(AuthenticatedUserTestCase):
         response = self.client.post(
             self.link,
             data={
-                'is_hiding_presence': False,
-                'limits_private_thread_invites_to': 1,
-                'subscribe_to_started_threads': 2,
-                'subscribe_to_replied_threads': 1,
-            }
+                "is_hiding_presence": False,
+                "limits_private_thread_invites_to": 1,
+                "subscribe_to_started_threads": 2,
+                "subscribe_to_replied_threads": 1,
+            },
         )
         self.assertEqual(response.status_code, 200)
 
@@ -394,9 +360,11 @@ class UserFollowTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.other_user = User.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
 
     def test_follow_unauthenticated(self):
         """you have to sign in to follow users"""
@@ -404,26 +372,24 @@ class UserFollowTests(AuthenticatedUserTestCase):
 
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "This action is not available to guests.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "This action is not available to guests."}
+        )
 
     def test_follow_myself(self):
         """you can't follow yourself"""
-        response = self.client.post('/api/users/%s/follow/' % self.user.pk)
+        response = self.client.post("/api/users/%s/follow/" % self.user.pk)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't add yourself to followed.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't add yourself to followed."}
+        )
 
-    @patch_user_acl({'can_follow_users': 0})
+    @patch_user_acl({"can_follow_users": 0})
     def test_cant_follow(self):
         """no permission to follow users"""
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't follow other users.",
-        })
+        self.assertEqual(response.json(), {"detail": "You can't follow other users."})
 
     def test_follow(self):
         """follow and unfollow other user"""
@@ -464,58 +430,59 @@ class UserBanTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.other_user = User.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})
+    @patch_user_acl({"can_see_ban_details": 0})
     def test_no_permission(self):
         """user has no permission to access ban"""
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            "detail": "You can't see users bans details.",
-        })
+        self.assertEqual(
+            response.json(), {"detail": "You can't see users bans details."}
+        )
 
-    @patch_user_acl({'can_see_ban_details': 1})
+    @patch_user_acl({"can_see_ban_details": 1})
     def test_no_ban(self):
         """api returns empty json"""
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.json(), {})
 
-    @patch_user_acl({'can_see_ban_details': 1})
+    @patch_user_acl({"can_see_ban_details": 1})
     def test_ban_details(self):
         """api returns ban json"""
         Ban.objects.create(
             check_type=Ban.USERNAME,
             banned_value=self.other_user.username,
-            user_message='Nope!',
+            user_message="Nope!",
         )
 
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
         ban_json = response.json()
-        self.assertEqual(ban_json['user_message']['plain'], 'Nope!')
-        self.assertEqual(ban_json['user_message']['html'], '<p>Nope!</p>')
+        self.assertEqual(ban_json["user_message"]["plain"], "Nope!")
+        self.assertEqual(ban_json["user_message"]["html"], "<p>Nope!</p>")
 
 
 class UserDeleteOwnAccountTests(AuthenticatedUserTestCase):
     """tests for user request own account delete RPC (POST to /api/users/1/delete-own-account/)"""
+
     def setUp(self):
         super().setUp()
-        self.api_link = '/api/users/%s/delete-own-account/' % self.user.pk
+        self.api_link = "/api/users/%s/delete-own-account/" % self.user.pk
 
     @override_settings(MISAGO_ENABLE_DELETE_OWN_ACCOUNT=False)
     def test_delete_own_account_feature_disabled(self):
         """raises 403 error when attempting to delete own account but feature is disabled"""
-        response = self.client.post(self.api_link, {'password': self.USER_PASSWORD})
+        response = self.client.post(self.api_link, {"password": self.USER_PASSWORD})
 
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You can't delete your account.",
-        })
+        self.assertEqual(response.json(), {"detail": "You can't delete your account."})
 
         self.reload_user()
         self.assertTrue(self.user.is_active)
@@ -526,11 +493,14 @@ class UserDeleteOwnAccountTests(AuthenticatedUserTestCase):
         self.user.is_staff = True
         self.user.save()
 
-        response = self.client.post(self.api_link, {'password': self.USER_PASSWORD})
+        response = self.client.post(self.api_link, {"password": self.USER_PASSWORD})
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You can't delete your account because you are an administrator.",
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "You can't delete your account because you are an administrator."
+            },
+        )
 
         self.reload_user()
         self.assertTrue(self.user.is_active)
@@ -541,11 +511,14 @@ class UserDeleteOwnAccountTests(AuthenticatedUserTestCase):
         self.user.is_superuser = True
         self.user.save()
 
-        response = self.client.post(self.api_link, {'password': self.USER_PASSWORD})
+        response = self.client.post(self.api_link, {"password": self.USER_PASSWORD})
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You can't delete your account because you are an administrator.",
-        })
+        self.assertEqual(
+            response.json(),
+            {
+                "detail": "You can't delete your account because you are an administrator."
+            },
+        )
 
         self.reload_user()
         self.assertTrue(self.user.is_active)
@@ -553,11 +526,11 @@ class UserDeleteOwnAccountTests(AuthenticatedUserTestCase):
 
     def test_delete_own_account_invalid_password(self):
         """raises 400 error when attempting to delete own account with invalid password"""
-        response = self.client.post(self.api_link, {'password': 'hello'})
+        response = self.client.post(self.api_link, {"password": "hello"})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'password': ["Entered password is invalid."]
-        })
+        self.assertEqual(
+            response.json(), {"password": ["Entered password is invalid."]}
+        )
 
         self.reload_user()
         self.assertTrue(self.user.is_active)
@@ -565,9 +538,9 @@ class UserDeleteOwnAccountTests(AuthenticatedUserTestCase):
 
     def test_delete_own_account(self):
         """deactivates account and marks it for deletion"""
-        response = self.client.post(self.api_link, {'password': self.USER_PASSWORD})
+        response = self.client.post(self.api_link, {"password": self.USER_PASSWORD})
         self.assertEqual(response.status_code, 200)
-        
+
         self.reload_user()
         self.assertFalse(self.user.is_active)
         self.assertTrue(self.user.is_deleting_account)
@@ -579,9 +552,11 @@ class UserDeleteTests(AuthenticatedUserTestCase):
     def setUp(self):
         super().setUp()
 
-        self.other_user = User.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
 
         self.threads = Thread.objects.count()
         self.posts = Post.objects.count()
@@ -593,22 +568,18 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         self.other_user.threads = 1
         self.other_user.save()
 
-    @patch_user_acl({
-        'can_delete_users_newer_than': 0,
-        'can_delete_users_with_less_posts_than': 0,
-    })
+    @patch_user_acl(
+        {"can_delete_users_newer_than": 0, "can_delete_users_with_less_posts_than": 0}
+    )
     def test_delete_no_permission(self):
         """raises 403 error when no permission to delete"""
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You can't delete users.",
-        })
-
-    @patch_user_acl({
-        'can_delete_users_newer_than': 0,
-        'can_delete_users_with_less_posts_than': 5,
-    })
+        self.assertEqual(response.json(), {"detail": "You can't delete users."})
+
+    @patch_user_acl(
+        {"can_delete_users_newer_than": 0, "can_delete_users_with_less_posts_than": 5}
+    )
     def test_delete_too_many_posts(self):
         """raises 403 error when user has too many posts"""
         self.other_user.posts = 6
@@ -616,14 +587,14 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            '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,
-    })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't delete users that made more than 5 posts."},
+        )
+
+    @patch_user_acl(
+        {"can_delete_users_newer_than": 5, "can_delete_users_with_less_posts_than": 0}
+    )
     def test_delete_too_old_member(self):
         """raises 403 error when user is too old"""
         self.other_user.joined_on -= timedelta(days=6)
@@ -632,26 +603,23 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            '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,
-    })
+        self.assertEqual(
+            response.json(),
+            {"detail": "You can't delete users that are members for more than 5 days."},
+        )
+
+    @patch_user_acl(
+        {"can_delete_users_newer_than": 10, "can_delete_users_with_less_posts_than": 10}
+    )
     def test_delete_self(self):
         """raises 403 error when attempting to delete oneself"""
-        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.json(), {
-            'detail': "You can't delete your account.",
-        })
-
-    @patch_user_acl({
-        'can_delete_users_newer_than': 10,
-        'can_delete_users_with_less_posts_than': 10,
-    })
+        self.assertEqual(response.json(), {"detail": "You can't delete your account."})
+
+    @patch_user_acl(
+        {"can_delete_users_newer_than": 10, "can_delete_users_with_less_posts_than": 10}
+    )
     def test_delete_admin(self):
         """raises 403 error when attempting to delete admin"""
         self.other_user.is_staff = True
@@ -659,14 +627,13 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You can't delete administrators.",
-        })
-
-    @patch_user_acl({
-        'can_delete_users_newer_than': 10,
-        'can_delete_users_with_less_posts_than': 10,
-    })
+        self.assertEqual(
+            response.json(), {"detail": "You can't delete administrators."}
+        )
+
+    @patch_user_acl(
+        {"can_delete_users_newer_than": 10, "can_delete_users_with_less_posts_than": 10}
+    )
     def test_delete_superadmin(self):
         """raises 403 error when attempting to delete superadmin"""
         self.other_user.is_superuser = True
@@ -674,22 +641,19 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
-        self.assertEqual(response.json(), {
-            'detail': "You can't delete administrators.",
-        })
-
-    @patch_user_acl({
-        'can_delete_users_newer_than': 10,
-        'can_delete_users_with_less_posts_than': 10,
-    })
+        self.assertEqual(
+            response.json(), {"detail": "You can't delete administrators."}
+        )
+
+    @patch_user_acl(
+        {"can_delete_users_newer_than": 10, "can_delete_users_with_less_posts_than": 10}
+    )
     def test_delete_with_content(self):
         """returns 200 and deletes user with content"""
         response = self.client.post(
             self.link,
-            json.dumps({
-                'with_content': True
-            }),
-            content_type='application/json',
+            json.dumps({"with_content": True}),
+            content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
@@ -699,18 +663,15 @@ class UserDeleteTests(AuthenticatedUserTestCase):
         self.assertEqual(Thread.objects.count(), self.threads)
         self.assertEqual(Post.objects.count(), self.posts)
 
-    @patch_user_acl({
-        'can_delete_users_newer_than': 10,
-        'can_delete_users_with_less_posts_than': 10,
-    })
+    @patch_user_acl(
+        {"can_delete_users_newer_than": 10, "can_delete_users_with_less_posts_than": 10}
+    )
     def test_delete_without_content(self):
         """returns 200 and deletes user without content"""
         response = self.client.post(
             self.link,
-            json.dumps({
-                'with_content': False
-            }),
-            content_type='application/json',
+            json.dumps({"with_content": False}),
+            content_type="application/json",
         )
         self.assertEqual(response.status_code, 200)
 
@@ -719,4 +680,3 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
         self.assertEqual(Thread.objects.count(), self.threads + 1)
         self.assertEqual(Post.objects.count(), self.posts + 2)
-

+ 2 - 2
misago/users/tests/test_utils.py

@@ -6,8 +6,8 @@ from misago.users.utils import hash_email
 class HashEmailTests(TestCase):
     def test_is_case_insensitive(self):
         """util is case insensitive"""
-        self.assertEqual(hash_email('abc@test.com'), hash_email('aBc@tEst.cOm'))
+        self.assertEqual(hash_email("abc@test.com"), hash_email("aBc@tEst.cOm"))
 
     def test_handles_unicode(self):
         """util works with unicode strings"""
-        self.assertEqual(hash_email('łóć@test.com'), hash_email('ŁÓĆ@tEst.cOm'))
+        self.assertEqual(hash_email("łóć@test.com"), hash_email("ŁÓĆ@tEst.cOm"))

+ 47 - 39
misago/users/tests/test_validators.py

@@ -6,9 +6,16 @@ from django.test import TestCase
 
 from misago.users.models import Ban
 from misago.users.validators import (
-    validate_email, validate_email_available, validate_email_banned, validate_gmail_email,
-    validate_username, validate_username_available, validate_username_banned,
-    validate_username_content, validate_username_length)
+    validate_email,
+    validate_email_available,
+    validate_email_banned,
+    validate_gmail_email,
+    validate_username,
+    validate_username_available,
+    validate_username_banned,
+    validate_username_content,
+    validate_username_length,
+)
 
 
 UserModel = get_user_model()
@@ -16,11 +23,13 @@ UserModel = get_user_model()
 
 class ValidateEmailAvailableTests(TestCase):
     def setUp(self):
-        self.test_user = UserModel.objects.create_user('EricTheFish', 'eric@test.com', 'pass123')
+        self.test_user = UserModel.objects.create_user(
+            "EricTheFish", "eric@test.com", "pass123"
+        )
 
     def test_valid_email(self):
         """validate_email_available allows available emails"""
-        validate_email_available('bob@boberson.com')
+        validate_email_available("bob@boberson.com")
         validate_email_available(self.test_user.email, exclude=self.test_user)
 
     def test_invalid_email(self):
@@ -31,45 +40,42 @@ class ValidateEmailAvailableTests(TestCase):
 
 class ValidateEmailBannedTests(TestCase):
     def setUp(self):
-        Ban.objects.create(
-            check_type=Ban.EMAIL,
-            banned_value="ban@test.com",
-        )
+        Ban.objects.create(check_type=Ban.EMAIL, banned_value="ban@test.com")
 
     def test_unbanned_name(self):
         """unbanned email passes validation"""
-        validate_email_banned('noban@test.com')
+        validate_email_banned("noban@test.com")
 
     def test_banned_name(self):
         """banned email fails validation"""
         with self.assertRaises(ValidationError):
-            validate_email_banned('ban@test.com')
+            validate_email_banned("ban@test.com")
 
 
 class ValidateEmailTests(TestCase):
     def test_validate_email(self):
         """validate_email has no crashes"""
-        validate_email('bob@boberson.com')
+        validate_email("bob@boberson.com")
         with self.assertRaises(ValidationError):
-            validate_email('*')
+            validate_email("*")
 
 
 class ValidateUsernameTests(TestCase):
     def test_validate_username(self):
         """validate_username has no crashes"""
         settings = Mock(username_length_min=1, username_length_max=5)
-        validate_username(settings, 'LeBob')
+        validate_username(settings, "LeBob")
         with self.assertRaises(ValidationError):
-            validate_username(settings, '*')
+            validate_username(settings, "*")
 
 
 class ValidateUsernameAvailableTests(TestCase):
     def setUp(self):
-        self.test_user = UserModel.objects.create_user('EricTheFish', 'eric@test.com')
+        self.test_user = UserModel.objects.create_user("EricTheFish", "eric@test.com")
 
     def test_valid_name(self):
         """validate_username_available allows available names"""
-        validate_username_available('BobBoberson')
+        validate_username_available("BobBoberson")
         validate_username_available(self.test_user.username, exclude=self.test_user)
 
     def test_invalid_name(self):
@@ -80,71 +86,73 @@ class ValidateUsernameAvailableTests(TestCase):
 
 class ValidateUsernameBannedTests(TestCase):
     def setUp(self):
-        Ban.objects.create(
-            check_type=Ban.USERNAME,
-            banned_value="Bob",
-        )
+        Ban.objects.create(check_type=Ban.USERNAME, banned_value="Bob")
 
     def test_unbanned_name(self):
         """unbanned name passes validation"""
-        validate_username_banned('Luke')
+        validate_username_banned("Luke")
 
     def test_banned_name(self):
         """banned name fails validation"""
         with self.assertRaises(ValidationError):
-            validate_username_banned('Bob')
+            validate_username_banned("Bob")
 
 
 class ValidateUsernameContentTests(TestCase):
     def test_valid_name(self):
         """validate_username_content allows valid names"""
-        validate_username_content('123')
-        validate_username_content('Bob')
-        validate_username_content('Bob123')
+        validate_username_content("123")
+        validate_username_content("Bob")
+        validate_username_content("Bob123")
 
     def test_invalid_name(self):
         """validate_username_content disallows invalid names"""
         with self.assertRaises(ValidationError):
-            validate_username_content('!')
+            validate_username_content("!")
         with self.assertRaises(ValidationError):
-            validate_username_content('Bob!')
+            validate_username_content("Bob!")
         with self.assertRaises(ValidationError):
-            validate_username_content('Bob Boberson')
+            validate_username_content("Bob Boberson")
         with self.assertRaises(ValidationError):
-            validate_username_content('Rafał')
+            validate_username_content("Rafał")
         with self.assertRaises(ValidationError):
-            validate_username_content('初音 ミク')
+            validate_username_content("初音 ミク")
 
 
 class ValidateUsernameLengthTests(TestCase):
     def test_valid_name(self):
         """validate_username_length allows valid names"""
         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)
+        validate_username_length(settings, "a" * settings.username_length_min)
+        validate_username_length(settings, "a" * settings.username_length_max)
 
     def test_invalid_name(self):
         """validate_username_length disallows invalid names"""
         settings = Mock(username_length_min=1, username_length_max=5)
         with self.assertRaises(ValidationError):
-            validate_username_length(settings, 'a' * (settings.username_length_min - 1))
+            validate_username_length(settings, "a" * (settings.username_length_min - 1))
         with self.assertRaises(ValidationError):
-            validate_username_length(settings, 'a' * (settings.username_length_max + 1))
+            validate_username_length(settings, "a" * (settings.username_length_max + 1))
 
 
 class ValidateGmailEmailTests(TestCase):
     def test_validate_gmail_email(self):
         """validate_gmail_email spots spammy gmail address"""
         added_errors = {}
+
         def add_errors(field_name, errors):
             added_errors[field_name] = errors
 
         validate_gmail_email(None, {}, add_errors)
-        validate_gmail_email(None, {'email': 'invalid-email'}, add_errors)
-        validate_gmail_email(None, {'email': 'the.bob.boberson@gmail.com'}, add_errors)
-        validate_gmail_email(None, {'email': 'the.bob.boberson@hotmail.com'}, add_errors)
+        validate_gmail_email(None, {"email": "invalid-email"}, add_errors)
+        validate_gmail_email(None, {"email": "the.bob.boberson@gmail.com"}, add_errors)
+        validate_gmail_email(
+            None, {"email": "the.bob.boberson@hotmail.com"}, add_errors
+        )
 
         self.assertFalse(added_errors)
 
-        validate_gmail_email(None, {'email': 'the.b.o.b.b.ob.e.r.son@gmail.com'}, add_errors)
+        validate_gmail_email(
+            None, {"email": "the.b.o.b.b.ob.e.r.son@gmail.com"}, add_errors
+        )
         self.assertTrue(added_errors)

+ 1 - 1
misago/users/tests/testfiles/profilefields.py

@@ -3,7 +3,7 @@ class NofieldnameField(object):
 
 
 class FieldnameField(object):
-    fieldname = 'hello'
+    fieldname = "hello"
 
 
 class RepeatedFieldnameField(FieldnameField):

+ 6 - 9
misago/users/testutils.py

@@ -9,7 +9,7 @@ User = get_user_model()
 
 class UserTestCase(TestCase):
     USER_PASSWORD = "Pass.123"
-    USER_IP = '127.0.0.1'
+    USER_IP = "127.0.0.1"
 
     def setUp(self):
         super().setUp()
@@ -23,10 +23,7 @@ class UserTestCase(TestCase):
 
     def get_authenticated_user(self):
         return create_test_user(
-            "TestUser",
-            "test@user.com",
-            self.USER_PASSWORD,
-            joined_from_ip=self.USER_IP,
+            "TestUser", "test@user.com", self.USER_PASSWORD, joined_from_ip=self.USER_IP
         )
 
     def get_superuser(self):
@@ -78,7 +75,7 @@ def create_test_superuser(username, email, password=None, **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"},
-    ]
+    {"size": 400, "url": "http://placekitten.com/400/400"},
+    {"size": 200, "url": "http://placekitten.com/200/200"},
+    {"size": 100, "url": "http://placekitten.com/100/100"},
+]

+ 12 - 8
misago/users/tokens.py

@@ -19,11 +19,13 @@ def make(user, token_type):
     user_hash = _make_hash(user, token_type)
     creation_day = _days_since_epoch()
 
-    obfuscated = base64.b64encode(force_bytes('%s%s' % (user_hash, creation_day))).decode()
-    obfuscated = obfuscated.rstrip('=')
+    obfuscated = base64.b64encode(
+        force_bytes("%s%s" % (user_hash, creation_day))
+    ).decode()
+    obfuscated = obfuscated.rstrip("=")
     checksum = _make_checksum(obfuscated)
 
-    return '%s%s' % (checksum, obfuscated)
+    return "%s%s" % (checksum, obfuscated)
 
 
 def is_valid(user, token_type, token):
@@ -33,7 +35,7 @@ def is_valid(user, token_type, token):
     if checksum != _make_checksum(obfuscated):
         return False
 
-    unobfuscated = base64.b64decode(obfuscated + '=' * (-len(obfuscated) % 4)).decode()
+    unobfuscated = base64.b64decode(obfuscated + "=" * (-len(obfuscated) % 4)).decode()
     user_hash = unobfuscated[:8]
 
     if user_hash != _make_hash(user, token_type):
@@ -53,7 +55,7 @@ def _make_hash(user, token_type):
         settings.SECRET_KEY,
     ]
 
-    return sha256(force_bytes('+'.join([str(s) for s in seeds]))).hexdigest()[:8]
+    return sha256(force_bytes("+".join([str(s) for s in seeds]))).hexdigest()[:8]
 
 
 def _days_since_epoch():
@@ -61,10 +63,12 @@ def _days_since_epoch():
 
 
 def _make_checksum(obfuscated):
-    return sha256(force_bytes('%s:%s' % (settings.SECRET_KEY, obfuscated))).hexdigest()[:8]
+    return sha256(force_bytes("%s:%s" % (settings.SECRET_KEY, obfuscated))).hexdigest()[
+        :8
+    ]
 
 
-ACTIVATION_TOKEN = 'activation'
+ACTIVATION_TOKEN = "activation"
 
 
 def make_activation_token(user):
@@ -75,7 +79,7 @@ def is_activation_token_valid(user, token):
     return is_valid(user, ACTIVATION_TOKEN, token)
 
 
-PASSWORD_CHANGE_TOKEN = 'change_password'
+PASSWORD_CHANGE_TOKEN = "change_password"
 
 
 def make_password_change_token(user):

+ 102 - 54
misago/users/urls/__init__.py

@@ -2,88 +2,136 @@ from django.conf.urls import include, url
 
 from misago.core.views import home_redirect
 
-from misago.users.views import activation, auth, avatarserver, forgottenpassword, lists, options, profile
+from misago.users.views import (
+    activation,
+    auth,
+    avatarserver,
+    forgottenpassword,
+    lists,
+    options,
+    profile,
+)
 
 urlpatterns = [
-    url(r'^banned/$', home_redirect, name='banned'),
-    url(r'^login/$', auth.login, name='login'),
-    url(r'^logout/$', auth.logout, name='logout'),
-    url(r'^request-activation/$', activation.request_activation, name='request-activation'),
+    url(r"^banned/$", home_redirect, name="banned"),
+    url(r"^login/$", auth.login, name="login"),
+    url(r"^logout/$", auth.logout, name="logout"),
     url(
-        r'^activation/(?P<pk>\d+)/(?P<token>[a-zA-Z0-9]+)/$',
+        r"^request-activation/$",
+        activation.request_activation,
+        name="request-activation",
+    ),
+    url(
+        r"^activation/(?P<pk>\d+)/(?P<token>[a-zA-Z0-9]+)/$",
         activation.activate_by_token,
-        name='activate-by-token'
+        name="activate-by-token",
+    ),
+    url(
+        r"^forgotten-password/$",
+        forgottenpassword.request_reset,
+        name="forgotten-password",
     ),
-    url(r'^forgotten-password/$', forgottenpassword.request_reset, name='forgotten-password'),
     url(
-        r'^forgotten-password/(?P<pk>\d+)/(?P<token>[a-zA-Z0-9]+)/$',
+        r"^forgotten-password/(?P<pk>\d+)/(?P<token>[a-zA-Z0-9]+)/$",
         forgottenpassword.reset_password_form,
-        name='forgotten-password-change-form'
+        name="forgotten-password-change-form",
     ),
 ]
 
 urlpatterns += [
-    url(r'^options/$', options.index, name='options'),
-    url(r'^options/(?P<form_name>[-a-zA-Z]+)/$', options.index, name='options-form'),
-    url(r'^options/forum-options/$', options.index, name='usercp-change-forum-options'),
-    url(r'^options/edit-details/$', options.index, name='usercp-edit-details'),
-    url(r'^options/change-username/$', options.index, name='usercp-change-username'),
-    url(r'^options/sign-in-credentials/$', options.index, name='usercp-change-email-password'),
+    url(r"^options/$", options.index, name="options"),
+    url(r"^options/(?P<form_name>[-a-zA-Z]+)/$", options.index, name="options-form"),
+    url(r"^options/forum-options/$", options.index, name="usercp-change-forum-options"),
+    url(r"^options/edit-details/$", options.index, name="usercp-edit-details"),
+    url(r"^options/change-username/$", options.index, name="usercp-change-username"),
     url(
-        r'^options/change-email/(?P<token>[a-zA-Z0-9]+)/$',
+        r"^options/sign-in-credentials/$",
+        options.index,
+        name="usercp-change-email-password",
+    ),
+    url(
+        r"^options/change-email/(?P<token>[a-zA-Z0-9]+)/$",
         options.confirm_email_change,
-        name='options-confirm-email-change'
+        name="options-confirm-email-change",
     ),
     url(
-        r'^options/change-password/(?P<token>[a-zA-Z0-9]+)/$',
+        r"^options/change-password/(?P<token>[a-zA-Z0-9]+)/$",
         options.confirm_password_change,
-        name='options-confirm-password-change'
+        name="options-confirm-password-change",
     ),
-    url(r'^options/dowload-data/$', options.index, name='usercp-download-data'),
-    url(r'^options/delete-account/$', options.index, name='usercp-delete-account'),
+    url(r"^options/dowload-data/$", options.index, name="usercp-download-data"),
+    url(r"^options/delete-account/$", options.index, name="usercp-delete-account"),
 ]
 
 urlpatterns += [
     url(
-        r'^users/',
-        include([
-            url(r'^$', lists.landing, name='users'),
-            url(
-                r'^active-posters/$',
-                lists.ActivePostersView.as_view(),
-                name='users-active-posters'
-            ),
-            url(r'^(?P<slug>[-a-zA-Z0-9]+)/$', lists.RankUsersView.as_view(), name='users-rank'),
-            url(
-                r'^(?P<slug>[-a-zA-Z0-9]+)/(?P<page>\d+)/$',
-                lists.RankUsersView.as_view(),
-                name='users-rank'
-            ),
-        ])
+        r"^users/",
+        include(
+            [
+                url(r"^$", lists.landing, name="users"),
+                url(
+                    r"^active-posters/$",
+                    lists.ActivePostersView.as_view(),
+                    name="users-active-posters",
+                ),
+                url(
+                    r"^(?P<slug>[-a-zA-Z0-9]+)/$",
+                    lists.RankUsersView.as_view(),
+                    name="users-rank",
+                ),
+                url(
+                    r"^(?P<slug>[-a-zA-Z0-9]+)/(?P<page>\d+)/$",
+                    lists.RankUsersView.as_view(),
+                    name="users-rank",
+                ),
+            ]
+        ),
     )
 ]
 
 urlpatterns += [
     url(
-        r'^u/(?P<slug>[a-zA-Z0-9]+)/(?P<pk>\d+)/',
-        include([
-            url(r'^$', profile.LandingView.as_view(), name='user'),
-            url(r'^posts/$', profile.UserPostsView.as_view(), name='user-posts'),
-            url(r'^threads/$', profile.UserThreadsView.as_view(), name='user-threads'),
-            url(r'^followers/$', profile.UserFollowersView.as_view(), name='user-followers'),
-            url(r'^follows/$', profile.UserFollowsView.as_view(), name='user-follows'),
-            url(r'^details/$', profile.UserProfileDetailsView.as_view(), name='user-details'),
-            url(
-                r'^username-history/$',
-                profile.UserUsernameHistoryView.as_view(),
-                name='username-history'
-            ),
-            url(r'^ban-details/$', profile.UserBanView.as_view(), name='user-ban'),
-        ])
+        r"^u/(?P<slug>[a-zA-Z0-9]+)/(?P<pk>\d+)/",
+        include(
+            [
+                url(r"^$", profile.LandingView.as_view(), name="user"),
+                url(r"^posts/$", profile.UserPostsView.as_view(), name="user-posts"),
+                url(
+                    r"^threads/$",
+                    profile.UserThreadsView.as_view(),
+                    name="user-threads",
+                ),
+                url(
+                    r"^followers/$",
+                    profile.UserFollowersView.as_view(),
+                    name="user-followers",
+                ),
+                url(
+                    r"^follows/$",
+                    profile.UserFollowsView.as_view(),
+                    name="user-follows",
+                ),
+                url(
+                    r"^details/$",
+                    profile.UserProfileDetailsView.as_view(),
+                    name="user-details",
+                ),
+                url(
+                    r"^username-history/$",
+                    profile.UserUsernameHistoryView.as_view(),
+                    name="username-history",
+                ),
+                url(r"^ban-details/$", profile.UserBanView.as_view(), name="user-ban"),
+            ]
+        ),
     )
 ]
 
 urlpatterns += [
-    url(r'^avatar/$', avatarserver.blank_avatar, name='blank-avatar'),
-    url(r'^avatar/(?P<pk>\d+)/(?P<size>\d+)/$', avatarserver.user_avatar, name='user-avatar'),
+    url(r"^avatar/$", avatarserver.blank_avatar, name="blank-avatar"),
+    url(
+        r"^avatar/(?P<pk>\d+)/(?P<size>\d+)/$",
+        avatarserver.user_avatar,
+        name="user-avatar",
+    ),
 ]

+ 15 - 11
misago/users/urls/api.py

@@ -8,21 +8,25 @@ from misago.users.api.users import UserViewSet
 
 
 urlpatterns = [
-    url(r'^auth/$', auth.gateway, name='auth'),
-    url(r'^auth/criteria/$', auth.get_criteria, name='auth-criteria'),
-    url(r'^auth/send-activation/$', auth.send_activation, name='send-activation'),
-    url(r'^auth/send-password-form/$', auth.send_password_form, name='send-password-form'),
+    url(r"^auth/$", auth.gateway, name="auth"),
+    url(r"^auth/criteria/$", auth.get_criteria, name="auth-criteria"),
+    url(r"^auth/send-activation/$", auth.send_activation, name="send-activation"),
     url(
-        r'^auth/change-password/(?P<pk>\d+)/(?P<token>[a-zA-Z0-9]+)/$',
+        r"^auth/send-password-form/$",
+        auth.send_password_form,
+        name="send-password-form",
+    ),
+    url(
+        r"^auth/change-password/(?P<pk>\d+)/(?P<token>[a-zA-Z0-9]+)/$",
         auth.change_forgotten_password,
-        name='change-forgotten-password'
+        name="change-forgotten-password",
     ),
-    url(r'^captcha-question/$', captcha.question, name='captcha-question'),
-    url(r'^mention/$', mention.mention_suggestions, name='mention-suggestions'),
+    url(r"^captcha-question/$", captcha.question, name="captcha-question"),
+    url(r"^mention/$", mention.mention_suggestions, name="mention-suggestions"),
 ]
 
 router = MisagoApiRouter()
-router.register(r'ranks', RanksViewSet)
-router.register(r'users', UserViewSet)
-router.register(r'username-changes', UsernameChangesViewSet, base_name='usernamechange')
+router.register(r"ranks", RanksViewSet)
+router.register(r"users", UserViewSet)
+router.register(r"username-changes", UsernameChangesViewSet, base_name="usernamechange")
 urlpatterns += router.urls

+ 1 - 1
misago/users/utils.py

@@ -2,4 +2,4 @@ import hashlib
 
 
 def hash_email(email):
-    return hashlib.md5(email.lower().encode('utf-8')).hexdigest()
+    return hashlib.md5(email.lower().encode("utf-8")).hexdigest()

+ 22 - 18
misago/users/validators.py

@@ -16,13 +16,14 @@ from misago.conf import settings
 from .bans import get_email_ban, get_username_ban
 
 
-USERNAME_RE = re.compile(r'^[0-9a-z]+$', re.IGNORECASE)
+USERNAME_RE = re.compile(r"^[0-9a-z]+$", re.IGNORECASE)
 
 UserModel = get_user_model()
 
 
 # E-mail validators
 
+
 def validate_email(value, exclude=None):
     """shortcut function that does complete validation of email"""
     validate_email_content(value)
@@ -30,7 +31,6 @@ def validate_email(value, exclude=None):
     validate_email_banned(value)
 
 
-
 def validate_email_available(value, exclude=None):
     try:
         user = UserModel.objects.get_by_email(value)
@@ -80,7 +80,9 @@ def validate_username_banned(value):
 
 def validate_username_content(value):
     if not USERNAME_RE.match(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(settings, value):
@@ -88,37 +90,39 @@ def validate_username_length(settings, value):
         message = ngettext(
             "Username must be at least %(limit_value)s character long.",
             "Username must be at least %(limit_value)s characters long.",
-            settings.username_length_min
+            settings.username_length_min,
         )
-        raise ValidationError(message % {'limit_value': settings.username_length_min})
+        raise ValidationError(message % {"limit_value": settings.username_length_min})
 
     if len(value) > settings.username_length_max:
         message = ngettext(
             "Username cannot be longer than %(limit_value)s characters.",
             "Username cannot be longer than %(limit_value)s characters.",
-            settings.username_length_max
+            settings.username_length_max,
         )
-        raise ValidationError(message % {'limit_value': settings.username_length_max})
+        raise ValidationError(message % {"limit_value": settings.username_length_max})
 
 
 # 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"
+)
 
 
 def validate_with_sfs(request, cleaned_data, add_error):
-    if settings.MISAGO_USE_STOP_FORUM_SPAM and cleaned_data.get('email'):
-        _real_validate_with_sfs(request.user_ip, cleaned_data['email'])
+    if settings.MISAGO_USE_STOP_FORUM_SPAM and cleaned_data.get("email"):
+        _real_validate_with_sfs(request.user_ip, cleaned_data["email"])
 
 
 def _real_validate_with_sfs(ip, email):
     try:
-        r = requests.get(SFS_API_URL % {'email': email, 'ip': ip}, timeout=5)
+        r = requests.get(SFS_API_URL % {"email": email, "ip": ip}, timeout=5)
 
         r.raise_for_status()
 
         api_response = json.loads(force_str(r.content))
-        ip_score = api_response.get('ip', {}).get('confidence', 0)
-        email_score = api_response.get('email', {}).get('confidence', 0)
+        ip_score = api_response.get("ip", {}).get("confidence", 0)
+        email_score = api_response.get("email", {}).get("confidence", 0)
 
         api_score = max((ip_score, email_score))
 
@@ -129,13 +133,13 @@ def _real_validate_with_sfs(ip, email):
 
 
 def validate_gmail_email(request, cleaned_data, add_error):
-    email = cleaned_data.get('email', '')
-    if '@' not in email:
+    email = cleaned_data.get("email", "")
+    if "@" not in email:
         return
 
-    username, domain = email.lower().split('@')
-    if domain == 'gmail.com' and username.count('.') > 5:
-        add_error('email', ValidationError(_("This email is not allowed.")))
+    username, domain = email.lower().split("@")
+    if domain == "gmail.com" and username.count(".") > 5:
+        add_error("email", ValidationError(_("This email is not allowed.")))
 
 
 # Registration validation

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

@@ -7,25 +7,25 @@ from misago.users.serializers import UserCardSerializer
 class ActivePosters(object):
     def __init__(self, request):
         ranking = get_active_posters_ranking()
-        make_users_status_aware(request, 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.users = ranking['users']
+        self.users = ranking["users"]
 
     def get_frontend_context(self):
         return {
-            'tracked_period': self.tracked_period,
-            'results': ScoredUserSerializer(self.users, many=True).data,
-            'count': self.count,
+            "tracked_period": self.tracked_period,
+            "results": ScoredUserSerializer(self.users, many=True).data,
+            "count": self.count,
         }
 
     def get_template_context(self):
         return {
-            'tracked_period': self.tracked_period,
-            'users': self.users,
-            'users_count': self.count,
+            "tracked_period": self.tracked_period,
+            "users": self.users,
+            "users_count": self.count,
         }
 
 
-ScoredUserSerializer = UserCardSerializer.extend_fields('meta')
+ScoredUserSerializer = UserCardSerializer.extend_fields("meta")

+ 3 - 6
misago/users/viewmodels/followers.py

@@ -8,7 +8,7 @@ from misago.users.serializers import UserCardSerializer
 
 class Followers(object):
     def __init__(self, request, profile, page=0, search=None):
-        queryset = self.get_queryset(profile).select_related('rank').order_by('slug')
+        queryset = self.get_queryset(profile).select_related("rank").order_by("slug")
 
         if not request.user.is_staff:
             queryset = queryset.filter(is_active=True)
@@ -30,12 +30,9 @@ class Followers(object):
         return profile.followed_by
 
     def get_frontend_context(self):
-        context = {'results': UserCardSerializer(self.users, many=True).data}
+        context = {"results": UserCardSerializer(self.users, many=True).data}
         context.update(self.paginator)
         return context
 
     def get_template_context(self):
-        return {
-            'followers': self.users,
-            'count': self.paginator['count'],
-        }
+        return {"followers": self.users, "count": self.paginator["count"]}

+ 1 - 4
misago/users/viewmodels/follows.py

@@ -6,7 +6,4 @@ class Follows(Followers):
         return profile.follows
 
     def get_template_context(self):
-        return {
-            'follows': self.users,
-            'count': self.paginator['count'],
-        }
+        return {"follows": self.users, "count": self.paginator["count"]}

+ 5 - 3
misago/users/viewmodels/posts.py

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

+ 4 - 9
misago/users/viewmodels/rankusers.py

@@ -7,10 +7,8 @@ from misago.users.serializers import UserCardSerializer
 class RankUsers(object):
     def __init__(self, request, rank, page=0):
         queryset = rank.user_set.select_related(
-            'rank',
-            'ban_cache',
-            'online_tracker',
-        ).order_by('slug')
+            "rank", "ban_cache", "online_tracker"
+        ).order_by("slug")
 
         if not request.user.is_staff:
             queryset = queryset.filter(is_active=True)
@@ -22,12 +20,9 @@ class RankUsers(object):
         self.paginator = pagination_dict(list_page)
 
     def get_frontend_context(self):
-        context = {'results': UserCardSerializer(self.users, many=True).data}
+        context = {"results": UserCardSerializer(self.users, many=True).data}
         context.update(self.paginator)
         return context
 
     def get_template_context(self):
-        return {
-            'users': self.users,
-            'paginator': self.paginator,
-        }
+        return {"users": self.users, "paginator": self.paginator}

+ 25 - 17
misago/users/viewmodels/threads.py

@@ -12,16 +12,21 @@ class UserThreads(object):
         root_category = ThreadsRootCategory(request)
         threads_categories = [root_category.unwrap()] + root_category.subcategories
 
-        threads_queryset = self.get_threads_queryset(request, threads_categories, profile)
+        threads_queryset = self.get_threads_queryset(
+            request, threads_categories, profile
+        )
 
-        posts_queryset = self.get_posts_queryset(request.user, profile, threads_queryset).filter(
-            is_event=False,
-            is_hidden=False,
-            is_unapproved=False,
-        ).order_by('-id')
+        posts_queryset = (
+            self.get_posts_queryset(request.user, profile, threads_queryset)
+            .filter(is_event=False, is_hidden=False, is_unapproved=False)
+            .order_by("-id")
+        )
 
         list_page = paginate(
-            posts_queryset, page, settings.MISAGO_POSTS_PER_PAGE, settings.MISAGO_POSTS_TAIL
+            posts_queryset,
+            page,
+            settings.MISAGO_POSTS_PER_PAGE,
+            settings.MISAGO_POSTS_TAIL,
         )
         paginator = pagination_dict(list_page)
 
@@ -31,7 +36,9 @@ class UserThreads(object):
         for post in posts:
             threads.append(post.thread)
 
-        add_categories_to_items(root_category.unwrap(), threads_categories, posts + threads)
+        add_categories_to_items(
+            root_category.unwrap(), threads_categories, posts + threads
+        )
 
         add_acl_to_obj(request.user_acl, threads)
         add_acl_to_obj(request.user_acl, posts)
@@ -42,16 +49,20 @@ class UserThreads(object):
         self.paginator = paginator
 
     def get_threads_queryset(self, request, threads_categories, profile):
-        return exclude_invisible_threads(request.user_acl, threads_categories, profile.thread_set)
+        return exclude_invisible_threads(
+            request.user_acl, threads_categories, profile.thread_set
+        )
 
     def get_posts_queryset(self, user, profile, threads_queryset):
-        return profile.post_set.select_related('thread', 'poster').filter(
-            id__in=threads_queryset.values('first_post_id'),
+        return profile.post_set.select_related("thread", "poster").filter(
+            id__in=threads_queryset.values("first_post_id")
         )
 
     def get_frontend_context(self):
         context = {
-            'results': UserFeedSerializer(self.posts, many=True, context={'user': self._user}).data
+            "results": UserFeedSerializer(
+                self.posts, many=True, context={"user": self._user}
+            ).data
         }
 
         context.update(self.paginator)
@@ -59,10 +70,7 @@ class UserThreads(object):
         return context
 
     def get_template_context(self):
-        return {
-            'posts': self.posts,
-            'paginator': self.paginator,
-        }
+        return {"posts": self.posts, "paginator": self.paginator}
 
 
-UserFeedSerializer = FeedSerializer.exclude_fields('poster')
+UserFeedSerializer = FeedSerializer.exclude_fields("poster")

+ 12 - 21
misago/users/views/activation.py

@@ -23,10 +23,10 @@ def activation_view(f):
 
 @activation_view
 def request_activation(request):
-    request.frontend_context.update({
-        'SEND_ACTIVATION_API': reverse('misago:api:send-activation'),
-    })
-    return render(request, 'misago/activation/request.html')
+    request.frontend_context.update(
+        {"SEND_ACTIVATION_API": reverse("misago:api:send-activation")}
+    )
+    return render(request, "misago/activation/request.html")
 
 
 class ActivationStopped(Exception):
@@ -44,41 +44,32 @@ def activate_by_token(request, pk, token):
     try:
         if not inactive_user.requires_activation:
             message = _("%(user)s, your account is already active.")
-            raise ActivationStopped(message % {'user': inactive_user.username})
+            raise ActivationStopped(message % {"user": inactive_user.username})
 
         if not is_activation_token_valid(inactive_user, token):
             message = _(
                 "%(user)s, your activation link is invalid. "
                 "Try again or request new activation link."
             )
-            raise ActivationError(message % {'user': inactive_user.username})
+            raise ActivationError(message % {"user": inactive_user.username})
 
         ban = get_user_ban(inactive_user, request.cache_versions)
         if ban:
             raise Banned(ban)
     except ActivationStopped as e:
-        return render(request, 'misago/activation/stopped.html', {
-            'message': e.args[0],
-        })
+        return render(request, "misago/activation/stopped.html", {"message": e.args[0]})
     except ActivationError as e:
         return render(
-            request,
-            'misago/activation/error.html',
-            {
-                'message': e.args[0],
-            },
-            status=400,
+            request, "misago/activation/error.html", {"message": e.args[0]}, status=400
         )
 
     inactive_user.requires_activation = UserModel.ACTIVATION_NONE
-    inactive_user.save(update_fields=['requires_activation'])
+    inactive_user.save(update_fields=["requires_activation"])
 
     message = _("%(user)s, your account has been activated!")
 
     return render(
-        request, 'misago/activation/done.html', {
-            'message': message % {
-                'user': inactive_user.username,
-            },
-        }
+        request,
+        "misago/activation/done.html",
+        {"message": message % {"user": inactive_user.username}},
     )

+ 17 - 15
misago/users/views/admin/bans.py

@@ -7,10 +7,10 @@ from misago.users.models import Ban
 
 
 class BanAdmin(generic.AdminBaseMixin):
-    root_link = 'misago:admin:users:bans:index'
+    root_link = "misago:admin:users:bans:index"
     model = Ban
     form = BanForm
-    templates_dir = 'misago/admin/bans'
+    templates_dir = "misago/admin/bans"
     message_404 = _("Requested ban does not exist.")
 
     def handle_form(self, form, request, target):
@@ -21,20 +21,22 @@ class BanAdmin(generic.AdminBaseMixin):
 class BansList(BanAdmin, generic.ListView):
     items_per_page = 30
     ordering = [
-        ('-id', _("From newest")),
-        ('id', _("From oldest")),
-        ('banned_value', _("A to z")),
-        ('-banned_value', _("Z to a")),
+        ("-id", _("From newest")),
+        ("id", _("From oldest")),
+        ("banned_value", _("A to z")),
+        ("-banned_value", _("Z to a")),
     ]
     search_form = SearchBansForm
-    selection_label = _('With bans: 0')
-    empty_selection_label = _('Select bans')
-    mass_actions = ({
-        'action': 'delete',
-        'icon': 'fa fa-times',
-        'name': _('Remove bans'),
-        'confirmation': _('Are you sure you want to remove those bans?')
-    }, )
+    selection_label = _("With bans: 0")
+    empty_selection_label = _("Select bans")
+    mass_actions = (
+        {
+            "action": "delete",
+            "icon": "fa fa-times",
+            "name": _("Remove bans"),
+            "confirmation": _("Are you sure you want to remove those bans?"),
+        },
+    )
 
     def action_delete(self, request, items):
         items.delete()
@@ -55,4 +57,4 @@ class DeleteBan(BanAdmin, generic.ButtonView):
         target.delete()
         Ban.objects.invalidate_cache()
         message = _('Ban "%(name)s" has been removed.')
-        messages.success(request, message % {'name': target.name})
+        messages.success(request, message % {"name": target.name})

+ 30 - 22
misago/users/views/admin/datadownloads.py

@@ -3,44 +3,48 @@ from django.utils.translation import gettext_lazy as _
 
 from misago.admin.views import generic
 from misago.users.datadownloads import (
-    expire_user_data_download, request_user_data_download, user_has_data_download_request)
+    expire_user_data_download,
+    request_user_data_download,
+    user_has_data_download_request,
+)
 from misago.users.forms.admin import RequestDataDownloadsForm, SearchDataDownloadsForm
 from misago.users.models import DataDownload
 
 
 class DataDownloadAdmin(generic.AdminBaseMixin):
-    root_link = 'misago:admin:users:data-downloads:index'
-    templates_dir = 'misago/admin/datadownloads'
+    root_link = "misago:admin:users:data-downloads:index"
+    templates_dir = "misago/admin/datadownloads"
     model = DataDownload
 
 
 class DataDownloadsList(DataDownloadAdmin, generic.ListView):
     items_per_page = 30
-    ordering = [
-        ('-id', _("From newest")),
-        ('id', _("From oldest")),
-    ]
-    selection_label = _('With data downloads: 0')
-    empty_selection_label = _('Select data downloads')
+    ordering = [("-id", _("From newest")), ("id", _("From oldest"))]
+    selection_label = _("With data downloads: 0")
+    empty_selection_label = _("Select data downloads")
     mass_actions = [
         {
-            'action': 'expire',
-            'name': _("Expire downloads"),
-            'icon': 'fa fa-ban',
-            'confirmation': _("Are you sure you want to set selected data downloads as expired?"),
+            "action": "expire",
+            "name": _("Expire downloads"),
+            "icon": "fa fa-ban",
+            "confirmation": _(
+                "Are you sure you want to set selected data downloads as expired?"
+            ),
         },
         {
-            'action': 'delete',
-            'name': _("Delete downloads"),
-            'icon': 'fa fa-times-circle',
-            'confirmation': _("Are you sure you want to delete selected data downloads?"),
+            "action": "delete",
+            "name": _("Delete downloads"),
+            "icon": "fa fa-times-circle",
+            "confirmation": _(
+                "Are you sure you want to delete selected data downloads?"
+            ),
         },
     ]
 
     def get_queryset(self):
         qs = super().get_queryset()
-        return qs.select_related('user', 'requester')
-        
+        return qs.select_related("user", "requester")
+
     def get_search_form(self, request):
         return SearchDataDownloadsForm
 
@@ -48,7 +52,9 @@ class DataDownloadsList(DataDownloadAdmin, generic.ListView):
         for data_download in data_downloads:
             expire_user_data_download(data_download)
 
-        messages.success(request, _("Selected data downloads have been set as expired."))
+        messages.success(
+            request, _("Selected data downloads have been set as expired.")
+        )
 
     def action_delete(self, request, data_downloads):
         for data_download in data_downloads:
@@ -61,8 +67,10 @@ class RequestDataDownloads(DataDownloadAdmin, generic.FormView):
     form = RequestDataDownloadsForm
 
     def handle_form(self, form, request):
-        for user in form.cleaned_data['users']:
+        for user in form.cleaned_data["users"]:
             if not user_has_data_download_request(user):
                 request_user_data_download(user, requester=request.user)
 
-        messages.success(request, _("Data downloads have been requested for specified users."))
+        messages.success(
+            request, _("Data downloads have been requested for specified users.")
+        )

+ 18 - 18
misago/users/views/admin/ranks.py

@@ -9,10 +9,10 @@ from misago.users.models import Rank
 
 
 class RankAdmin(generic.AdminBaseMixin):
-    root_link = 'misago:admin:users:ranks:index'
+    root_link = "misago:admin:users:ranks:index"
     model = Rank
     form = RankForm
-    templates_dir = 'misago/admin/ranks'
+    templates_dir = "misago/admin/ranks"
     message_404 = _("Requested rank does not exist.")
 
     def update_roles(self, target, roles):
@@ -22,11 +22,11 @@ class RankAdmin(generic.AdminBaseMixin):
 
     def handle_form(self, form, request, target):
         super().handle_form(form, request, target)
-        self.update_roles(target, form.cleaned_data['roles'])
+        self.update_roles(target, form.cleaned_data["roles"])
 
 
 class RanksList(RankAdmin, generic.ListView):
-    ordering = (('order', None), )
+    ordering = (("order", None),)
 
 
 class NewRank(RankAdmin, generic.ModelFormView):
@@ -39,7 +39,7 @@ class EditRank(RankAdmin, generic.ModelFormView):
 
 class DeleteRank(RankAdmin, generic.ButtonView):
     def check_permissions(self, request, target):
-        message_format = {'name': target.name}
+        message_format = {"name": target.name}
         if target.is_default:
             message = _('Rank "%(name)s" is default rank and can\'t be deleted.')
             return message % message_format
@@ -50,24 +50,24 @@ class DeleteRank(RankAdmin, generic.ButtonView):
     def button_action(self, request, target):
         target.delete()
         message = _('Rank "%(name)s" has been deleted.')
-        messages.success(request, message % {'name': target.name})
+        messages.success(request, message % {"name": target.name})
 
 
 class MoveDownRank(RankAdmin, generic.ButtonView):
     def button_action(self, request, target):
         try:
             other_target = Rank.objects.filter(order__gt=target.order)
-            other_target = other_target.earliest('order')
+            other_target = other_target.earliest("order")
         except Rank.DoesNotExist:
             other_target = None
 
         if other_target:
             other_target.order, target.order = target.order, other_target.order
-            other_target.save(update_fields=['order'])
-            target.save(update_fields=['order'])
+            other_target.save(update_fields=["order"])
+            target.save(update_fields=["order"])
 
             message = _('Rank "%(name)s" has been moved below "%(other)s".')
-            targets_names = {'name': target.name, 'other': other_target.name}
+            targets_names = {"name": target.name, "other": other_target.name}
             messages.success(request, message % targets_names)
 
 
@@ -75,33 +75,33 @@ class MoveUpRank(RankAdmin, generic.ButtonView):
     def button_action(self, request, target):
         try:
             other_target = Rank.objects.filter(order__lt=target.order)
-            other_target = other_target.latest('order')
+            other_target = other_target.latest("order")
         except Rank.DoesNotExist:
             other_target = None
 
         if other_target:
             other_target.order, target.order = target.order, other_target.order
-            other_target.save(update_fields=['order'])
-            target.save(update_fields=['order'])
+            other_target.save(update_fields=["order"])
+            target.save(update_fields=["order"])
 
             message = _('Rank "%(name)s" has been moved above "%(other)s".')
-            targets_names = {'name': target.name, 'other': other_target.name}
+            targets_names = {"name": target.name, "other": other_target.name}
             messages.success(request, message % targets_names)
 
 
 class RankUsers(RankAdmin, generic.TargetedView):
     def real_dispatch(self, request, target):
-        redirect_url = reverse('misago:admin:users:accounts:index')
-        return redirect('%s?rank=%s' % (redirect_url, target.pk))
+        redirect_url = reverse("misago:admin:users:accounts:index")
+        return redirect("%s?rank=%s" % (redirect_url, target.pk))
 
 
 class DefaultRank(RankAdmin, generic.ButtonView):
     def check_permissions(self, request, target):
         if target.is_default:
             message = _('Rank "%(name)s" is already default.')
-            return message % {'name': target.name}
+            return message % {"name": target.name}
 
     def button_action(self, request, target):
         Rank.objects.make_rank_default(target)
         message = _('Rank "%(name)s" has been made default.')
-        messages.success(request, message % {'name': target.name})
+        messages.success(request, message % {"name": target.name})

+ 118 - 131
misago/users/views/admin/users.py

@@ -14,11 +14,15 @@ from misago.core.pgutils import chunk_queryset
 from misago.threads.models import Thread
 from misago.users.avatars.dynamic import set_avatar as set_dynamic_avatar
 from misago.users.datadownloads import (
-    request_user_data_download, user_has_data_download_request
+    request_user_data_download,
+    user_has_data_download_request,
 )
 from misago.users.forms.admin import (
-    BanUsersForm, EditUserForm, EditUserFormFactory, NewUserForm,
-    create_search_users_form
+    BanUsersForm,
+    EditUserForm,
+    EditUserFormFactory,
+    NewUserForm,
+    create_search_users_form,
 )
 from misago.users.models import Ban
 from misago.users.profilefields import profilefields
@@ -29,8 +33,8 @@ User = get_user_model()
 
 
 class UserAdmin(generic.AdminBaseMixin):
-    root_link = 'misago:admin:users:accounts:index'
-    templates_dir = 'misago/admin/users'
+    root_link = "misago:admin:users:accounts:index"
+    templates_dir = "misago/admin/users"
     model = User
 
     def create_form_type(self, request, target):
@@ -57,52 +61,48 @@ class UserAdmin(generic.AdminBaseMixin):
 class UsersList(UserAdmin, generic.ListView):
     items_per_page = 24
     ordering = [
-        ('-id', _("From newest")),
-        ('id', _("From oldest")),
-        ('slug', _("A to z")),
-        ('-slug', _("Z to a")),
-        ('posts', _("Biggest posters")),
-        ('-posts', _("Smallest posters")),
+        ("-id", _("From newest")),
+        ("id", _("From oldest")),
+        ("slug", _("A to z")),
+        ("-slug", _("Z to a")),
+        ("posts", _("Biggest posters")),
+        ("-posts", _("Smallest posters")),
     ]
-    selection_label = _('With users: 0')
-    empty_selection_label = _('Select users')
+    selection_label = _("With users: 0")
+    empty_selection_label = _("Select users")
     mass_actions = [
         {
-            'action': 'activate',
-            'name': _("Activate accounts"),
-            'icon': 'fa fa-check-square-o',
+            "action": "activate",
+            "name": _("Activate accounts"),
+            "icon": "fa fa-check-square-o",
         },
+        {"action": "ban", "name": _("Ban users"), "icon": "fa fa-lock"},
         {
-            'action': 'ban',
-            'name': _("Ban users"),
-            'icon': 'fa fa-lock',
+            "action": "request_data_download",
+            "name": _("Request data download"),
+            "icon": "fa fa-download",
         },
         {
-            'action': 'request_data_download',
-            'name': _("Request data download"),
-            'icon': 'fa fa-download',
+            "action": "delete_accounts",
+            "name": _("Delete accounts"),
+            "icon": "fa fa-times-circle",
+            "confirmation": _("Are you sure you want to delete selected users?"),
         },
         {
-            'action': 'delete_accounts',
-            'name': _("Delete accounts"),
-            'icon': 'fa fa-times-circle',
-            'confirmation': _("Are you sure you want to delete selected users?"),
-        },
-        {
-            'action': 'delete_all',
-            'name': _("Delete all"),
-            'icon': 'fa fa-eraser',
-            'confirmation': _(
+            "action": "delete_all",
+            "name": _("Delete all"),
+            "icon": "fa fa-eraser",
+            "confirmation": _(
                 "Are you sure you want to delete selected users? "
                 "This will also delete all content associated with their accounts."
             ),
-            'is_atomic': False,
+            "is_atomic": False,
         },
     ]
 
     def get_queryset(self):
         qs = super().get_queryset()
-        return qs.select_related('rank')
+        return qs.select_related("rank")
 
     def get_search_form(self, request):
         return create_search_users_form()
@@ -122,80 +122,79 @@ class UsersList(UserAdmin, generic.ListView):
             queryset.update(requires_activation=User.ACTIVATION_NONE)
 
             subject = _("Your account on %(forum_name)s forums has been activated")
-            mail_subject = subject % {'forum_name': request.settings.forum_name}
+            mail_subject = subject % {"forum_name": request.settings.forum_name}
 
             mail_users(
                 inactive_users,
                 mail_subject,
-                'misago/emails/activation/by_admin',
+                "misago/emails/activation/by_admin",
                 context={"settings": request.settings},
             )
 
             messages.success(request, _("Selected users accounts have been activated."))
 
     def action_ban(self, request, users):
-        users = users.order_by('slug')
+        users = users.order_by("slug")
         for user in users:
             if user.is_superuser:
                 message = _("%(user)s is super admin and can't be banned.")
-                mesage = message % {'user': user.username}
+                mesage = message % {"user": user.username}
                 raise generic.MassActionError(mesage)
 
         form = BanUsersForm(users=users)
-        if 'finalize' in request.POST:
+        if "finalize" in request.POST:
             form = BanUsersForm(request.POST, users=users)
             if form.is_valid():
                 cleaned_data = form.cleaned_data
                 banned_values = []
 
                 ban_kwargs = {
-                    'user_message': cleaned_data.get('user_message'),
-                    'staff_message': cleaned_data.get('staff_message'),
-                    'expires_on': cleaned_data.get('expires_on'),
+                    "user_message": cleaned_data.get("user_message"),
+                    "staff_message": cleaned_data.get("staff_message"),
+                    "expires_on": cleaned_data.get("expires_on"),
                 }
 
                 for user in users:
-                    for ban in cleaned_data['ban_type']:
+                    for ban in cleaned_data["ban_type"]:
                         banned_value = None
 
-                        if ban == 'usernames':
+                        if ban == "usernames":
                             check_type = Ban.USERNAME
                             banned_value = user.username.lower()
 
-                        if ban == 'emails':
+                        if ban == "emails":
                             check_type = Ban.EMAIL
                             banned_value = user.email.lower()
 
-                        if ban == 'domains':
+                        if ban == "domains":
                             check_type = Ban.EMAIL
                             banned_value = user.email.lower()
-                            at_pos = banned_value.find('@')
-                            banned_value = '*%s' % banned_value[at_pos:]
+                            at_pos = banned_value.find("@")
+                            banned_value = "*%s" % banned_value[at_pos:]
 
-                        if ban == 'ip' and user.joined_from_ip:
+                        if ban == "ip" and user.joined_from_ip:
                             check_type = Ban.IP
                             banned_value = user.joined_from_ip
 
-                        if ban in ('ip_first', 'ip_two') and user.joined_from_ip:
+                        if ban in ("ip_first", "ip_two") and user.joined_from_ip:
                             check_type = Ban.IP
 
-                            if ':' in user.joined_from_ip:
-                                ip_separator = ':'
-                            if '.' in user.joined_from_ip:
-                                ip_separator = '.'
+                            if ":" in user.joined_from_ip:
+                                ip_separator = ":"
+                            if "." in user.joined_from_ip:
+                                ip_separator = "."
 
                             bits = user.joined_from_ip.split(ip_separator)
-                            if ban == 'ip_first':
+                            if ban == "ip_first":
                                 formats = (bits[0], ip_separator)
-                            if ban == 'ip_two':
+                            if ban == "ip_two":
                                 formats = (bits[0], ip_separator, bits[1], ip_separator)
-                            banned_value = '%s*' % (''.join(formats))
+                            banned_value = "%s*" % ("".join(formats))
 
                         if banned_value and banned_value not in banned_values:
-                            ban_kwargs.update({
-                                'check_type': check_type,
-                                'banned_value': banned_value,
-                            })
+                            ban_kwargs.update(
+                                {"check_type": check_type, "banned_value": banned_value}
+                            )
                             Ban.objects.create(**ban_kwargs)
                             banned_values.append(banned_value)
 
@@ -205,11 +204,8 @@ class UsersList(UserAdmin, generic.ListView):
 
         return self.render(
             request,
-            template='misago/admin/users/ban.html',
-            context={
-                'users': users,
-                'form': form,
-            }
+            template="misago/admin/users/ban.html",
+            context={"users": users, "form": form},
         )
 
     def action_request_data_download(self, request, users):
@@ -218,14 +214,17 @@ class UsersList(UserAdmin, generic.ListView):
                 request_user_data_download(user, requester=request.user)
 
         messages.success(
-            request, _("Data download requests have been placed for selected users."))
+            request, _("Data download requests have been placed for selected users.")
+        )
 
     def action_delete_accounts(self, request, users):
         for user in users:
             if user == request.user:
                 raise generic.MassActionError(_("You can't delete yourself."))
             if user.is_staff or user.is_superuser:
-                message = _("%(user)s is admin and can't be deleted.") % {'user': user.username}
+                message = _("%(user)s is admin and can't be deleted.") % {
+                    "user": user.username
+                }
                 raise generic.MassActionError(message)
 
         for user in users:
@@ -238,60 +237,53 @@ class UsersList(UserAdmin, generic.ListView):
             if user == request.user:
                 raise generic.MassActionError(_("You can't delete yourself."))
             if user.is_staff or user.is_superuser:
-                message = _("%(user)s is admin and can't be deleted.") % {'user': user.username}
+                message = _("%(user)s is admin and can't be deleted.") % {
+                    "user": user.username
+                }
                 raise generic.MassActionError(message)
 
         return self.render(
-            request,
-            template='misago/admin/users/delete.html',
-            context={
-                'users': users,
-            },
+            request, template="misago/admin/users/delete.html", context={"users": users}
         )
 
 
 class NewUser(UserAdmin, generic.ModelFormView):
     form = NewUserForm
-    template = 'new.html'
+    template = "new.html"
     message_submit = _('New user "%(user)s" has been registered.')
 
     def initialize_form(self, form, request, target):
-        if request.method == 'POST':
-            return form(
-                request.POST,
-                request.FILES,
-                instance=target,
-                request=request,
-            )
+        if request.method == "POST":
+            return form(request.POST, request.FILES, instance=target, request=request)
         else:
             return form(instance=target, request=request)
-            
+
     def handle_form(self, form, request, target):
         new_user = User.objects.create_user(
-            form.cleaned_data['username'],
-            form.cleaned_data['email'],
-            form.cleaned_data['new_password'],
-            title=form.cleaned_data['title'],
-            rank=form.cleaned_data.get('rank'),
+            form.cleaned_data["username"],
+            form.cleaned_data["email"],
+            form.cleaned_data["new_password"],
+            title=form.cleaned_data["title"],
+            rank=form.cleaned_data.get("rank"),
             joined_from_ip=request.user_ip,
         )
 
-        if form.cleaned_data.get('staff_level'):
-            new_user.staff_level = form.cleaned_data['staff_level']
+        if form.cleaned_data.get("staff_level"):
+            new_user.staff_level = form.cleaned_data["staff_level"]
 
-        if form.cleaned_data.get('roles'):
-            new_user.roles.add(*form.cleaned_data['roles'])
+        if form.cleaned_data.get("roles"):
+            new_user.roles.add(*form.cleaned_data["roles"])
 
         new_user.update_acl_key()
         setup_new_user(request.settings, new_user)
 
-        messages.success(request, self.message_submit % {'user': target.username})
-        return redirect('misago:admin:users:accounts:edit', pk=new_user.pk)
+        messages.success(request, self.message_submit % {"user": target.username})
+        return redirect("misago:admin:users:accounts:edit", pk=new_user.pk)
 
 
 class EditUser(UserAdmin, generic.ModelFormView):
     form = EditUserForm
-    template = 'edit.html'
+    template = "edit.html"
     message_submit = _('User "%(user)s" has been edited.')
 
     def real_dispatch(self, request, target):
@@ -300,53 +292,52 @@ class EditUser(UserAdmin, generic.ModelFormView):
         return super().real_dispatch(request, target)
 
     def initialize_form(self, form, request, target):
-        if request.method == 'POST':
-            return form(
-                request.POST,
-                request.FILES,
-                instance=target,
-                request=request,
-            )
+        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):
         target.username = target.old_username
-        if target.username != form.cleaned_data.get('username'):
-            target.set_username(form.cleaned_data.get('username'), changed_by=request.user)
+        if target.username != form.cleaned_data.get("username"):
+            target.set_username(
+                form.cleaned_data.get("username"), changed_by=request.user
+            )
 
-        if form.cleaned_data.get('new_password'):
-            target.set_password(form.cleaned_data['new_password'])
+        if form.cleaned_data.get("new_password"):
+            target.set_password(form.cleaned_data["new_password"])
 
             if target.pk == request.user.pk:
                 start_admin_session(request, target)
                 update_session_auth_hash(request, target)
 
-        if form.cleaned_data.get('email'):
-            target.set_email(form.cleaned_data['email'])
+        if form.cleaned_data.get("email"):
+            target.set_email(form.cleaned_data["email"])
             if target.pk == request.user.pk:
                 start_admin_session(request, target)
 
-        if form.cleaned_data.get('is_avatar_locked'):
+        if form.cleaned_data.get("is_avatar_locked"):
             if not target.old_is_avatar_locked:
                 set_dynamic_avatar(target)
 
-        if 'is_staff' in form.fields and 'is_superuser' in form.fields:
-            target.is_staff = form.cleaned_data.get('is_staff')
-            target.is_superuser = form.cleaned_data.get('is_superuser')
+        if "is_staff" in form.fields and "is_superuser" in form.fields:
+            target.is_staff = form.cleaned_data.get("is_staff")
+            target.is_superuser = form.cleaned_data.get("is_superuser")
 
-        if 'is_active' in form.fields and 'is_active_staff_message' in form.fields:
-            target.is_active = form.cleaned_data.get('is_active')
-            target.is_active_staff_message = form.cleaned_data.get('is_active_staff_message')
+        if "is_active" in form.fields and "is_active_staff_message" in form.fields:
+            target.is_active = form.cleaned_data.get("is_active")
+            target.is_active_staff_message = form.cleaned_data.get(
+                "is_active_staff_message"
+            )
 
-        target.rank = form.cleaned_data.get('rank')
+        target.rank = form.cleaned_data.get("rank")
 
         target.roles.clear()
-        target.roles.add(*form.cleaned_data['roles'])
+        target.roles.add(*form.cleaned_data["roles"])
 
         target_acl = get_user_acl(target, request.cache_versions)
         set_user_signature(
-            request, target, target_acl, form.cleaned_data.get('signature')
+            request, target, target_acl, form.cleaned_data.get("signature")
         )
 
         profilefields.update_user_profile_fields(request, target, form)
@@ -354,7 +345,7 @@ class EditUser(UserAdmin, generic.ModelFormView):
         target.update_acl_key()
         target.save()
 
-        messages.success(request, self.message_submit % {'user': target.username})
+        messages.success(request, self.message_submit % {"user": target.username})
 
 
 class DeletionStep(UserAdmin, generic.ButtonView):
@@ -368,7 +359,9 @@ class DeletionStep(UserAdmin, generic.ButtonView):
             return _("You can't delete yourself.")
 
         if target.is_staff or target.is_superuser:
-            return _("%(user)s is admin and can't be deleted.") % {'user': target.username}
+            return _("%(user)s is admin and can't be deleted.") % {
+                "user": target.username
+            }
 
     def execute_step(self, user):
         raise NotImplementedError(
@@ -387,7 +380,7 @@ class DeleteThreadsStep(DeletionStep):
         deleted_threads = 0
         is_completed = False
 
-        for thread in user.thread_set.order_by('-id')[:50]:
+        for thread in user.thread_set.order_by("-id")[:50]:
             recount_categories.add(thread.category_id)
             with transaction.atomic():
                 thread.delete()
@@ -400,10 +393,7 @@ class DeleteThreadsStep(DeletionStep):
         else:
             is_completed = True
 
-        return {
-            'deleted_count': deleted_threads,
-            'is_completed': is_completed,
-        }
+        return {"deleted_count": deleted_threads, "is_completed": is_completed}
 
 
 class DeletePostsStep(DeletionStep):
@@ -414,7 +404,7 @@ class DeletePostsStep(DeletionStep):
         deleted_posts = 0
         is_completed = False
 
-        for post in user.post_set.order_by('-id')[:50]:
+        for post in user.post_set.order_by("-id")[:50]:
             recount_categories.add(post.category_id)
             recount_threads.add(post.thread_id)
             with transaction.atomic():
@@ -433,13 +423,10 @@ class DeletePostsStep(DeletionStep):
         else:
             is_completed = True
 
-        return {
-            'deleted_count': deleted_posts,
-            'is_completed': is_completed,
-        }
+        return {"deleted_count": deleted_posts, "is_completed": is_completed}
 
 
 class DeleteAccountStep(DeletionStep):
     def execute_step(self, user):
         user.delete(delete_content=True)
-        return {'is_completed': True}
+        return {"is_completed": True}

+ 7 - 7
misago/users/views/auth.py

@@ -14,8 +14,8 @@ from django.views.decorators.debug import sensitive_post_parameters
 @never_cache
 @csrf_protect
 def login(request):
-    if request.method == 'POST':
-        redirect_to = request.POST.get('redirect_to')
+    if request.method == "POST":
+        redirect_to = request.POST.get("redirect_to")
         if redirect_to:
             is_redirect_safe = is_safe_url(
                 url=redirect_to,
@@ -24,11 +24,11 @@ def login(request):
             )
             if is_redirect_safe:
                 redirect_to_path = urlparse(redirect_to).path
-                if '?' not in redirect_to_path:
-                    redirect_to_path = '%s?' % redirect_to_path
+                if "?" not in redirect_to_path:
+                    redirect_to_path = "%s?" % redirect_to_path
                 else:
-                    redirect_to_path = '%s&' % redirect_to_path
-                redirect_to_path = '%sref=login' % redirect_to_path
+                    redirect_to_path = "%s&" % redirect_to_path
+                redirect_to_path = "%sref=login" % redirect_to_path
                 try:
                     return redirect(redirect_to_path)
                 except NoReverseMatch:
@@ -40,6 +40,6 @@ def login(request):
 @never_cache
 @csrf_protect
 def logout(request):
-    if request.method == 'POST' and request.user.is_authenticated:
+    if request.method == "POST" and request.user.is_authenticated:
         auth.logout(request)
     return redirect(settings.LOGIN_REDIRECT_URL)

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

@@ -18,9 +18,9 @@ def user_avatar(request, pk, size):
 
     found_avatar = user.avatars[0]
     for avatar in user.avatars:
-        if avatar['size'] >= size:
+        if avatar["size"] >= size:
             found_avatar = avatar
-    return redirect(found_avatar['url'])
+    return redirect(found_avatar["url"])
 
 
 def blank_avatar(request):

+ 20 - 18
misago/users/views/forgottenpassword.py

@@ -19,10 +19,10 @@ def reset_view(f):
 
 @reset_view
 def request_reset(request):
-    request.frontend_context.update({
-        'SEND_PASSWORD_RESET_API': reverse('misago:api:send-password-form'),
-    })
-    return render(request, 'misago/forgottenpassword/request.html')
+    request.frontend_context.update(
+        {"SEND_PASSWORD_RESET_API": reverse("misago:api:send-password-form")}
+    )
+    return render(request, "misago/forgottenpassword/request.html")
 
 
 class ResetError(Exception):
@@ -34,30 +34,32 @@ def reset_password_form(request, pk, token):
     requesting_user = get_object_or_404(get_user_model(), pk=pk)
 
     try:
-        if (request.user.is_authenticated and request.user.id != requesting_user.id):
-            message = _("%(user)s, your link has expired. Please request new link and try again.")
-            raise ResetError(message % {'user': requesting_user.username})
+        if request.user.is_authenticated and request.user.id != requesting_user.id:
+            message = _(
+                "%(user)s, your link has expired. Please request new link and try again."
+            )
+            raise ResetError(message % {"user": requesting_user.username})
 
         if not is_password_change_token_valid(requesting_user, token):
-            message = _("%(user)s, your link is invalid. Please try again or request new link.")
-            raise ResetError(message % {'user': requesting_user.username})
+            message = _(
+                "%(user)s, your link is invalid. Please try again or request new link."
+            )
+            raise ResetError(message % {"user": requesting_user.username})
 
         ban = get_user_ban(requesting_user, request.cache_versions)
         if ban:
             raise Banned(ban)
     except ResetError as e:
         return render(
-            request, 'misago/forgottenpassword/error.html', {
-                'message': e.args[0],
-            }, status=400
+            request,
+            "misago/forgottenpassword/error.html",
+            {"message": e.args[0]},
+            status=400,
         )
 
     api_url = reverse(
-        'misago:api:change-forgotten-password', kwargs={
-            'pk': pk,
-            'token': token,
-        }
+        "misago:api:change-forgotten-password", kwargs={"pk": pk, "token": token}
     )
 
-    request.frontend_context['CHANGE_PASSWORD_API'] = api_url
-    return render(request, 'misago/forgottenpassword/form.html')
+    request.frontend_context["CHANGE_PASSWORD_API"] = api_url
+    return render(request, "misago/forgottenpassword/form.html")

+ 37 - 34
misago/users/views/lists.py

@@ -17,43 +17,48 @@ class ListView(View):
 
         sections = users_list.get_sections(request)
 
-        context_data['pages'] = sections
+        context_data["pages"] = sections
 
-        request.frontend_context['USERS_LISTS'] = []
+        request.frontend_context["USERS_LISTS"] = []
         for page in sections:
-            page['reversed_link'] = reverse(page['link'])
-            request.frontend_context['USERS_LISTS'].append({
-                'name': str(page['name']),
-                'component': page['component'],
-            })
-
-        active_rank = context_data.get('rank')
-        for rank in Rank.objects.filter(is_tab=True).order_by('order'):
-            context_data['pages'].append({
-                'name': rank.name,
-                'reversed_link': reverse('misago:users-rank', kwargs={'slug': rank.slug}),
-                'is_active': active_rank.pk == rank.pk if active_rank else None
-            })
+            page["reversed_link"] = reverse(page["link"])
+            request.frontend_context["USERS_LISTS"].append(
+                {"name": str(page["name"]), "component": page["component"]}
+            )
+
+        active_rank = context_data.get("rank")
+        for rank in Rank.objects.filter(is_tab=True).order_by("order"):
+            context_data["pages"].append(
+                {
+                    "name": rank.name,
+                    "reversed_link": reverse(
+                        "misago:users-rank", kwargs={"slug": rank.slug}
+                    ),
+                    "is_active": active_rank.pk == rank.pk if active_rank else None,
+                }
+            )
 
             if rank.description:
                 description = {
-                    'plain': rank.description,
-                    'html': format_plaintext_for_html(rank.description)
+                    "plain": rank.description,
+                    "html": format_plaintext_for_html(rank.description),
                 }
             else:
                 description = None
 
-            request.frontend_context['USERS_LISTS'].append({
-                'id': rank.pk,
-                'name': rank.name,
-                'slug': rank.slug,
-                'css_class': rank.css_class,
-                'description': description,
-                'component': 'rank',
-            })
+            request.frontend_context["USERS_LISTS"].append(
+                {
+                    "id": rank.pk,
+                    "name": rank.name,
+                    "slug": rank.slug,
+                    "css_class": rank.css_class,
+                    "description": description,
+                    "component": "rank",
+                }
+            )
 
-        active_section = list(filter(lambda x: x['is_active'], sections))[0]
-        context_data['active_section'] = active_section
+        active_section = list(filter(lambda x: x["is_active"], sections))[0]
+        context_data["active_section"] = active_section
 
         return render(request, self.template_name, context_data)
 
@@ -67,28 +72,26 @@ def landing(request):
 
 
 class ActivePostersView(ListView):
-    template_name = 'misago/userslists/active_posters.html'
+    template_name = "misago/userslists/active_posters.html"
 
     def get_context_data(self, request, *args, **kwargs):
         model = ActivePosters(request)
 
-        request.frontend_context['USERS'] = model.get_frontend_context()
+        request.frontend_context["USERS"] = model.get_frontend_context()
 
         return model.get_template_context()
 
 
 class RankUsersView(ListView):
-    template_name = 'misago/userslists/rank.html'
+    template_name = "misago/userslists/rank.html"
 
     def get_context_data(self, request, slug, page=0):
         rank = get_object_or_404(Rank.objects.filter(is_tab=True), slug=slug)
         users = RankUsers(request, rank, page)
 
-        request.frontend_context['USERS'] = users.get_frontend_context()
+        request.frontend_context["USERS"] = users.get_frontend_context()
 
-        context = {
-            'rank': rank,
-        }
+        context = {"rank": rank}
         context.update(users.get_template_context())
 
         return context

+ 20 - 22
misago/users/views/options.py

@@ -12,15 +12,17 @@ from misago.users.pages import usercp
 def index(request, *args, **kwargs):
     user_options = []
     for section in usercp.get_sections(request):
-        user_options.append({
-            'name': str(section['name']),
-            'icon': section['icon'],
-            'component': section['component'],
-        })
+        user_options.append(
+            {
+                "name": str(section["name"]),
+                "icon": section["icon"],
+                "component": section["component"],
+            }
+        )
 
-    request.frontend_context.update({'USER_OPTIONS': user_options})
+    request.frontend_context.update({"USER_OPTIONS": user_options})
 
-    return render(request, 'misago/options/noscript.html')
+    return render(request, "misago/options/noscript.html")
 
 
 class ChangeError(Exception):
@@ -33,48 +35,44 @@ def confirm_change_view(f):
         try:
             return f(request, token)
         except ChangeError:
-            return render(request, 'misago/options/credentials_error.html', status=400)
+            return render(request, "misago/options/credentials_error.html", status=400)
 
     return decorator
 
 
 @confirm_change_view
 def confirm_email_change(request, token):
-    new_credential = read_new_credential(request, 'email', token)
+    new_credential = read_new_credential(request, "email", token)
     if not new_credential:
         raise ChangeError()
 
     try:
         request.user.set_email(new_credential)
-        request.user.save(update_fields=['email', 'email_hash'])
+        request.user.save(update_fields=["email", "email_hash"])
     except IntegrityError:
         raise ChangeError()
 
     message = _("%(user)s, your e-mail has been changed.")
     return render(
-        request, 'misago/options/credentials_changed.html', {
-            'message': message % {
-                'user': request.user.username,
-            },
-        }
+        request,
+        "misago/options/credentials_changed.html",
+        {"message": message % {"user": request.user.username}},
     )
 
 
 @confirm_change_view
 def confirm_password_change(request, token):
-    new_credential = read_new_credential(request, 'password', token)
+    new_credential = read_new_credential(request, "password", token)
     if not new_credential:
         raise ChangeError()
 
     request.user.set_password(new_credential)
     update_session_auth_hash(request, request.user)
-    request.user.save(update_fields=['password'])
+    request.user.save(update_fields=["password"])
 
     message = _("%(user)s, your password has been changed.")
     return render(
-        request, 'misago/options/credentials_changed.html', {
-            'message': message % {
-                'user': request.user.username,
-            },
-        }
+        request,
+        "misago/options/credentials_changed.html",
+        {"message": message % {"user": request.user.username}},
     )

+ 86 - 63
misago/users/views/profile.py

@@ -9,7 +9,11 @@ from misago.users.bans import get_user_ban
 from misago.users.online.utils import get_user_status
 from misago.users.pages import user_profile
 from misago.users.profilefields import profilefields, serialize_profilefields_data
-from misago.users.serializers import BanDetailsSerializer, UsernameChangeSerializer, UserSerializer
+from misago.users.serializers import (
+    BanDetailsSerializer,
+    UsernameChangeSerializer,
+    UserSerializer,
+)
 from misago.users.viewmodels import Followers, Follows, UserPosts, UserThreads
 
 
@@ -18,7 +22,7 @@ UserModel = get_user_model()
 
 class ProfileView(View):
     def get(self, request, *args, **kwargs):
-        profile = self.get_profile(request, kwargs.pop('pk'), kwargs.pop('slug'))
+        profile = self.get_profile(request, kwargs.pop("pk"), kwargs.pop("slug"))
 
         # resolve that we can display requested section
         sections = user_profile.get_sections(request, profile)
@@ -36,7 +40,9 @@ class ProfileView(View):
         return render(request, self.template_name, context_data)
 
     def get_profile(self, request, pk, slug):
-        queryset = UserModel.objects.select_related('rank', 'online_tracker', 'ban_cache')
+        queryset = UserModel.objects.select_related(
+            "rank", "online_tracker", "ban_cache"
+        )
 
         profile = get_object_or_404(queryset, pk=pk)
 
@@ -50,151 +56,168 @@ class ProfileView(View):
 
     def get_active_section(self, sections):
         for section in sections:
-            if section['is_active']:
+            if section["is_active"]:
                 return section
 
     def get_context_data(self, request, profile):
         return {}
 
     def complete_frontend_context(self, request, profile, sections):
-        request.frontend_context['PROFILE_PAGES'] = []
+        request.frontend_context["PROFILE_PAGES"] = []
         for section in sections:
-            request.frontend_context['PROFILE_PAGES'].append({
-                'name': str(section['name']),
-                'icon': section['icon'],
-                'meta': section.get('metadata'),
-                'component': section['component'],
-            })
-
-        request.frontend_context['PROFILE'] = UserProfileSerializer(
-            profile, context={'request': request}
+            request.frontend_context["PROFILE_PAGES"].append(
+                {
+                    "name": str(section["name"]),
+                    "icon": section["icon"],
+                    "meta": section.get("metadata"),
+                    "component": section["component"],
+                }
+            )
+
+        request.frontend_context["PROFILE"] = UserProfileSerializer(
+            profile, context={"request": request}
         ).data
 
         if not profile.is_active:
-            request.frontend_context['PROFILE']['is_active'] = False
+            request.frontend_context["PROFILE"]["is_active"] = False
         if profile.is_deleting_account:
-            request.frontend_context['PROFILE']['is_deleting_account'] = True
+            request.frontend_context["PROFILE"]["is_deleting_account"] = True
 
     def complete_context_data(self, request, profile, sections, context):
-        context['profile'] = profile
+        context["profile"] = profile
 
-        context['sections'] = sections
+        context["sections"] = sections
         for section in sections:
-            if section['is_active']:
-                context['active_section'] = section
+            if section["is_active"]:
+                context["active_section"] = section
                 break
 
         if request.user.is_authenticated:
             is_authenticated_user = profile.pk == request.user.pk
-            context.update({
-                'is_authenticated_user': is_authenticated_user,
-                'show_email': is_authenticated_user,
-            })
-
-            if not context['show_email']:
-                context['show_email'] = request.user_acl['can_see_users_emails']
+            context.update(
+                {
+                    "is_authenticated_user": is_authenticated_user,
+                    "show_email": is_authenticated_user,
+                }
+            )
+
+            if not context["show_email"]:
+                context["show_email"] = request.user_acl["can_see_users_emails"]
         else:
-            context.update({
-                'is_authenticated_user': False,
-                'show_email': False,
-            })
+            context.update({"is_authenticated_user": False, "show_email": False})
 
 
 class LandingView(ProfileView):
     def get(self, request, *args, **kwargs):
-        profile = self.get_profile(request, kwargs.pop('pk'), kwargs.pop('slug'))
+        profile = self.get_profile(request, kwargs.pop("pk"), kwargs.pop("slug"))
 
-        return redirect(user_profile.get_default_link(), slug=profile.slug, pk=profile.pk)
+        return redirect(
+            user_profile.get_default_link(), slug=profile.slug, pk=profile.pk
+        )
 
 
 class UserPostsView(ProfileView):
-    template_name = 'misago/profile/posts.html'
+    template_name = "misago/profile/posts.html"
 
     def get_context_data(self, request, profile):
         feed = UserPosts(request, profile)
 
-        request.frontend_context['POSTS'] = feed.get_frontend_context()
+        request.frontend_context["POSTS"] = feed.get_frontend_context()
         return feed.get_template_context()
 
 
 class UserThreadsView(ProfileView):
-    template_name = 'misago/profile/threads.html'
+    template_name = "misago/profile/threads.html"
 
     def get_context_data(self, request, profile):
         feed = UserThreads(request, profile)
 
-        request.frontend_context['POSTS'] = feed.get_frontend_context()
+        request.frontend_context["POSTS"] = feed.get_frontend_context()
         return feed.get_template_context()
 
 
 class UserFollowersView(ProfileView):
-    template_name = 'misago/profile/followers.html'
+    template_name = "misago/profile/followers.html"
 
     def get_context_data(self, request, profile):
         users = Followers(request, profile)
 
-        request.frontend_context['PROFILE_FOLLOWERS'] = users.get_frontend_context()
+        request.frontend_context["PROFILE_FOLLOWERS"] = users.get_frontend_context()
         return users.get_template_context()
 
 
 class UserFollowsView(ProfileView):
-    template_name = 'misago/profile/follows.html'
+    template_name = "misago/profile/follows.html"
 
     def get_context_data(self, request, profile):
         users = Follows(request, profile)
 
-        request.frontend_context['PROFILE_FOLLOWS'] = users.get_frontend_context()
+        request.frontend_context["PROFILE_FOLLOWS"] = users.get_frontend_context()
         return users.get_template_context()
 
 
 class UserProfileDetailsView(ProfileView):
-    template_name = 'misago/profile/details.html'
+    template_name = "misago/profile/details.html"
 
     def get_context_data(self, request, profile):
         details = serialize_profilefields_data(request, profilefields, profile)
 
-        request.frontend_context['PROFILE_DETAILS'] = details
+        request.frontend_context["PROFILE_DETAILS"] = details
 
-        return {
-            'profile_details': details,
-        }
+        return {"profile_details": details}
 
 
 class UserUsernameHistoryView(ProfileView):
-    template_name = 'misago/profile/username_history.html'
+    template_name = "misago/profile/username_history.html"
 
     def get_context_data(self, request, profile):
-        queryset = profile.namechanges.select_related('user', 'changed_by')
-        queryset = queryset.order_by('-id')
+        queryset = profile.namechanges.select_related("user", "changed_by")
+        queryset = queryset.order_by("-id")
 
         page = paginate(queryset, None, 14, 4)
 
         data = pagination_dict(page)
-        data.update({'results': UsernameChangeSerializer(page.object_list, many=True).data})
+        data.update(
+            {"results": UsernameChangeSerializer(page.object_list, many=True).data}
+        )
 
-        request.frontend_context['PROFILE_NAME_HISTORY'] = data
+        request.frontend_context["PROFILE_NAME_HISTORY"] = data
 
-        return {
-            'history': page.object_list,
-            'count': data['count'],
-        }
+        return {"history": page.object_list, "count": data["count"]}
 
 
 class UserBanView(ProfileView):
-    template_name = 'misago/profile/ban_details.html'
+    template_name = "misago/profile/ban_details.html"
 
     def get_context_data(self, request, 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
 
-        return {
-            'ban': ban,
-        }
+        return {"ban": ban}
 
 
 UserProfileSerializer = UserSerializer.subset_fields(
-    'id', 'username', 'slug', 'email', 'joined_on', 'rank', 'title', 'avatars', 'is_avatar_locked',
-    'signature', 'is_signature_locked', 'followers', 'following', 'threads', 'posts', 'acl',
-    'is_followed', 'is_blocked', 'real_name', 'status', 'api', 'url'
+    "id",
+    "username",
+    "slug",
+    "email",
+    "joined_on",
+    "rank",
+    "title",
+    "avatars",
+    "is_avatar_locked",
+    "signature",
+    "is_signature_locked",
+    "followers",
+    "following",
+    "threads",
+    "posts",
+    "acl",
+    "is_followed",
+    "is_blocked",
+    "real_name",
+    "status",
+    "api",
+    "url",
 )