Browse Source

Merge pull request #742 from rafalp/yapf

Autoformat python codebase, fixes #702
Rafał Pitoń 8 years ago
parent
commit
0498f4e629
454 changed files with 12524 additions and 10046 deletions
  1. 2 2
      .gitignore
  2. 3 3
      .pylintrc
  3. 11 0
      .style.yapf
  4. 6 0
      cleansource
  5. 0 0
      extras/__init__.py
  6. 5 0
      extras/config.py
  7. 1 1
      extras/fixabsoluteimports.py
  8. 160 0
      extras/fixdictsformatting.py
  9. 2 1
      extras/fixrelativeimports.py
  10. 5 6
      misago/acl/algebra.py
  11. 4 14
      misago/acl/api.py
  12. 1 3
      misago/acl/builder.py
  13. 1 0
      misago/acl/decorators.py
  14. 11 9
      misago/acl/forms.py
  15. 7 4
      misago/acl/migrations/0001_initial.py
  16. 1 1
      misago/acl/migrations/0002_acl_version_tracker.py
  17. 0 1
      misago/acl/migrations/0003_default_roles.py
  18. 2 4
      misago/acl/panels.py
  19. 4 7
      misago/acl/providers.py
  20. 4 4
      misago/acl/tests/test_acl_algebra.py
  21. 1 2
      misago/acl/tests/test_api.py
  22. 33 13
      misago/acl/tests/test_roleadmin_views.py
  23. 6 8
      misago/acl/views.py
  24. 0 1
      misago/admin/__init__.py
  25. 0 1
      misago/admin/admin.py
  26. 4 0
      misago/admin/auth.py
  27. 8 6
      misago/admin/discoverer.py
  28. 20 17
      misago/admin/hierarchy.py
  29. 30 21
      misago/admin/tests/test_admin_views.py
  30. 7 4
      misago/admin/testutils.py
  31. 10 10
      misago/admin/urlpatterns.py
  32. 1 2
      misago/admin/urls.py
  33. 0 1
      misago/admin/views/__init__.py
  34. 2 4
      misago/admin/views/auth.py
  35. 7 5
      misago/admin/views/errorpages.py
  36. 1 2
      misago/admin/views/generic/__init__.py
  37. 1 3
      misago/admin/views/generic/base.py
  38. 13 14
      misago/admin/views/generic/formsbuttons.py
  39. 14 34
      misago/admin/views/generic/list.py
  40. 1 3
      misago/admin/views/generic/mixin.py
  41. 20 12
      misago/admin/views/index.py
  42. 0 1
      misago/categories/__init__.py
  43. 9 6
      misago/categories/admin.py
  44. 1 1
      misago/categories/apps.py
  45. 61 60
      misago/categories/forms.py
  46. 54 13
      misago/categories/migrations/0001_initial.py
  47. 2 2
      misago/categories/migrations/0002_default_categories.py
  48. 5 10
      misago/categories/migrations/0003_categories_roles.py
  49. 7 1
      misago/categories/migrations/0004_category_last_thread.py
  50. 4 4
      misago/categories/models.py
  51. 10 15
      misago/categories/permissions.py
  52. 13 11
      misago/categories/serializers.py
  53. 1 4
      misago/categories/signals.py
  54. 160 130
      misago/categories/tests/test_categories_admin_views.py
  55. 3 9
      misago/categories/tests/test_category_model.py
  56. 130 90
      misago/categories/tests/test_permissions_admin_views.py
  57. 8 9
      misago/categories/tests/test_prunecategories.py
  58. 2 3
      misago/categories/tests/test_synchronizecategories.py
  59. 44 25
      misago/categories/tests/test_utils.py
  60. 0 2
      misago/categories/urls/__init__.py
  61. 1 6
      misago/categories/utils.py
  62. 11 9
      misago/categories/views/categoriesadmin.py
  63. 1 1
      misago/categories/views/categorieslist.py
  64. 23 25
      misago/categories/views/permsadmin.py
  65. 0 1
      misago/conf/__init__.py
  66. 2 1
      misago/conf/admin.py
  67. 0 13
      misago/conf/context_processors.py
  68. 0 5
      misago/conf/defaults.py
  69. 13 19
      misago/conf/forms.py
  70. 15 10
      misago/conf/migrations/0001_initial.py
  71. 4 5
      misago/conf/migrationutils.py
  72. 1 1
      misago/conf/models.py
  73. 17 15
      misago/conf/tests/test_admin_views.py
  74. 24 24
      misago/conf/tests/test_migrationutils.py
  75. 28 19
      misago/conf/tests/test_models.py
  76. 28 33
      misago/conf/tests/test_settings.py
  77. 3 6
      misago/conf/utils.py
  78. 10 11
      misago/conf/views.py
  79. 3 4
      misago/core/__init__.py
  80. 1 1
      misago/core/apipatch.py
  81. 2 2
      misago/core/apirouter.py
  82. 1 2
      misago/core/cachebuster.py
  83. 5 5
      misago/core/context_processors.py
  84. 2 0
      misago/core/decorators.py
  85. 14 8
      misago/core/errorpages.py
  86. 15 8
      misago/core/exceptionhandler.py
  87. 4 3
      misago/core/exceptions.py
  88. 6 2
      misago/core/forms.py
  89. 1 2
      misago/core/mail.py
  90. 6 6
      misago/core/management/commands/misagodbrelations.py
  91. 0 30
      misago/core/management/commands/testemailsetup.py
  92. 1 2
      misago/core/middleware/exceptionhandler.py
  93. 8 6
      misago/core/migrations/0001_initial.py
  94. 66 67
      misago/core/migrations/0002_basic_settings.py
  95. 14 15
      misago/core/page.py
  96. 7 16
      misago/core/pgutils.py
  97. 6 12
      misago/core/serializers.py
  98. 10 6
      misago/core/setup.py
  99. 13 10
      misago/core/shortcuts.py
  100. 1 1
      misago/core/templatetags/misago_capture.py
  101. 22 22
      misago/core/templatetags/misago_forms.py
  102. 0 2
      misago/core/templatetags/misago_pagetitle.py
  103. 2 2
      misago/core/testproject/serializers.py
  104. 35 11
      misago/core/testproject/urls.py
  105. 7 11
      misago/core/testproject/views.py
  106. 135 52
      misago/core/tests/test_apipatch.py
  107. 1 2
      misago/core/tests/test_cachebuster.py
  108. 2 2
      misago/core/tests/test_checks.py
  109. 1 2
      misago/core/tests/test_common_middleware_redirect.py
  110. 42 28
      misago/core/tests/test_context_processors.py
  111. 0 1
      misago/core/tests/test_decorators.py
  112. 1 1
      misago/core/tests/test_deprecations.py
  113. 2 6
      misago/core/tests/test_errorpages.py
  114. 10 18
      misago/core/tests/test_exceptionhandlers.py
  115. 1 4
      misago/core/tests/test_mailer.py
  116. 2 7
      misago/core/tests/test_migrationutils.py
  117. 4 19
      misago/core/tests/test_momentjs.py
  118. 6 3
      misago/core/tests/test_page.py
  119. 22 23
      misago/core/tests/test_serializers.py
  120. 4 4
      misago/core/tests/test_setup.py
  121. 117 91
      misago/core/tests/test_shortcuts.py
  122. 33 39
      misago/core/tests/test_templatetags.py
  123. 0 32
      misago/core/tests/test_testmailsetup.py
  124. 103 93
      misago/core/tests/test_utils.py
  125. 7 5
      misago/core/tests/test_validators.py
  126. 2 3
      misago/core/testutils.py
  127. 17 34
      misago/core/utils.py
  128. 1 2
      misago/core/validators.py
  129. 1 3
      misago/core/views.py
  130. 7 17
      misago/datamover/attachments.py
  131. 5 5
      misago/datamover/avatars.py
  132. 4 8
      misago/datamover/bans.py
  133. 21 13
      misago/datamover/categories.py
  134. 1 3
      misago/datamover/db.py
  135. 1 3
      misago/datamover/management/commands/buildmovesindex.py
  136. 2 4
      misago/datamover/management/commands/movecategories.py
  137. 1 2
      misago/datamover/management/commands/movesettings.py
  138. 11 19
      misago/datamover/management/commands/movethreads.py
  139. 7 16
      misago/datamover/management/commands/moveusers.py
  140. 3 5
      misago/datamover/management/commands/runmigration.py
  141. 0 3
      misago/datamover/markup/__init__.py
  142. 1 1
      misago/datamover/markup/quotes.py
  143. 11 4
      misago/datamover/migrations/0001_initial.py
  144. 1 0
      misago/datamover/movedids.py
  145. 1 1
      misago/datamover/polls.py
  146. 10 6
      misago/datamover/settings.py
  147. 21 33
      misago/datamover/threads.py
  148. 194 51
      misago/datamover/urls.py
  149. 17 16
      misago/datamover/users.py
  150. 1 2
      misago/faker/management/commands/createfakebans.py
  151. 12 12
      misago/faker/management/commands/createfakecategories.py
  152. 1 1
      misago/faker/management/commands/createfakefollowers.py
  153. 8 14
      misago/faker/management/commands/createfakethreads.py
  154. 8 11
      misago/faker/management/commands/createfakeusers.py
  155. 1 2
      misago/legal/context_processors.py
  156. 97 97
      misago/legal/migrations/0001_initial.py
  157. 10 8
      misago/legal/tests.py
  158. 8 4
      misago/legal/views.py
  159. 0 1
      misago/markup/__init__.py
  160. 2 7
      misago/markup/api.py
  161. 18 11
      misago/markup/bbcode/blocks.py
  162. 4 5
      misago/markup/bbcode/inline.py
  163. 1 1
      misago/markup/context_processors.py
  164. 4 2
      misago/markup/finalise.py
  165. 25 7
      misago/markup/flavours.py
  166. 2 3
      misago/markup/md/shortimgs.py
  167. 2 1
      misago/markup/md/striketrough.py
  168. 1 1
      misago/markup/mentions.py
  169. 14 9
      misago/markup/parser.py
  170. 3 3
      misago/markup/pipeline.py
  171. 4 0
      misago/markup/templatetags/misago_editor.py
  172. 8 12
      misago/markup/tests/test_api.py
  173. 2 4
      misago/markup/tests/test_checksums.py
  174. 17 36
      misago/markup/tests/test_mentions.py
  175. 1 3
      misago/markup/urls.py
  176. 1 1
      misago/readtracker/apps.py
  177. 9 10
      misago/readtracker/categoriestracker.py
  178. 14 8
      misago/readtracker/migrations/0001_initial.py
  179. 0 3
      misago/readtracker/models.py
  180. 0 3
      misago/readtracker/signals.py
  181. 2 4
      misago/readtracker/tests/test_dates.py
  182. 5 5
      misago/readtracker/tests/test_readtracker.py
  183. 6 5
      misago/readtracker/threadstracker.py
  184. 3 16
      misago/search/permissions.py
  185. 2 1
      misago/search/searchprovider.py
  186. 2 4
      misago/search/searchproviders.py
  187. 11 11
      misago/search/tests/test_api.py
  188. 7 13
      misago/search/tests/test_searchproviders.py
  189. 21 20
      misago/search/tests/test_views.py
  190. 0 2
      misago/search/urls/__init__.py
  191. 1 1
      misago/search/views.py
  192. 6 4
      misago/threads/admin.py
  193. 16 12
      misago/threads/api/attachments.py
  194. 3 2
      misago/threads/api/pollvotecreateendpoint.py
  195. 1 2
      misago/threads/api/postendpoints/edits.py
  196. 1 3
      misago/threads/api/postendpoints/likes.py
  197. 7 3
      misago/threads/api/postendpoints/merge.py
  198. 8 2
      misago/threads/api/postendpoints/move.py
  199. 4 0
      misago/threads/api/postendpoints/patch_event.py
  200. 12 2
      misago/threads/api/postendpoints/patch_post.py
  201. 1 1
      misago/threads/api/postendpoints/read.py
  202. 3 4
      misago/threads/api/postendpoints/split.py
  203. 12 11
      misago/threads/api/postingendpoint/__init__.py
  204. 28 20
      misago/threads/api/postingendpoint/attachments.py
  205. 11 10
      misago/threads/api/postingendpoint/category.py
  206. 4 7
      misago/threads/api/postingendpoint/emailnotification.py
  207. 2 1
      misago/threads/api/postingendpoint/floodprotection.py
  208. 13 15
      misago/threads/api/postingendpoint/participants.py
  209. 2 3
      misago/threads/api/postingendpoint/privatethread.py
  210. 4 8
      misago/threads/api/postingendpoint/recordedit.py
  211. 2 4
      misago/threads/api/postingendpoint/reply.py
  212. 3 4
      misago/threads/api/postingendpoint/subscribe.py
  213. 3 4
      misago/threads/api/postingendpoint/syncprivatethreads.py
  214. 5 3
      misago/threads/api/threadendpoints/editor.py
  215. 1 1
      misago/threads/api/threadendpoints/list.py
  216. 38 33
      misago/threads/api/threadendpoints/merge.py
  217. 42 31
      misago/threads/api/threadendpoints/patch.py
  218. 3 4
      misago/threads/api/threadendpoints/read.py
  219. 3 2
      misago/threads/api/threadpoll.py
  220. 25 16
      misago/threads/api/threadposts.py
  221. 11 8
      misago/threads/api/threads.py
  222. 1 1
      misago/threads/apps.py
  223. 0 4
      misago/threads/context_processors.py
  224. 24 16
      misago/threads/forms.py
  225. 1 1
      misago/threads/management/commands/clearattachments.py
  226. 7 9
      misago/threads/middleware.py
  227. 280 58
      misago/threads/migrations/0001_initial.py
  228. 57 56
      misago/threads/migrations/0002_threads_settings.py
  229. 27 31
      misago/threads/migrations/0003_attachment_types.py
  230. 56 54
      misago/threads/migrations/0004_update_settings.py
  231. 21 41
      misago/threads/models/attachment.py
  232. 2 2
      misago/threads/models/attachmenttype.py
  233. 2 5
      misago/threads/models/poll.py
  234. 5 5
      misago/threads/models/post.py
  235. 1 3
      misago/threads/models/subscription.py
  236. 10 13
      misago/threads/models/thread.py
  237. 4 18
      misago/threads/models/threadparticipant.py
  238. 9 7
      misago/threads/moderation/posts.py
  239. 17 14
      misago/threads/moderation/threads.py
  240. 6 14
      misago/threads/paginator.py
  241. 45 48
      misago/threads/participants.py
  242. 9 12
      misago/threads/permissions/attachments.py
  243. 51 40
      misago/threads/permissions/polls.py
  244. 41 36
      misago/threads/permissions/privatethreads.py
  245. 171 119
      misago/threads/permissions/threads.py
  246. 13 8
      misago/threads/search.py
  247. 8 9
      misago/threads/serializers/attachment.py
  248. 4 16
      misago/threads/serializers/feed.py
  249. 12 6
      misago/threads/serializers/moderation.py
  250. 27 25
      misago/threads/serializers/poll.py
  251. 8 8
      misago/threads/serializers/pollvote.py
  252. 14 13
      misago/threads/serializers/post.py
  253. 8 8
      misago/threads/serializers/postedit.py
  254. 8 8
      misago/threads/serializers/postlike.py
  255. 18 18
      misago/threads/serializers/thread.py
  256. 1 7
      misago/threads/serializers/threadparticipant.py
  257. 15 13
      misago/threads/signals.py
  258. 1 1
      misago/threads/subscriptions.py
  259. 3 11
      misago/threads/templatetags/misago_poststags.py
  260. 48 19
      misago/threads/tests/test_attachmentadmin_views.py
  261. 90 72
      misago/threads/tests/test_attachments_api.py
  262. 28 34
      misago/threads/tests/test_attachments_middleware.py
  263. 86 53
      misago/threads/tests/test_attachmenttypeadmin_views.py
  264. 34 21
      misago/threads/tests/test_attachmentview.py
  265. 3 3
      misago/threads/tests/test_clearattachments.py
  266. 51 36
      misago/threads/tests/test_emailnotification_middleware.py
  267. 3 7
      misago/threads/tests/test_events.py
  268. 19 11
      misago/threads/tests/test_floodprotection.py
  269. 2 5
      misago/threads/tests/test_floodprotection_middleware.py
  270. 58 26
      misago/threads/tests/test_gotoviews.py
  271. 57 42
      misago/threads/tests/test_paginator.py
  272. 26 8
      misago/threads/tests/test_participants.py
  273. 74 44
      misago/threads/tests/test_post_mentions.py
  274. 51 30
      misago/threads/tests/test_post_model.py
  275. 319 135
      misago/threads/tests/test_privatethread_patch_api.py
  276. 7 5
      misago/threads/tests/test_privatethread_reply_api.py
  277. 195 167
      misago/threads/tests/test_privatethread_start_api.py
  278. 3 9
      misago/threads/tests/test_privatethread_view.py
  279. 5 4
      misago/threads/tests/test_privatethreads.py
  280. 37 48
      misago/threads/tests/test_privatethreads_api.py
  281. 5 7
      misago/threads/tests/test_privatethreads_lists.py
  282. 13 5
      misago/threads/tests/test_search.py
  283. 55 34
      misago/threads/tests/test_subscription_middleware.py
  284. 5 24
      misago/threads/tests/test_subscriptions.py
  285. 2 1
      misago/threads/tests/test_sync_unread_private_threads.py
  286. 2 3
      misago/threads/tests/test_synchronizethreads.py
  287. 72 89
      misago/threads/tests/test_thread_editreply_api.py
  288. 143 153
      misago/threads/tests/test_thread_merge_api.py
  289. 23 24
      misago/threads/tests/test_thread_model.py
  290. 468 295
      misago/threads/tests/test_thread_patch_api.py
  291. 13 8
      misago/threads/tests/test_thread_poll_api.py
  292. 134 131
      misago/threads/tests/test_thread_pollcreate_api.py
  293. 40 46
      misago/threads/tests/test_thread_polldelete_api.py
  294. 282 264
      misago/threads/tests/test_thread_polledit_api.py
  295. 63 51
      misago/threads/tests/test_thread_pollvotes_api.py
  296. 39 37
      misago/threads/tests/test_thread_postdelete_api.py
  297. 13 20
      misago/threads/tests/test_thread_postedits_api.py
  298. 15 14
      misago/threads/tests/test_thread_postlikes_api.py
  299. 234 126
      misago/threads/tests/test_thread_postmerge_api.py
  300. 162 103
      misago/threads/tests/test_thread_postmove_api.py
  301. 538 315
      misago/threads/tests/test_thread_postpatch_api.py
  302. 9 7
      misago/threads/tests/test_thread_postread_api.py
  303. 346 237
      misago/threads/tests/test_thread_postsplit_api.py
  304. 44 43
      misago/threads/tests/test_thread_reply_api.py
  305. 139 109
      misago/threads/tests/test_thread_start_api.py
  306. 3 4
      misago/threads/tests/test_threadparticipant_model.py
  307. 38 52
      misago/threads/tests/test_threads_api.py
  308. 204 248
      misago/threads/tests/test_threads_editor_api.py
  309. 434 288
      misago/threads/tests/test_threads_merge_api.py
  310. 12 10
      misago/threads/tests/test_threads_moderation.py
  311. 257 274
      misago/threads/tests/test_threadslists.py
  312. 39 70
      misago/threads/tests/test_threadview.py
  313. 15 4
      misago/threads/tests/test_treesmap.py
  314. 58 32
      misago/threads/tests/test_utils.py
  315. 3 3
      misago/threads/tests/test_validators.py
  316. 45 30
      misago/threads/testutils.py
  317. 103 62
      misago/threads/threadtypes/privatethread.py
  318. 142 92
      misago/threads/threadtypes/thread.py
  319. 1 0
      misago/threads/threadtypes/treesmap.py
  320. 54 58
      misago/threads/urls/__init__.py
  321. 8 2
      misago/threads/urls/api.py
  322. 4 7
      misago/threads/utils.py
  323. 32 20
      misago/threads/validators.py
  324. 18 11
      misago/threads/viewmodels/category.py
  325. 2 7
      misago/threads/viewmodels/post.py
  326. 7 5
      misago/threads/viewmodels/posts.py
  327. 20 13
      misago/threads/viewmodels/thread.py
  328. 23 24
      misago/threads/viewmodels/threads.py
  329. 6 9
      misago/threads/views/admin/attachments.py
  330. 4 4
      misago/threads/views/admin/attachmenttypes.py
  331. 0 3
      misago/threads/views/attachment.py
  332. 16 6
      misago/threads/views/goto.py
  333. 6 7
      misago/threads/views/list.py
  334. 7 8
      misago/threads/views/thread.py
  335. 4 7
      misago/urls.py
  336. 3 8
      misago/users/activepostersranking.py
  337. 16 5
      misago/users/admin.py
  338. 78 64
      misago/users/api/auth.py
  339. 2 1
      misago/users/api/rest_permissions.py
  340. 38 30
      misago/users/api/userendpoints/avatar.py
  341. 5 11
      misago/users/api/userendpoints/changeemail.py
  342. 9 12
      misago/users/api/userendpoints/changepassword.py
  343. 8 15
      misago/users/api/userendpoints/create.py
  344. 2 10
      misago/users/api/userendpoints/list.py
  345. 11 15
      misago/users/api/userendpoints/signature.py
  346. 11 20
      misago/users/api/userendpoints/username.py
  347. 9 11
      misago/users/api/usernamechanges.py
  348. 35 22
      misago/users/api/users.py
  349. 3 5
      misago/users/apps.py
  350. 1 3
      misago/users/avatars/__init__.py
  351. 12 31
      misago/users/avatars/dynamic.py
  352. 5 8
      misago/users/avatars/gallery.py
  353. 8 7
      misago/users/avatars/store.py
  354. 2 8
      misago/users/avatars/uploaded.py
  355. 11 25
      misago/users/bans.py
  356. 10 6
      misago/users/captcha.py
  357. 2 10
      misago/users/context_processors.py
  358. 3 8
      misago/users/credentialchange.py
  359. 6 8
      misago/users/decorators.py
  360. 23 51
      misago/users/djangoadmin.py
  361. 46 87
      misago/users/forms/admin.py
  362. 27 47
      misago/users/forms/auth.py
  363. 1 1
      misago/users/forms/register.py
  364. 0 1
      misago/users/management/commands/buildactivepostersranking.py
  365. 45 25
      misago/users/management/commands/createsuperuser.py
  366. 5 2
      misago/users/management/commands/synchronizeusers.py
  367. 0 3
      misago/users/middleware.py
  368. 173 42
      misago/users/migrations/0001_initial.py
  369. 228 214
      misago/users/migrations/0002_users_settings.py
  370. 1 1
      misago/users/migrations/0003_bans_version_tracker.py
  371. 2 5
      misago/users/migrations/0004_default_ranks.py
  372. 8 1
      misago/users/migrations/0005_dj_19_update.py
  373. 145 131
      misago/users/migrations/0006_update_settings.py
  374. 9 3
      misago/users/migrations/0007_auto_20170219_1639.py
  375. 1 4
      misago/users/models/avatar.py
  376. 11 9
      misago/users/models/ban.py
  377. 58 64
      misago/users/models/user.py
  378. 0 1
      misago/users/online/utils.py
  379. 14 15
      misago/users/permissions/account.py
  380. 2 0
      misago/users/permissions/decorators.py
  381. 18 24
      misago/users/permissions/delete.py
  382. 21 22
      misago/users/permissions/moderation.py
  383. 22 48
      misago/users/permissions/profiles.py
  384. 9 14
      misago/users/search.py
  385. 15 15
      misago/users/serializers/auth.py
  386. 10 3
      misago/users/serializers/ban.py
  387. 9 5
      misago/users/serializers/moderation.py
  388. 4 8
      misago/users/serializers/options.py
  389. 2 2
      misago/users/serializers/rank.py
  390. 17 12
      misago/users/serializers/user.py
  391. 4 6
      misago/users/serializers/usernamechange.py
  392. 1 6
      misago/users/signals.py
  393. 1 1
      misago/users/signatures.py
  394. 0 3
      misago/users/templatetags/misago_avatars.py
  395. 55 28
      misago/users/tests/test_activation_views.py
  396. 2 4
      misago/users/tests/test_activepostersranking.py
  397. 154 78
      misago/users/tests/test_auth_api.py
  398. 6 7
      misago/users/tests/test_auth_backend.py
  399. 20 13
      misago/users/tests/test_auth_views.py
  400. 19 22
      misago/users/tests/test_avatars.py
  401. 26 17
      misago/users/tests/test_avatarserver_views.py
  402. 3 12
      misago/users/tests/test_ban_model.py
  403. 67 40
      misago/users/tests/test_banadmin_views.py
  404. 18 20
      misago/users/tests/test_bans.py
  405. 1 4
      misago/users/tests/test_captcha_api.py
  406. 1 2
      misago/users/tests/test_commands.py
  407. 4 2
      misago/users/tests/test_createsuperuser.py
  408. 4 8
      misago/users/tests/test_credentialchange.py
  409. 3 4
      misago/users/tests/test_decorators.py
  410. 7 4
      misago/users/tests/test_djangoadmin_auth.py
  411. 33 20
      misago/users/tests/test_forgottenpassword_views.py
  412. 7 5
      misago/users/tests/test_invalidatebans.py
  413. 35 11
      misago/users/tests/test_lists_views.py
  414. 33 17
      misago/users/tests/test_options_views.py
  415. 53 30
      misago/users/tests/test_profile_views.py
  416. 86 33
      misago/users/tests/test_rankadmin_views.py
  417. 1 2
      misago/users/tests/test_realip_middleware.py
  418. 32 20
      misago/users/tests/test_rest_permissions.py
  419. 11 7
      misago/users/tests/test_search.py
  420. 1 2
      misago/users/tests/test_signatures.py
  421. 5 8
      misago/users/tests/test_testutils.py
  422. 144 131
      misago/users/tests/test_user_avatar_api.py
  423. 53 40
      misago/users/tests/test_user_changeemail_api.py
  424. 49 42
      misago/users/tests/test_user_changepassword_api.py
  425. 68 49
      misago/users/tests/test_user_create_api.py
  426. 66 14
      misago/users/tests/test_user_feeds_api.py
  427. 6 2
      misago/users/tests/test_user_model.py
  428. 27 27
      misago/users/tests/test_user_signature_api.py
  429. 66 53
      misago/users/tests/test_user_username_api.py
  430. 265 239
      misago/users/tests/test_useradmin_views.py
  431. 3 8
      misago/users/tests/test_usernamechanges_api.py
  432. 179 156
      misago/users/tests/test_users_api.py
  433. 2 4
      misago/users/tests/test_utils.py
  434. 5 8
      misago/users/tests/test_validators.py
  435. 3 3
      misago/users/testutils.py
  436. 13 21
      misago/users/tokens.py
  437. 53 29
      misago/users/urls/__init__.py
  438. 5 3
      misago/users/urls/api.py
  439. 15 27
      misago/users/validators.py
  440. 3 2
      misago/users/viewmodels/activeposters.py
  441. 3 4
      misago/users/viewmodels/followers.py
  442. 2 3
      misago/users/viewmodels/posts.py
  443. 5 5
      misago/users/viewmodels/rankusers.py
  444. 10 15
      misago/users/viewmodels/threads.py
  445. 24 16
      misago/users/views/activation.py
  446. 8 10
      misago/users/views/admin/bans.py
  447. 1 1
      misago/users/views/admin/ranks.py
  448. 41 48
      misago/users/views/admin/users.py
  449. 1 2
      misago/users/views/auth.py
  450. 18 18
      misago/users/views/forgottenpassword.py
  451. 2 5
      misago/users/views/lists.py
  452. 18 13
      misago/users/views/options.py
  453. 11 19
      misago/users/views/profile.py
  454. 38 0
      pycodestyle.py

+ 2 - 2
.gitignore

@@ -39,8 +39,8 @@ coverage.xml
 report.txt
 flake8.txt
 
-# Translations
-*.mo
+# Pylint report
+pylint.txt
 
 # Mr Developer
 .mr.developer.cfg

+ 3 - 3
.pylintrc

@@ -1,4 +1,4 @@
 [Basic]
-disable=abstract-method,cyclic-import,duplicate-code,file-ignored,invalid-name,locally-disabled,missing-docstring,no-init,no-member,no-self-use,old-style-class,super-on-old-class,too-few-public-methods,too-many-ancestors,unused-argument
-max-line-length=120
-max-locals=20
+disable=all
+enable=function-redefined,import-self,redefined-outer-name,reimported,return-in-init,undefined-all-variable,undefined-variable,unreachable,unused-import,unused-variable
+reports=no

+ 11 - 0
.style.yapf

@@ -0,0 +1,11 @@
+[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

+ 6 - 0
cleansource

@@ -0,0 +1,6 @@
+#!/bin/bash
+
+yapf -ir ${1:-misago} -e '*/project_template/**/*.py' -e '*/conf/defaults.py'
+isort -rc ${1:-misago}
+pylint ${1:-misago}
+python pycodestyle.py ${1:-misago}

+ 0 - 0
extras/__init__.py


+ 5 - 0
extras/config.py

@@ -0,0 +1,5 @@
+from django.utils.six.moves import configparser
+
+
+yapf = configparser.ConfigParser()
+yapf.read('.style.yapf')

+ 1 - 1
fixabsoluteimports.py → extras/fixabsoluteimports.py

@@ -87,7 +87,7 @@ def clean_import(package, import_path):
 
 
 if __name__ == '__main__':
-    for args in os.walk('misago'):
+    for args in os.walk('../misago'):
         walk_directory(*args)
 
     print "\nDone! Don't forget to run isort to fix imports ordering!"

+ 160 - 0
extras/fixdictsformatting.py

@@ -0,0 +1,160 @@
+from __future__ import unicode_literals
+
+import sys
+
+from lib2to3.pytree import Node, Leaf
+from lib2to3.fixer_util import token, syms
+
+from yapf.yapflib import pytree_utils
+
+from django.utils import six
+
+from .config import yapf as yapf_config
+
+
+MAX_LINE_LENGTH = yapf_config.getint('style', 'column_limit') + 1
+
+
+def fix_formatting(filesource):
+    if not ('{'  in filesource and ('[' in filesource or '(' in filesource)):
+        return filesource
+
+    tree = pytree_utils.ParseCodeToTree(filesource)
+    for node in tree.children:
+        walk_tree(node, node.children)
+    return six.text_type(tree)
+
+
+def walk_tree(node, children):
+    for item in children:
+        if item.type == syms.dictsetmaker:
+            indent = item.parent.children[-1].column
+            walk_dict_tree(item, item.children, indent)
+        else:
+            walk_tree(item, item.children)
+
+
+def walk_dict_tree(node, children, indent):
+    for item in children:
+        prev = item.prev_sibling
+        if isinstance(prev, Leaf) and prev.value == ':':
+            if isinstance(item, Leaf):
+                if six.text_type(item).startswith("\n"):
+                    item.replace(Leaf(
+                        item.type,
+                        item.value,
+                        prefix=' ',
+                    ))
+            elif six.text_type(item).strip()[0] in ('[', '{'):
+                walk_tree(item, item.children)
+            else:
+                walk_dedent_tree(item, item.children, indent)
+
+
+def walk_dedent_tree(node, children, indent):
+    force_split_next = False
+    for item in children:
+        prev = item.prev_sibling
+        if not prev:
+            if isinstance(item, Leaf) and six.text_type(item).startswith("\n"):
+                prev = node.prev_sibling
+                next = node.next_sibling
+                final_length = 0
+
+                if prev and "\n" not in six.text_type(node).strip():
+                    final_length = prev.column + len(six.text_type(node).strip()) + 3
+
+                item.replace(Leaf(
+                    item.type,
+                    item.value,
+                    prefix=' ',
+                ))
+
+                if final_length and final_length > MAX_LINE_LENGTH:
+                    # tell next call to walk_dedent_tree_node that we need
+                    # different stringformat tactic
+                    force_split_next = True
+        elif isinstance(item, Node):
+            if node.type == syms.power:
+                for subitem in item.children[1:]:
+                    walk_dedent_power_node(subitem, subitem.children, indent)
+            else:
+                for subitem in item.children[1:]:
+                    walk_dedent_tree_node(subitem, subitem.children, indent, force_split_next)
+                    force_split_next = False
+
+
+def walk_dedent_tree_node(node, children, indent, force_split_next=False):
+    if six.text_type(node).startswith("\n"):
+        if isinstance(node, Leaf):
+            prev = node.prev_sibling
+            next = node.next_sibling
+
+            is_followup = prev and prev.type == token.STRING and node.type == token.STRING
+            if is_followup:
+                new_value = node.value
+
+                # insert linebreak after last string in braces, so its closing brace moves to new line
+                if not node.next_sibling:
+                    closing_bracket = node.parent.parent.children[-1]
+                    if not six.text_type(closing_bracket).startswith("\n"):
+                        new_value = "%s\n%s" % (node.value, (' ' * (indent + 4)))
+
+                node.replace(Leaf(
+                    node.type,
+                    new_value,
+                    prefix="\n%s" % (' ' * (indent + 8)),
+                ))
+            else:
+                if six.text_type(node).strip() in (')', '}'):
+                    new_prefix = "\n%s" % (' ' * (indent + 4))
+                else:
+                    new_prefix = "\n%s" % (' ' * (indent + 8))
+
+                node.replace(Leaf(
+                    node.type,
+                    node.value,
+                    prefix=new_prefix
+                ))
+        else:
+            for item in children:
+                walk_dedent_tree_node(item, item.children, indent)
+    elif isinstance(node, Leaf):
+        if node.type == token.STRING:
+            strings_tuple = node.parent.parent
+
+            prev = node.prev_sibling
+            next = node.next_sibling
+
+            is_opening = prev is None and six.text_type(strings_tuple).strip()[0] == '('
+            has_followup = next and next.type == token.STRING
+
+            if is_opening and has_followup:
+                node.replace(Leaf(
+                    node.type,
+                    node.value,
+                    prefix="\n%s" % (' ' * (indent + 8)),
+                ))
+            elif force_split_next:
+                node.replace(Leaf(
+                    node.type,
+                    "%s\n%s" % (node.value, (' ' * (indent + 4))),
+                    prefix="\n%s" % (' ' * (indent + 8)),
+                ))
+    else:
+        for item in children:
+            walk_dedent_tree_node(item, item.children, indent)
+
+
+def walk_dedent_power_node(node, children, indent):
+    if isinstance(node, Leaf):
+        if six.text_type(node).startswith("\n"):
+            node.replace(Leaf(
+                node.type,
+                node.value,
+                prefix=node.prefix[:-4],
+            ))
+    else:
+        for item in children:
+            walk_dedent_power_node(item, item.children, indent)
+

+ 2 - 1
fixrelativeimports.py → extras/fixrelativeimports.py

@@ -45,8 +45,9 @@ def clean_import(package, match):
 
     return '.'.join(import_path)
 
+
 if __name__ == '__main__':
-    for args in os.walk('misago'):
+    for args in os.walk('../misago'):
         walk_directory(*args)
 
     print "\nDone! Don't forget to run isort to fix imports ordering!"

+ 5 - 6
misago/acl/algebra.py

@@ -9,17 +9,16 @@ def _roles_acls(key_name, roles):
 
 def sum_acls(result_acl, acls=None, roles=None, key=None, **permissions):
     if acls and roles:
-        raise ValueError(
-            'You can not provide both "acls" and "roles" arguments')
+        raise ValueError('You can not provide both "acls" and "roles" arguments')
 
     if (acls is None) and (roles is None):
-        raise ValueError(
-            'You have to provide either "acls" and "roles" argument')
+        raise ValueError('You have to provide either "acls" and "roles" argument')
 
     if roles is not None:
         if not key:
-            raise ValueError('You have to provide "key" argument if '
-                             'you are passing roles instead of acls')
+            raise ValueError(
+                'You have to provide "key" argument if you are passing roles instead of acls'
+            )
         acls = _roles_acls(key, roles)
 
     for permission, compare in permissions.items():

+ 4 - 14
misago/acl/api.py

@@ -10,8 +10,6 @@ properties defined by ACL providers within their "add_acl_to_target"
 """
 import copy
 
-from django.contrib.auth import get_user_model
-
 from misago.core import threadstore
 from misago.core.cache import cache
 
@@ -21,9 +19,7 @@ from .providers import providers
 
 
 def get_user_acl(user):
-    """
-    Get ACL for User
-    """
+    """get ACL for User"""
     acl_key = 'acl_%s' % user.acl_key
 
     acl_cache = threadstore.get(acl_key)
@@ -43,9 +39,7 @@ def get_user_acl(user):
 
 
 def add_acl(user, target):
-    """
-    Add valid ACL to target (iterable of objects or single object)
-    """
+    """add valid ACL to target (iterable of objects or single object)"""
     if hasattr(target, '__iter__'):
         for item in target:
             _add_acl_to_target(user, item)
@@ -54,9 +48,7 @@ def add_acl(user, target):
 
 
 def _add_acl_to_target(user, target):
-    """
-    Add valid ACL to single target, helper for add_acl function
-    """
+    """add valid ACL to single target, helper for add_acl function"""
     target.acl = {}
 
     for annotator in providers.get_type_annotators(target):
@@ -64,9 +56,7 @@ def _add_acl_to_target(user, target):
 
 
 def serialize_acl(target):
-    """
-    Serialize authenticated user's ACL
-    """
+    """serialize authenticated user's ACL"""
     serialized_acl = copy.deepcopy(target.acl_cache)
 
     for serializer in providers.get_type_serializers(target):

+ 1 - 3
misago/acl/builder.py

@@ -2,9 +2,7 @@ from .providers import providers
 
 
 def build_acl(roles):
-    """
-    Build ACL for given roles
-    """
+    """build ACL for given roles"""
     acl = {}
 
     for extension, module in providers.list():

+ 1 - 0
misago/acl/decorators.py

@@ -10,4 +10,5 @@ def return_boolean(f):
             return False
         else:
             return True
+
     return decorator

+ 11 - 9
misago/acl/forms.py

@@ -14,9 +14,7 @@ class RoleForm(forms.ModelForm):
 
 
 def get_permissions_forms(role, data=None):
-    """
-    Utility function for building forms in admin
-    """
+    """utility function for building forms in admin"""
     role_permissions = role.permissions
 
     perms_forms = []
@@ -25,18 +23,22 @@ 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))
-            else:
                 perms_forms.append(FormType(
-                    initial=role_permissions.get(extension),
-                    prefix=extension
+                    data,
+                    prefix=extension,
                 ))
+            else:
+                perms_forms.append(
+                    FormType(
+                        initial=role_permissions.get(extension),
+                        prefix=extension,
+                    )
+                )
 
     return perms_forms

+ 7 - 4
misago/acl/migrations/0001_initial.py

@@ -9,14 +9,17 @@ from misago.acl.models import permissions_default
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-    ]
+    dependencies = []
 
     operations = [
         migrations.CreateModel(
             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)),
@@ -24,6 +27,6 @@ class Migration(migrations.Migration):
             options={
                 'abstract': False,
             },
-            bases=(models.Model,),
+            bases=(models.Model, ),
         ),
     ]

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

@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-from django.db import migrations, models
+from django.db import migrations
 
 from misago.acl.constants import ACL_CACHEBUSTER
 from misago.core.migrationutils import cachebuster_register_cache

+ 0 - 1
misago/acl/migrations/0003_default_roles.py

@@ -163,7 +163,6 @@ def create_default_roles(apps, schema_editor):
             'misago.users.permissions.profiles': {
                 'can_see_ban_details': 1,
             },
-
             'misago.users.permissions.moderation': {
                 'can_ban_users': 1,
                 'max_ban_length': 14,

+ 2 - 4
misago/acl/panels.py

@@ -4,10 +4,8 @@ from django.utils.translation import ugettext_lazy as _
 
 
 class MisagoACLPanel(Panel):
-    """
-    Panel that displays current user's ACL
-    """
-    title = _('Misago User ACL')
+    """panel that displays current user's ACL"""
+    title = _("Misago User ACL")
     template = 'misago/acl_debug.html'
 
     @property

+ 4 - 7
misago/acl/providers.py

@@ -3,8 +3,9 @@ from importlib import import_module
 from misago.conf import settings
 
 
-# Manager for permission providers
 class PermissionProviders(object):
+    """manager for permission providers"""
+
     def __init__(self):
         self._initialized = False
         self._providers = []
@@ -33,15 +34,11 @@ class PermissionProviders(object):
             types_dict[hashType] = tuple(types_dict[hashType])
 
     def acl_annotator(self, hashable_type, func):
-        """
-        registers ACL annotator for specified types
-        """
+        """registers ACL annotator for specified types"""
         self._annotators.setdefault(hashable_type, []).append(func)
 
     def acl_serializer(self, hashable_type, func):
-        """
-        registers ACL serializer for specified types
-        """
+        """registers ACL serializer for specified types"""
         self._serializers.setdefault(hashable_type, []).append(func)
 
     def get_type_annotators(self, obj):

+ 4 - 4
misago/acl/tests/test_acl_algebra.py

@@ -28,7 +28,6 @@ class ComparisionsTests(TestCase):
         self.assertEqual(algebra.lower(2, 2), 2)
         self.assertEqual(algebra.lower(True, False), False)
 
-
     def test_lower_non_zero(self):
         """lower non-zero wins test"""
         self.assertEqual(algebra.lower_non_zero(1, 3), 1)
@@ -73,13 +72,14 @@ class SumACLTests(TestCase):
         }
 
         acl = algebra.sum_acls(
-            defaults, acls=test_acls,
+            defaults,
+            acls=test_acls,
             can_see=algebra.greater,
             can_hear=algebra.greater,
             max_speed=algebra.greater,
             min_age=algebra.lower,
-            speed_limit=algebra.greater_or_zero
-            )
+            speed_limit=algebra.greater_or_zero,
+        )
 
         self.assertEqual(acl['can_see'], 1)
         self.assertEqual(acl['can_hear'], 1)

+ 1 - 2
misago/acl/tests/test_api.py

@@ -11,8 +11,7 @@ UserModel = get_user_model()
 class GetUserACLTests(TestCase):
     def test_get_authenticated_acl(self):
         """get ACL for authenticated user"""
-        test_user = UserModel.objects.create_user(
-            'Bob', 'bob@bob.com', 'pass123')
+        test_user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
 
         acl = get_user_acl(test_user)
 

+ 33 - 13
misago/acl/tests/test_roleadmin_views.py

@@ -26,8 +26,9 @@ class RoleAdminViewsTests(AdminTestCase):
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
-            reverse('misago:admin:permissions:users:new'),
-            data=fake_data({'name': 'Test Role'})
+            reverse('misago:admin:permissions:users:new'), data=fake_data({
+                'name': 'Test Role',
+            })
         )
         self.assertEqual(response.status_code, 302)
 
@@ -39,19 +40,28 @@ class RoleAdminViewsTests(AdminTestCase):
     def test_edit_view(self):
         """edit role view has no showstoppers"""
         self.client.post(
-            reverse('misago:admin:permissions:users:new'),
-            data=fake_data({'name': 'Test Role'})
+            reverse('misago:admin:permissions:users:new'), data=fake_data({
+                '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}))
+        response = self.client.get(
+            reverse('misago:admin:permissions:users:edit', kwargs={
+                'pk': test_role.pk,
+            })
+        )
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, 'Test Role')
 
         response = self.client.post(
-            reverse('misago:admin:permissions:users:edit', kwargs={'pk': test_role.pk}),
-            data=fake_data({'name': 'Top Lel'})
+            reverse('misago:admin:permissions:users:edit', kwargs={
+                'pk': test_role.pk,
+            }),
+            data=fake_data({
+                'name': 'Top Lel',
+            })
         )
         self.assertEqual(response.status_code, 302)
 
@@ -63,23 +73,33 @@ class RoleAdminViewsTests(AdminTestCase):
     def test_users_view(self):
         """users with this role view has no showstoppers"""
         response = self.client.post(
-            reverse('misago:admin:permissions:users:new'),
-            data=fake_data({'name': 'Test Role'})
+            reverse('misago:admin:permissions:users:new'), data=fake_data({
+                '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}))
+        response = self.client.get(
+            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=fake_data({'name': 'Test Role'})
+            reverse('misago:admin:permissions:users:new'), data=fake_data({
+                '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}))
+        response = self.client.post(
+            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

+ 6 - 8
misago/acl/views.py

@@ -17,7 +17,7 @@ class RoleAdmin(generic.AdminBaseMixin):
 
 
 class RolesList(RoleAdmin, generic.ListView):
-    ordering = (('name', None),)
+    ordering = (('name', None), )
 
 
 class RoleFormMixin(object):
@@ -43,8 +43,7 @@ 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:
                     return redirect(request.path)
@@ -54,12 +53,12 @@ class RoleFormMixin(object):
                 form.add_error(None, _("Form contains errors."))
 
         return self.render(
-            request,
-            {
+            request, {
                 'form': form,
                 'target': target,
                 'perms_forms': perms_forms,
-            })
+            }
+        )
 
 
 class NewRole(RoleFormMixin, RoleAdmin, generic.ModelFormView):
@@ -73,8 +72,7 @@ class EditRole(RoleFormMixin, RoleAdmin, generic.ModelFormView):
 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.')
+            message = _('Role "%(name)s" is special role and can\'t be deleted.')
             return message % {'name': target.name}
 
     def button_action(self, request, target):

+ 0 - 1
misago/admin/__init__.py

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

+ 0 - 1
misago/admin/admin.py

@@ -1,4 +1,3 @@
-from django.conf.urls import url
 from django.utils.deprecation import MiddlewareMixin
 from django.utils.translation import ugettext_lazy as _
 

+ 4 - 0
misago/admin/auth.py

@@ -67,9 +67,13 @@ def django_login_handler(sender, **kwargs):
         admin_namespace = False
     if admin_namespace and user.is_staff:
         start_admin_session(request, user)
+
+
 dj_auth.signals.user_logged_in.connect(django_login_handler)
 
 
 def django_logout_handler(sender, **kwargs):
     close_admin_session(kwargs['request'])
+
+
 dj_auth.signals.user_logged_out.connect(django_logout_handler)

+ 8 - 6
misago/admin/discoverer.py

@@ -9,9 +9,11 @@ from .urlpatterns import urlpatterns
 def discover_misago_admin():
     for app in apps.get_app_configs():
         module = import_module(app.name)
-        if hasattr(module, 'admin'):
-            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)
+        if not hasattr(module, 'admin'):
+            continue
+
+        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)

+ 20 - 17
misago/admin/hierarchy.py

@@ -81,8 +81,7 @@ 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
@@ -100,25 +99,20 @@ class AdminHierarchyBuilder(object):
         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']
-                    )
+                    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'])
+                        node_added = parent.add_node(node_obj, after=node['after'])
                     elif node['before']:
-                        node_added = parent.add_node(
-                            node_obj, before=node['before'])
+                        node_added = parent.add_node(node_obj, before=node['before'])
                     else:
                         node_added = parent.add_node(node_obj)
 
@@ -133,11 +127,20 @@ 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):
+    def add_node(
+            self,
+            name=None,
+            icon=None,
+            parent='misago:admin',
+            after=None,
+            before=None,
+            namespace=None,
+            link=None
+    ):
         if self.nodes_dict:
-            raise RuntimeError("Misago admin site has already been "
-                               "initialized. You can't add new nodes to it.")
+            raise RuntimeError(
+                "Misago admin site has already been initialized. You can't add new nodes to it."
+            )
 
         if after and before:
             raise ValueError("after and before arguments are exclusive")

+ 30 - 21
misago/admin/tests/test_admin_views.py

@@ -22,11 +22,7 @@ class AdminProtectedNamespaceTests(TestCase):
     def test_valid_cases(self):
         """get_protected_namespace returns true for protected links"""
         links_prefix = reverse('misago:admin:index')
-        TEST_CASES = (
-            '',
-            'somewhere/',
-            'ejksajdlksajldjskajdlksajlkdas',
-        )
+        TEST_CASES = ('', 'somewhere/', 'ejksajdlksajldjskajdlksajlkdas', )
 
         for case in TEST_CASES:
             request = FakeRequest(links_prefix + case)
@@ -34,11 +30,7 @@ class AdminProtectedNamespaceTests(TestCase):
 
     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 = FakeRequest(case)
@@ -58,7 +50,11 @@ class AdminLoginViewTests(TestCase):
         """form handles invalid data gracefully"""
         response = self.client.post(
             reverse('misago:admin:index'),
-            data={'username': 'Nope', 'password': 'Nope'})
+            data={
+                'username': 'Nope',
+                'password': 'Nope',
+            },
+        )
 
         self.assertContains(response, "Login or password is incorrect.")
         self.assertContains(response, "Sign in")
@@ -75,7 +71,11 @@ class AdminLoginViewTests(TestCase):
 
         response = self.client.post(
             reverse('misago:admin:index'),
-            data={'username': 'Bob', 'password': 'Pass.123'})
+            data={
+                'username': 'Bob',
+                'password': 'Pass.123',
+            },
+        )
 
         self.assertContains(response, "Your account does not have admin privileges.")
 
@@ -89,7 +89,11 @@ class AdminLoginViewTests(TestCase):
 
         response = self.client.post(
             reverse('misago:admin:index'),
-            data={'username': 'Bob', 'password': 'Pass.123'})
+            data={
+                'username': 'Bob',
+                'password': 'Pass.123',
+            },
+        )
 
         self.assertContains(response, "Your account does not have admin privileges.")
 
@@ -103,7 +107,11 @@ class AdminLoginViewTests(TestCase):
 
         response = self.client.post(
             reverse('misago:admin:index'),
-            data={'username': 'Bob', 'password': 'Pass.123'})
+            data={
+                'username': 'Bob',
+                'password': 'Pass.123',
+            },
+        )
 
         self.assertEqual(response.status_code, 302)
 
@@ -117,7 +125,11 @@ class AdminLoginViewTests(TestCase):
 
         response = self.client.post(
             reverse('misago:admin:index'),
-            data={'username': 'Bob', 'password': 'Pass.123'})
+            data={
+                'username': 'Bob',
+                'password': 'Pass.123',
+            },
+        )
 
         self.assertEqual(response.status_code, 302)
 
@@ -199,8 +211,7 @@ class Admin404ErrorTests(AdminTestCase):
 
         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):
@@ -214,13 +225,11 @@ class AdminGenericViewsTests(AdminTestCase):
         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'))
+        response = self.client.get('%s?redirected=1&username=%s' % (test_link, 'łut'))
         self.assertEqual(response.status_code, 200)

+ 7 - 4
misago/admin/testutils.py

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

+ 10 - 10
misago/admin/urlpatterns.py

@@ -11,13 +11,13 @@ class URLPatterns(object):
             'path': path,
             'parent': parent,
             'namespace': namespace,
-            })
+        })
 
-    def patterns(self, namespace, *urlpatterns):
+    def patterns(self, namespace, *new_patterns):
         self._patterns.append({
             'namespace': namespace,
-            'urlpatterns': urlpatterns,
-            })
+            'urlpatterns': new_patterns,
+        })
 
     def get_child_patterns(self, parent):
         prefix = '%s:' % parent if parent else ''
@@ -26,8 +26,8 @@ class URLPatterns(object):
         for namespace in self._namespaces:
             if namespace['parent'] == parent:
                 prefixed_namespace = prefix + namespace['namespace']
-                urlpatterns = self.get_child_patterns(prefixed_namespace)
-                included_patterns = include(urlpatterns, namespace=namespace['namespace'])
+                child_patterns = self.get_child_patterns(prefixed_namespace)
+                included_patterns = include(child_patterns, namespace=namespace['namespace'])
                 namespace_urlpatterns.append(url(namespace['path'], included_patterns))
 
         return namespace_urlpatterns
@@ -36,8 +36,8 @@ class URLPatterns(object):
         all_patterns = {}
         for urls in self._patterns:
             namespace = urls['namespace']
-            urlpatterns = urls['urlpatterns']
-            all_patterns.setdefault(namespace, []).extend(urlpatterns)
+            added_patterns = urls['urlpatterns']
+            all_patterns.setdefault(namespace, []).extend(added_patterns)
 
         self.namespace_patterns = all_patterns
 
@@ -45,8 +45,8 @@ class URLPatterns(object):
         root_urlpatterns = []
         for namespace in self._namespaces:
             if not namespace['parent']:
-                urlpatterns = self.get_child_patterns(namespace['namespace'])
-                included_patterns = include(urlpatterns, namespace=namespace['namespace'])
+                child_patterns = self.get_child_patterns(namespace['namespace'])
+                included_patterns = include(child_patterns, namespace=namespace['namespace'])
                 root_urlpatterns.append(url(namespace['path'], included_patterns))
 
         return root_urlpatterns

+ 1 - 2
misago/admin/urls.py

@@ -1,4 +1,4 @@
-from django.conf.urls import include, url
+from django.conf.urls import url
 
 from misago import admin
 
@@ -14,7 +14,6 @@ urlpatterns = [
     url(r'^logout/$', auth.logout, name='logout'),
 ]
 
-
 # Discover admin and register patterns
 admin.discover_misago_admin()
 urlpatterns += admin.urlpatterns()

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

@@ -8,7 +8,6 @@ from misago.admin.auth import is_admin_session, update_admin_session
 from .auth import login
 
 
-
 def get_protected_namespace(request):
     for namespace in settings.MISAGO_ADMIN_NAMESPACES:
         try:

+ 2 - 4
misago/admin/views/auth.py

@@ -28,8 +28,7 @@ def login(request):
             auth.login(request, form.user_cache)
             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
@@ -37,8 +36,7 @@ def login(request):
 def logout(request):
     if request.method == 'POST':
         auth.close_admin_session(request)
-        messages.info(request,
-                      _("Your admin session has been closed."))
+        messages.info(request, _("Your admin session has been closed."))
         return redirect('misago:index')
     else:
         return redirect('misago:admin:index')

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

@@ -11,9 +11,7 @@ def _error_page(request, code, message=None):
     if is_admin_session(request):
         template_pattern = 'misago/admin/errorpages/%s.html' % code
 
-        response = render(request, template_pattern, {
-            'message': message
-        }, error_page=True)
+        response = render(request, template_pattern, {'message': message}, error_page=True)
         response.status_code = code
         return response
     else:
@@ -27,6 +25,7 @@ def admin_error_page(f):
             return _error_page(request, *args, **kwargs)
         else:
             return f(request, *args, **kwargs)
+
     return decorator
 
 
@@ -35,8 +34,10 @@ def _csrf_failure(request, reason=""):
     if is_admin_session(request):
         update_admin_session(request)
         response = render(
-            request, 'misago/admin/errorpages/csrf_failure_authenticated.html',
-            error_page=True)
+            request,
+            'misago/admin/errorpages/csrf_failure_authenticated.html',
+            error_page=True,
+        )
     else:
         response = render(request, 'misago/admin/errorpages/csrf_failure.html')
 
@@ -50,4 +51,5 @@ def admin_csrf_failure(f):
             return _csrf_failure(request, *args, **kwargs)
         else:
             return f(request, *args, **kwargs)
+
     return decorator

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

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

+ 1 - 3
misago/admin/views/generic/base.py

@@ -12,9 +12,7 @@ class AdminView(View):
         return '%s:%s' % (request.resolver_match.namespace, matched_url)
 
     def process_context(self, request, context):
-        """
-        Simple hook for extending and manipulating template context.
-        """
+        """simple hook for extending and manipulating template context."""
         return context
 
     def render(self, request, context=None, template=None):

+ 13 - 14
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()()
@@ -37,17 +37,17 @@ class TargetedView(AdminView):
             return self.wrapped_dispatch(request, *args, **kwargs)
 
     def wrapped_dispatch(self, request, *args, **kwargs):
-            target = self.get_target_or_none(request, kwargs)
-            if not target:
-                messages.error(request, self.message_404)
-                return redirect(self.root_link)
+        target = self.get_target_or_none(request, kwargs)
+        if not target:
+            messages.error(request, self.message_404)
+            return redirect(self.root_link)
 
-            error = self.check_permissions(request, target)
-            if error:
-                messages.error(request, error)
-                return redirect(self.root_link)
+        error = self.check_permissions(request, target)
+        if error:
+            messages.error(request, error)
+            return redirect(self.root_link)
 
-            return self.real_dispatch(request, target)
+        return self.real_dispatch(request, target)
 
     def real_dispatch(self, request, target):
         pass
@@ -68,8 +68,8 @@ class FormView(TargetedView):
 
     def handle_form(self, form, request):
         raise NotImplementedError(
-            "You have to define your own handle_form method to handle "
-            "form submissions.")
+            "You have to define your own handle_form method to handle form submissions."
+        )
 
     def real_dispatch(self, request, target):
         FormType = self.create_form_type(request)
@@ -103,8 +103,7 @@ class ModelFormView(FormView):
     def handle_form(self, form, request, target):
         form.instance.save()
         if self.message_submit:
-            format = {'name': target.name}
-            messages.success(request, self.message_submit % format)
+            messages.success(request, self.message_submit % {'name': target.name})
 
     def real_dispatch(self, request, target):
         FormType = self.create_form_type(request, target)

+ 14 - 34
misago/admin/views/generic/list.py

@@ -65,9 +65,6 @@ class ListView(AdminView):
     def get_queryset(self):
         return self.get_model().objects.all()
 
-    """
-    Dispatch response
-    """
     def dispatch(self, request, *args, **kwargs):
         mass_actions_list = self.mass_actions or []
         extra_actions_list = self.extra_actions or []
@@ -76,25 +73,19 @@ class ListView(AdminView):
 
         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),
         }
@@ -114,8 +105,7 @@ 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']
@@ -128,14 +118,12 @@ class ListView(AdminView):
         SearchForm = self.get_search_form(request)
         if SearchForm:
             filtering_methods = self.get_filtering_methods(request)
-            active_filters = self.get_filtering_method_to_use(
-                filtering_methods)
+            active_filters = self.get_filtering_method_to_use(filtering_methods)
             if request.GET.get('clear_filters'):
                 # Clear filters from querystring
                 request.session.pop(self.filters_session_key, None)
                 active_filters = {}
-            self.apply_filtering_on_context(
-                context, active_filters, SearchForm)
+            self.apply_filtering_on_context(context, active_filters, SearchForm)
 
             if (filtering_methods['GET'] and
                     filtering_methods['GET'] != filtering_methods['session']):
@@ -159,8 +147,7 @@ class ListView(AdminView):
             try:
                 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_info, context['querystring']))
@@ -178,13 +165,12 @@ class ListView(AdminView):
             page = 1
 
         context['paginator'] = Paginator(
-            context['items'], self.items_per_page, allow_empty_first_page=True)
+            context['items'], self.items_per_page, allow_empty_first_page=True
+        )
         context['page'] = context['paginator'].page(page)
         context['items'] = context['page'].object_list
 
-    """
-    Filter list items
-    """
+    # Filter list items
     search_form = None
 
     def get_search_form(self, request):
@@ -236,11 +222,10 @@ class ListView(AdminView):
 
         if context['active_filters']:
             context['items'] = context['search_form'].filter_queryset(
-                active_filters, context['items'])
+                active_filters, context['items']
+            )
 
-    """
-    Order list items
-    """
+    # Order list items
     @property
     def ordering_session_key(self):
         return 'misago_admin_%s_order_by' % self.root_link
@@ -262,7 +247,7 @@ class ListView(AdminView):
         return self.clean_ordering(new_ordering)
 
     def clean_ordering(self, new_ordering):
-        for order_by, name in self.ordering:
+        for order_by, _ in self.ordering:
             if order_by == new_ordering:
                 return order_by
         else:
@@ -290,16 +275,13 @@ class ListView(AdminView):
 
             if order_by == method:
                 context['order'] = order_as_dict
-                context['items'] = context['items'].order_by(
-                    order_as_dict['order_by'])
+                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
-    """
+    # 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'))
@@ -329,9 +311,7 @@ class ListView(AdminView):
         else:
             raise MassActionError(_("Action is not allowed."))
 
-    """
-    Querystring builder
-    """
+    # Querystring builder
     def make_querystring(self, context):
         values = {}
         filter_values = {}

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

@@ -15,7 +15,5 @@ class AdminBaseMixin(object):
     message_404 = None
 
     def get_model(self):
-        """
-        Basic method for retrieving Model, used in cases such as User model.
-        """
+        """basic method for retrieving Model, used in cases such as User model."""
         return self.model

+ 20 - 12
misago/admin/views/index.py

@@ -5,7 +5,6 @@ from requests.exceptions import RequestException
 
 from django.contrib.auth import get_user_model
 from django.http import Http404, JsonResponse
-from django.utils.six.moves import range
 from django.utils.translation import ugettext as _
 
 from misago import __version__
@@ -21,25 +20,31 @@ UserModel = get_user_model()
 
 
 def admin_index(request):
+    inactive_users_queryset = UserModel.objects.exclude(
+        requires_activation=UserModel.ACTIVATION_NONE,
+    )
+
     db_stats = {
         'threads': Thread.objects.count(),
         'posts': Post.objects.count(),
         'users': UserModel.objects.count(),
-        'inactive_users': UserModel.objects.exclude(
-            requires_activation=UserModel.ACTIVATION_NONE
-        ).count()
+        'inactive_users': inactive_users_queryset.count()
     }
 
-    return render(request, 'misago/admin/index.html', {
-        'db_stats': db_stats,
-        'version_check': cache.get(VERSION_CHECK_CACHE_KEY)
-    })
+    return render(
+        request, 'misago/admin/index.html', {
+            'db_stats': db_stats,
+            'version_check': cache.get(VERSION_CHECK_CACHE_KEY),
+        }
+    )
 
 
 def check_version(request):
     if request.method != "POST":
         raise Http404()
+
     version = cache.get(VERSION_CHECK_CACHE_KEY, 'nada')
+
     if version == 'nada':
         try:
             api_url = 'https://api.github.com/repos/rafalp/Misago/releases'
@@ -57,16 +62,18 @@ def check_version(request):
                     message = _("Outdated: %(current)s < %(latest)s")
                     formats = {
                         'latest': latest_version,
-                        'current': __version__
+                        'current': __version__,
                     }
 
                     version = {
                         'is_error': True,
-                        'message': message % formats
+                        'message': message % formats,
                     }
                     break
             else:
-                formats = {'current': __version__}
+                formats = {
+                    'current': __version__,
+                }
                 version = {
                     'is_error': False,
                     'message': _("Up to date! (%(current)s)") % formats,
@@ -77,6 +84,7 @@ def check_version(request):
             message = _("Failed to connect to GitHub API. Try again later.")
             version = {
                 'is_error': True,
-                'message': message
+                'message': message,
             }
+
     return JsonResponse(version)

+ 0 - 1
misago/categories/__init__.py

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

+ 9 - 6
misago/categories/admin.py

@@ -15,7 +15,8 @@ class MisagoAdminExtension(object):
 
         # Nodes
         urlpatterns.namespace(r'^nodes/', 'nodes', 'categories')
-        urlpatterns.patterns('categories:nodes',
+        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'),
@@ -27,7 +28,8 @@ class MisagoAdminExtension(object):
 
         # Category Roles
         urlpatterns.namespace(r'^categories/', 'categories', 'permissions')
-        urlpatterns.patterns('permissions:categories',
+        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'),
@@ -35,7 +37,8 @@ class MisagoAdminExtension(object):
         )
 
         # Change Role Category Permissions
-        urlpatterns.patterns('permissions:users',
+        urlpatterns.patterns(
+            'permissions:users',
             url(r'^categories/(?P<pk>\d+)/$', RoleCategoriesACL.as_view(), name='categories'),
         )
 
@@ -46,14 +49,14 @@ class MisagoAdminExtension(object):
             parent='misago:admin',
             before='misago:admin:permissions:users:index',
             namespace='misago:admin:categories',
-            link='misago:admin:categories:nodes:index'
+            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'
+            link='misago:admin:categories:nodes:index',
         )
         site.add_node(
             name=_("Category roles"),
@@ -61,5 +64,5 @@ class MisagoAdminExtension(object):
             parent='misago:admin:permissions',
             after='misago:admin:permissions:users:index',
             namespace='misago:admin:permissions:categories',
-            link='misago:admin:permissions:categories:index'
+            link='misago:admin:permissions:categories:index',
         )

+ 1 - 1
misago/categories/apps.py

@@ -7,4 +7,4 @@ class MisagoCategoriesConfig(AppConfig):
     verbose_name = "Misago Categories"
 
     def ready(self):
-        from . import signals
+        from . import signals as _

+ 61 - 60
misago/categories/forms.py

@@ -13,9 +13,6 @@ from . import THREADS_ROOT_NAME
 from .models import Category, CategoryRole
 
 
-"""
-Fields
-"""
 class AdminCategoryFieldMixin(object):
     def __init__(self, *args, **kwargs):
         self.base_level = kwargs.pop('base_level', 1)
@@ -42,57 +39,53 @@ class AdminCategoryChoiceField(AdminCategoryFieldMixin, TreeNodeChoiceField):
     pass
 
 
-class AdminCategoryMultipleChoiceField(
-        AdminCategoryFieldMixin, TreeNodeMultipleChoiceField):
+class AdminCategoryMultipleChoiceField(AdminCategoryFieldMixin, TreeNodeMultipleChoiceField):
     pass
 
 
-"""
-Forms
-"""
 class CategoryFormBase(forms.ModelForm):
-    name = forms.CharField(
-        label=_("Name"),
-        validators=[validate_sluggable()]
-    )
+    name = forms.CharField(label=_("Name"), validators=[validate_sluggable()])
     description = forms.CharField(
         label=_("Description"),
         max_length=2048,
         required=False,
         widget=forms.Textarea(attrs={'rows': 3}),
-        help_text=_("Optional description explaining category intented purpose.")
+        help_text=_("Optional description explaining category intented purpose."),
     )
     css_class = forms.CharField(
         label=_("CSS class"),
         required=False,
-        help_text=_("Optional CSS class used to customize this category "
-                    "appearance from templates.")
+        help_text=_(
+            "Optional CSS class used to customize this category appearance from templates."
+        ),
     )
     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"),
         required=False,
-        help_text=_("Optional CSS class used to customize this category "
-                    "appearance from templates.")
+        help_text=_(
+            "Optional CSS class used to customize this category appearance from templates."
+        ),
     )
     prune_started_after = forms.IntegerField(
         label=_("Thread age"),
         min_value=0,
-        help_text=_("Prune thread if number of days since its creation is "
-                    "greater than specified. Enter 0 to disable this "
-                    "pruning criteria.")
+        help_text=_(
+            "Prune thread if number of days since its creation is greater than specified. "
+            "Enter 0 to disable this pruning criteria."
+        ),
     )
     prune_replied_after = forms.IntegerField(
         label=_("Last reply"),
         min_value=0,
-        help_text=_("Prune thread if number of days since last reply is "
-                    "greater than specified. Enter 0 to disable this "
-                    "pruning criteria.")
+        help_text=_(
+            "Prune thread if number of days since last reply is greater than specified. "
+            "Enter 0 to disable this pruning criteria."
+        ),
     )
 
     class Meta:
@@ -134,29 +127,36 @@ def CategoryFormFactory(instance):
         not_siblings = not_siblings | models.Q(rght__gt=instance.rght)
         parent_queryset = parent_queryset.filter(not_siblings)
 
-    return type('CategoryFormFinal', (CategoryFormBase,), {
-        'new_parent': AdminCategoryChoiceField(
-            label=_("Parent category"),
-            queryset=parent_queryset,
-            initial=instance.parent,
-            empty_label=None),
-
-        'copy_permissions': AdminCategoryChoiceField(
-            label=_("Copy permissions"),
-            help_text=_("You can replace this category permissions with "
-                        "permissions copied from category selected here."),
-            queryset=Category.objects.all_categories(),
-            empty_label=_("Don't copy permissions"),
-            required=False),
-
-        'archive_pruned_in': AdminCategoryChoiceField(
-            label=_("Archive"),
-            help_text=_("Instead of being deleted, pruned threads can be "
-                        "moved to designated category."),
-            queryset=Category.objects.all_categories(),
-            empty_label=_("Don't archive pruned threads"),
-            required=False),
-        })
+    return type(
+        'CategoryFormFinal', (CategoryFormBase, ), {
+            'new_parent': AdminCategoryChoiceField(
+                label=_("Parent category"),
+                queryset=parent_queryset,
+                initial=instance.parent,
+                empty_label=None,
+            ),
+            'copy_permissions': AdminCategoryChoiceField(
+                label=_("Copy permissions"),
+                help_text=_(
+                    "You can replace this category permissions with "
+                    "permissions copied from category selected here."
+                ),
+                queryset=Category.objects.all_categories(),
+                empty_label=_("Don't copy permissions"),
+                required=False,
+            ),
+            'archive_pruned_in': AdminCategoryChoiceField(
+                label=_("Archive"),
+                help_text=_(
+                    "Instead of being deleted, pruned threads can be "
+                    "moved to designated category."
+                ),
+                queryset=Category.objects.all_categories(),
+                empty_label=_("Don't archive pruned threads"),
+                required=False,
+            ),
+        }
+    )
 
 
 class DeleteCategoryFormBase(forms.ModelForm):
@@ -169,15 +169,16 @@ class DeleteCategoryFormBase(forms.ModelForm):
 
         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.")
+                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'):
-                message = _("You are trying to move this category threads to a "
-                            "child category that will be deleted together with "
-                            "this category.")
+                message = _(
+                    "You are trying to move this category threads to a "
+                    "child category that will be deleted together with "
+                    "this category."
+                )
                 raise forms.ValidationError(message)
 
         return data
@@ -191,7 +192,7 @@ def DeleteFormFactory(instance):
             queryset=content_queryset,
             initial=instance.parent,
             empty_label=_('Delete with category'),
-            required=False
+            required=False,
         )
     }
 
@@ -205,10 +206,10 @@ def DeleteFormFactory(instance):
             label=_("Move child categories to"),
             queryset=children_queryset,
             empty_label=_('Delete with category'),
-            required=False
+            required=False,
         )
 
-    return type('DeleteCategoryFormFinal', (DeleteCategoryFormBase,), fields)
+    return type('DeleteCategoryFormFinal', (DeleteCategoryFormBase, ), fields)
 
 
 class CategoryRoleForm(forms.ModelForm):
@@ -227,11 +228,11 @@ def RoleCategoryACLFormFactory(category, category_roles, selected_role):
             required=False,
             queryset=category_roles,
             initial=selected_role,
-            empty_label=_("No access")
+            empty_label=_("No access"),
         )
     }
 
-    return type('RoleCategoryACLForm', (forms.Form,), attrs)
+    return type('RoleCategoryACLForm', (forms.Form, ), attrs)
 
 
 def CategoryRolesACLFormFactory(role, category_roles, selected_role):
@@ -242,8 +243,8 @@ def CategoryRolesACLFormFactory(role, category_roles, selected_role):
             required=False,
             queryset=category_roles,
             initial=selected_role,
-            empty_label=_("No access")
+            empty_label=_("No access"),
         )
     }
 
-    return type('CategoryRolesACLForm', (forms.Form,), attrs)
+    return type('CategoryRolesACLForm', (forms.Form, ), attrs)

+ 54 - 13
misago/categories/migrations/0001_initial.py

@@ -22,7 +22,11 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             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)),
@@ -42,19 +46,46 @@ class Migration(migrations.Migration):
                 ('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)),
-                ('last_poster', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, null=True)),
-                ('parent', mptt.fields.TreeForeignKey(related_name='children', blank=True, to='misago_categories.Category', null=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
+                    )
+                ),
+                (
+                    'last_poster', models.ForeignKey(
+                        related_name='+',
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        blank=True,
+                        to=settings.AUTH_USER_MODEL,
+                        null=True
+                    )
+                ),
+                (
+                    'parent', mptt.fields.TreeForeignKey(
+                        related_name='children',
+                        blank=True,
+                        to='misago_categories.Category',
+                        null=True
+                    )
+                ),
             ],
             options={
                 'abstract': False,
             },
-            bases=(models.Model,),
+            bases=(models.Model, ),
         ),
         migrations.CreateModel(
             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)),
                 ('permissions', JSONField(default=permissions_default)),
@@ -62,18 +93,28 @@ class Migration(migrations.Migration):
             options={
                 'abstract': False,
             },
-            bases=(models.Model,),
+            bases=(models.Model, ),
         ),
         migrations.CreateModel(
             name='RoleCategoryACL',
             fields=[
-                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
-                ('category', models.ForeignKey(related_name='category_role_set', to='misago_categories.Category')),
-                ('category_role', models.ForeignKey(to='misago_categories.CategoryRole', to_field='id')),
+                (
+                    'id', models.AutoField(
+                        verbose_name='ID', serialize=False, auto_created=True, primary_key=True
+                    )
+                ),
+                (
+                    'category', models.ForeignKey(
+                        related_name='category_role_set', to='misago_categories.Category'
+                    )
+                ),
+                (
+                    'category_role',
+                    models.ForeignKey(to='misago_categories.CategoryRole', to_field='id')
+                ),
                 ('role', models.ForeignKey(related_name='categories_acls', to='misago_acl.Role')),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
     ]

+ 2 - 2
misago/categories/migrations/0002_default_categories.py

@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-from django.db import migrations, models
+from django.db import migrations
 from django.utils.translation import ugettext as _
 
 from misago.core.utils import slugify
@@ -32,7 +32,7 @@ def create_default_categories_tree(apps, schema_editor):
 
     category_name = _("First category")
 
-    category = Category.objects.create(
+    Category.objects.create(
         parent=root,
         lft=4,
         rght=5,

+ 5 - 10
misago/categories/migrations/0003_categories_roles.py

@@ -1,17 +1,14 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-from django.db import migrations, models
+from django.db import migrations
 from django.utils.translation import ugettext as _
 
 
 def create_default_categories_roles(apps, schema_editor):
-    """
-    Crete roles
-    """
     CategoryRole = apps.get_model('misago_categories', 'CategoryRole')
 
-    see_only = CategoryRole.objects.create(
+    CategoryRole.objects.create(
         name=_('See only'),
         permissions={
             # categories perms
@@ -41,7 +38,7 @@ def create_default_categories_roles(apps, schema_editor):
         }
     )
 
-    reply_only = CategoryRole.objects.create(
+    CategoryRole.objects.create(
         name=_('Reply to threads'),
         permissions={
             # categories perms
@@ -87,7 +84,7 @@ def create_default_categories_roles(apps, schema_editor):
         }
     )
 
-    standard_with_polls = CategoryRole.objects.create(
+    CategoryRole.objects.create(
         name=_('Start and reply threads, make polls'),
         permissions={
             # categories perms
@@ -162,9 +159,7 @@ def create_default_categories_roles(apps, schema_editor):
     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(

+ 7 - 1
misago/categories/migrations/0004_category_last_thread.py

@@ -16,7 +16,13 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='category',
             name='last_thread',
-            field=models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, blank=True, to='misago_threads.Thread', null=True),
+            field=models.ForeignKey(
+                related_name='+',
+                on_delete=django.db.models.deletion.SET_NULL,
+                blank=True,
+                to='misago_threads.Thread',
+                null=True
+            ),
             preserve_default=True,
         ),
     ]

+ 4 - 4
misago/categories/models.py

@@ -64,7 +64,7 @@ class Category(MPTTModel):
         'self',
         null=True,
         blank=True,
-        related_name='children'
+        related_name='children',
     )
     special_role = models.CharField(max_length=255, null=True, blank=True)
     name = models.CharField(max_length=255)
@@ -79,7 +79,7 @@ class Category(MPTTModel):
         related_name='+',
         null=True,
         blank=True,
-        on_delete=models.SET_NULL
+        on_delete=models.SET_NULL,
     )
     last_thread_title = models.CharField(max_length=255, null=True, blank=True)
     last_thread_slug = models.CharField(max_length=255, null=True, blank=True)
@@ -88,7 +88,7 @@ class Category(MPTTModel):
         related_name='+',
         null=True,
         blank=True,
-        on_delete=models.SET_NULL
+        on_delete=models.SET_NULL,
     )
     last_poster_name = models.CharField(max_length=255, null=True, blank=True)
     last_poster_slug = models.CharField(max_length=255, null=True, blank=True)
@@ -99,7 +99,7 @@ class Category(MPTTModel):
         related_name='pruned_archive',
         null=True,
         blank=True,
-        on_delete=models.SET_NULL
+        on_delete=models.SET_NULL,
     )
     css_class = models.CharField(max_length=255, null=True, blank=True)
 

+ 10 - 15
misago/categories/permissions.py

@@ -12,9 +12,6 @@ from misago.users.models import AnonymousUser
 from .models import Category, CategoryRole, RoleCategoryACL
 
 
-"""
-Admin Permissions Form
-"""
 class PermissionsForm(forms.Form):
     legend = _("Category access")
 
@@ -29,9 +26,6 @@ def change_permissions_form(role):
         return None
 
 
-"""
-ACL Builder
-"""
 def build_acl(acl, roles, key_name):
     new_acl = {
         'visible_categories': [],
@@ -75,9 +69,12 @@ def build_category_acl(acl, category, categories_roles, key_name):
         'can_browse': 0,
     }
 
-    algebra.sum_acls(final_acl, roles=category_roles, key=key_name,
+    algebra.sum_acls(
+        final_acl,
+        roles=category_roles,
+        key=key_name,
         can_see=algebra.greater,
-        can_browse=algebra.greater
+        can_browse=algebra.greater,
     )
 
     if final_acl['can_see']:
@@ -88,9 +85,6 @@ def build_category_acl(acl, category, categories_roles, key_name):
             acl['browseable_categories'].append(category.pk)
 
 
-"""
-ACL's for targets
-"""
 def add_acl_to_category(user, target):
     target.acl['can_see'] = can_see_category(user, target)
     target.acl['can_browse'] = can_browse_category(user, target)
@@ -106,7 +100,7 @@ def serialize_categories_alcs(serialized_acl):
                 '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)
+                'can_close_threads': acl.get('can_close_threads', False),
             })
     serialized_acl['categories'] = categories_acl
 
@@ -118,9 +112,6 @@ def register_with(registry):
     registry.acl_serializer(AnonymousUser, serialize_categories_alcs)
 
 
-"""
-ACL tests
-"""
 def allow_see_category(user, target):
     try:
         category_id = target.pk
@@ -129,6 +120,8 @@ def allow_see_category(user, target):
 
     if not category_id in user.acl_cache['visible_categories']:
         raise Http404()
+
+
 can_see_category = return_boolean(allow_see_category)
 
 
@@ -137,4 +130,6 @@ def allow_browse_category(user, target):
     if not target_acl['can_browse']:
         message = _('You don\'t have permission to browse "%(category)s" contents.')
         raise PermissionDenied(message % {'category': target.name})
+
+
 can_browse_category = return_boolean(allow_browse_category)

+ 13 - 11
misago/categories/serializers.py

@@ -13,19 +13,19 @@ __all__ = ['CategorySerializer']
 
 def last_activity_detail(f):
     """util for serializing last activity details"""
+
     def decorator(self, obj):
         if not obj.last_thread_id:
             return None
 
         acl = self.get_acl(obj)
-        if not all((
-                    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
 
         return f(self, obj)
+
     return decorator
 
 
@@ -43,7 +43,7 @@ class CategorySerializer(serializers.ModelSerializer, MutableFields):
 
     class Meta:
         model = Category
-        fields = (
+        fields = [
             'id',
             'parent',
             'name',
@@ -66,7 +66,7 @@ class CategorySerializer(serializers.ModelSerializer, MutableFields):
             'level',
             'lft',
             'rght',
-        )
+        ]
 
     def get_description(self, obj):
         if obj.description:
@@ -103,10 +103,12 @@ class CategorySerializer(serializers.ModelSerializer, MutableFields):
     @last_activity_detail
     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,
-            })
+            return reverse(
+                'misago:user', kwargs={
+                    'slug': obj.last_poster_slug,
+                    'pk': obj.last_poster_id,
+                }
+            )
         else:
             return None
 

+ 1 - 4
misago/categories/signals.py

@@ -9,12 +9,9 @@ delete_category_content = Signal()
 move_category_content = Signal(providing_args=["new_category"])
 
 
-"""
-Signal handlers
-"""
 @receiver(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_slug=sender.slug,
     )

+ 160 - 130
misago/categories/tests/test_categories_admin_views.py

@@ -14,52 +14,42 @@ class CategoryAdminTestCate(AdminTestCase):
         current_tree = []
         for category in queryset:
             current_tree.append((
-                category,
-                category.level,
-                category.lft - root.lft + 1,
+                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)))
+            self.fail(
+                '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(CategoryAdminTestCate):
     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'))
 
     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')
 
@@ -68,8 +58,7 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
         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')
@@ -79,8 +68,7 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
         root = Category.objects.root_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(
@@ -91,11 +79,11 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
                 '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'))
+        response = self.client.get(reverse('misago:admin:categories:nodes:index'))
         self.assertContains(response, 'Test Category')
 
         test_category = Category.objects.get(slug='test-category')
@@ -114,7 +102,8 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
                 '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')
@@ -134,7 +123,8 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
                 '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')
@@ -147,8 +137,7 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
             (test_other_category, 1, 8, 9),
         ])
 
-        response = self.client.get(
-            reverse('misago:admin:categories:nodes:index'))
+        response = self.client.get(reverse('misago:admin:categories:nodes:index'))
         self.assertContains(response, 'Test Subcategory')
 
     def test_edit_view(self):
@@ -159,14 +148,16 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
 
         response = self.client.get(
             reverse('misago:admin:categories:nodes:edit', kwargs={
-                'pk': private_threads.pk
-            }))
+                'pk': private_threads.pk,
+            })
+        )
         self.assertEqual(response.status_code, 302)
 
         response = self.client.get(
             reverse('misago:admin:categories:nodes:edit', kwargs={
-                'pk': root.pk
-            }))
+                'pk': root.pk,
+            })
+        )
         self.assertEqual(response.status_code, 302)
 
         response = self.client.post(
@@ -177,21 +168,23 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
                 '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')
 
         response = self.client.get(
             reverse('misago:admin:categories:nodes:edit', kwargs={
-                'pk': test_category.pk
-            }))
+                'pk': test_category.pk,
+            })
+        )
 
         self.assertContains(response, 'Test Category')
 
         response = self.client.post(
             reverse('misago:admin:categories:nodes:edit', kwargs={
-                'pk': test_category.pk
+                'pk': test_category.pk,
             }),
             data={
                 'name': 'Test Category Edited',
@@ -199,7 +192,8 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
                 'role': 'category',
                 'prune_started_after': 0,
                 'prune_replied_after': 0,
-            })
+            },
+        )
         self.assertEqual(response.status_code, 302)
 
         self.assertValidTree([
@@ -208,13 +202,12 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
             (test_category, 1, 4, 5),
         ])
 
-        response = self.client.get(
-            reverse('misago:admin:categories:nodes:index'))
+        response = self.client.get(reverse('misago:admin:categories:nodes:index'))
         self.assertContains(response, 'Test Category Edited')
 
         response = self.client.post(
             reverse('misago:admin:categories:nodes:edit', kwargs={
-                'pk': test_category.pk
+                'pk': test_category.pk,
             }),
             data={
                 'name': 'Test Category Edited',
@@ -222,7 +215,8 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
                 'role': 'category',
                 'prune_started_after': 0,
                 'prune_replied_after': 0,
-            })
+            },
+        )
         self.assertEqual(response.status_code, 302)
 
         self.assertValidTree([
@@ -231,8 +225,7 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
             (test_category, 2, 3, 4),
         ])
 
-        response = self.client.get(
-            reverse('misago:admin:categories:nodes:index'))
+        response = self.client.get(reverse('misago:admin:categories:nodes:index'))
         self.assertContains(response, 'Test Category Edited')
 
     def test_move_views(self):
@@ -240,26 +233,33 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
         root = Category.objects.root_category()
         first_category = Category.objects.get(slug='first-category')
 
-        self.client.post(reverse('misago:admin:categories:nodes:new'), data={
-            'name': 'Category A',
-            'new_parent': root.pk,
-            'prune_started_after': 0,
-            'prune_replied_after': 0,
-        })
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                'name': 'Category A',
+                'new_parent': root.pk,
+                'prune_started_after': 0,
+                'prune_replied_after': 0,
+            },
+        )
         category_a = Category.objects.get(slug='category-a')
 
-        self.client.post(reverse('misago:admin:categories:nodes:new'), data={
-            'name': 'Category B',
-            'new_parent': root.pk,
-            'prune_started_after': 0,
-            'prune_replied_after': 0,
-        })
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                'name': 'Category B',
+                'new_parent': root.pk,
+                'prune_started_after': 0,
+                'prune_replied_after': 0,
+            },
+        )
         category_b = Category.objects.get(slug='category-b')
 
         response = self.client.post(
             reverse('misago:admin:categories:nodes:up', kwargs={
-                'pk': category_b.pk
-            }))
+                'pk': category_b.pk,
+            })
+        )
         self.assertEqual(response.status_code, 302)
 
         self.assertValidTree([
@@ -271,8 +271,9 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
 
         response = self.client.post(
             reverse('misago:admin:categories:nodes:up', kwargs={
-                'pk': category_b.pk
-            }))
+                'pk': category_b.pk,
+            })
+        )
         self.assertEqual(response.status_code, 302)
 
         self.assertValidTree([
@@ -284,8 +285,9 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
 
         response = self.client.post(
             reverse('misago:admin:categories:nodes:down', kwargs={
-                'pk': category_b.pk
-            }))
+                'pk': category_b.pk,
+            })
+        )
         self.assertEqual(response.status_code, 302)
 
         self.assertValidTree([
@@ -297,8 +299,9 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
 
         response = self.client.post(
             reverse('misago:admin:categories:nodes:down', kwargs={
-                'pk': category_b.pk
-            }))
+                'pk': category_b.pk,
+            })
+        )
         self.assertEqual(response.status_code, 302)
 
         self.assertValidTree([
@@ -310,8 +313,9 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
 
         response = self.client.post(
             reverse('misago:admin:categories:nodes:down', kwargs={
-                'pk': category_b.pk
-            }))
+                'pk': category_b.pk,
+            })
+        )
         self.assertEqual(response.status_code, 302)
 
         self.assertValidTree([
@@ -324,10 +328,6 @@ class CategoryAdminViewsTests(CategoryAdminTestCate):
 
 class CategoryAdminDeleteViewTests(CategoryAdminTestCate):
     def setUp(self):
-        super(CategoryAdminDeleteViewTests, self).setUp()
-        self.root = Category.objects.root_category()
-        self.first_category = Category.objects.get(slug='first-category')
-
         """
         Create categories tree for test cases:
 
@@ -341,75 +341,101 @@ class CategoryAdminDeleteViewTests(CategoryAdminTestCate):
         Category E
           + Category F
         """
-        self.client.post(reverse('misago:admin:categories:nodes:new'), data={
-            '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'), data={
-            'name': 'Category E',
-            'new_parent': self.root.pk,
-            'prune_started_after': 0,
-            'prune_replied_after': 0,
-        })
+
+        super(CategoryAdminDeleteViewTests, self).setUp()
+
+        self.root = Category.objects.root_category()
+        self.first_category = Category.objects.get(slug='first-category')
+
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                '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'),
+            data={
+                '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.client.post(reverse('misago:admin:categories:nodes:new'), data={
-            'name': 'Category B',
-            'new_parent': self.category_a.pk,
-            'prune_started_after': 0,
-            'prune_replied_after': 0,
-        })
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                '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.client.post(reverse('misago:admin:categories:nodes:new'), data={
-            'name': 'Subcategory C',
-            'new_parent': self.category_b.pk,
-            'prune_started_after': 0,
-            'prune_replied_after': 0,
-        })
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                '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.client.post(reverse('misago:admin:categories:nodes:new'), data={
-            'name': 'Subcategory D',
-            'new_parent': self.category_b.pk,
-            'prune_started_after': 0,
-            'prune_replied_after': 0,
-        })
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                '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.client.post(reverse('misago:admin:categories:nodes:new'), data={
-            'name': 'Category F',
-            'new_parent': self.category_e.pk,
-            'prune_started_after': 0,
-            'prune_replied_after': 0,
-        })
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                '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')
 
     def test_delete_category_move_contents(self):
         """category was deleted and its contents were moved"""
-        for i in range(10):
+        for _ in range(10):
             testutils.post_thread(self.category_b)
         self.assertEqual(Thread.objects.count(), 10)
 
         response = self.client.get(
             reverse('misago:admin:categories:nodes:delete', kwargs={
-                'pk': self.category_b.pk
-            }))
+                '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
+                'pk': self.category_b.pk,
             }),
             data={
                 'move_children_to': self.category_e.pk,
                 'move_threads_to': self.category_d.pk,
-            })
+            },
+        )
         self.assertEqual(response.status_code, 302)
         self.assertEqual(Category.objects.all_categories().count(), 6)
         self.assertEqual(Thread.objects.count(), 10)
@@ -426,23 +452,25 @@ class CategoryAdminDeleteViewTests(CategoryAdminTestCate):
 
     def test_delete_category_and_contents(self):
         """category and its contents were deleted"""
-        for i in range(10):
+        for _ in range(10):
             testutils.post_thread(self.category_b)
 
         response = self.client.get(
             reverse('misago:admin:categories:nodes:delete', kwargs={
-                'pk': self.category_b.pk
-            }))
+                '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
+                'pk': self.category_b.pk,
             }),
             data={
                 'move_children_to': '',
-                'move_threads_to': ''
-            })
+                'move_threads_to': '',
+            }
+        )
         self.assertEqual(response.status_code, 302)
 
         self.assertEqual(Category.objects.all_categories().count(), 4)
@@ -458,24 +486,26 @@ class CategoryAdminDeleteViewTests(CategoryAdminTestCate):
 
     def test_delete_leaf_category(self):
         """category was deleted and its contents were moved"""
-        for i in range(10):
+        for _ in range(10):
             testutils.post_thread(self.category_d)
         self.assertEqual(Thread.objects.count(), 10)
 
         response = self.client.get(
             reverse('misago:admin:categories:nodes:delete', kwargs={
-                'pk': self.category_d.pk
-            }))
+                '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
+                '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)

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

@@ -1,5 +1,3 @@
-from django.utils import timezone
-
 from misago.categories import THREADS_ROOT_NAME
 from misago.categories.models import Category
 from misago.core.testutils import MisagoTestCase
@@ -54,11 +52,7 @@ class CategoryModelTests(MisagoTestCase):
         self.category = Category.objects.all_categories()[:1][0]
 
     def create_thread(self):
-        datetime = timezone.now()
-
-        thread = testutils.post_thread(self.category)
-
-        return thread
+        return testutils.post_thread(self.category)
 
     def assertCategoryIsEmpty(self):
         self.assertIsNone(self.category.last_post_on)
@@ -114,7 +108,7 @@ class CategoryModelTests(MisagoTestCase):
 
     def test_delete_content(self):
         """delete_content empties category"""
-        for i in range(10):
+        for _ in range(10):
             self.create_thread()
 
         self.category.synchronize()
@@ -131,7 +125,7 @@ class CategoryModelTests(MisagoTestCase):
 
     def test_move_content(self):
         """move_content moves category threads and posts to other category"""
-        for i in range(10):
+        for _ in range(10):
             self.create_thread()
         self.category.synchronize()
 

+ 130 - 90
misago/categories/tests/test_permissions_admin_views.py

@@ -13,76 +13,84 @@ def fake_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'))
 
     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=fake_data({'name': 'Test CategoryRole'}))
+            data=fake_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'))
+        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=fake_data({'name': 'Test CategoryRole'}))
+            data=fake_data({
+                '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
-            }))
+                'pk': test_role.pk,
+            })
+        )
         self.assertContains(response, 'Test CategoryRole')
 
         response = self.client.post(
             reverse('misago:admin:permissions:categories:edit', kwargs={
-                'pk': test_role.pk
+                'pk': test_role.pk,
+            }),
+            data=fake_data({
+                'name': 'Top Lel',
             }),
-            data=fake_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'))
+        response = self.client.get(reverse('misago:admin:permissions:categories:index'))
         self.assertContains(response, test_role.name)
 
     def test_delete_view(self):
         """delete role view has no showstoppers"""
         self.client.post(
             reverse('misago:admin:permissions:categories:new'),
-            data=fake_data({'name': 'Test CategoryRole'}))
+            data=fake_data({
+                '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
-            }))
+                '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'))
+        response = self.client.get(reverse('misago:admin:permissions:categories:index'))
         self.assertNotContains(response, test_role.name)
 
     def test_change_category_roles_view(self):
@@ -90,7 +98,6 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         root = Category.objects.root_category()
         for descendant in root.get_descendants():
             descendant.delete()
-
         """
         Create categories tree for test cases:
 
@@ -100,46 +107,59 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
           + Category D
         """
         root = Category.objects.root_category()
-        self.client.post(reverse('misago:admin:categories:nodes:new'), data={
-            'name': 'Category A',
-            'new_parent': root.pk,
-            'prune_started_after': 0,
-            'prune_replied_after': 0,
-        })
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                'name': 'Category A',
+                'new_parent': root.pk,
+                'prune_started_after': 0,
+                'prune_replied_after': 0,
+            },
+        )
         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=fake_post_data(Role(), {'name': 'Test Role A'}))
+            data=fake_post_data(Role(), {'name': 'Test Role A'})
+        )
         self.client.post(
             reverse('misago:admin:permissions:users:new'),
-            data=fake_post_data(Role(), {'name': 'Test Role B'}))
+            data=fake_post_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')
 
         self.client.post(
             reverse('misago:admin:permissions:categories:new'),
-            data=fake_data({'name': 'Test Comments'}))
+            data=fake_data({
+                'name': 'Test Comments',
+            }),
+        )
         self.client.post(
             reverse('misago:admin:permissions:categories:new'),
-            data=fake_data({'name': 'Test Full'}))
+            data=fake_data({
+                '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}))
+            reverse(
+                'misago:admin:categories:nodes:permissions', kwargs={
+                    'pk': test_category.pk,
+                }
+            )
+        )
         self.assertContains(response, test_category.name)
         self.assertContains(response, test_role_a.name)
         self.assertContains(response, test_role_b.name)
@@ -148,28 +168,31 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
 
         # Assign roles to categories
         response = self.client.post(
-            reverse('misago:admin:categories:nodes:permissions',
-                    kwargs={'pk': test_category.pk}),
+            reverse(
+                'misago:admin:categories:nodes:permissions', kwargs={
+                    'pk': test_category.pk,
+                }
+            ),
             data={
                 ('%s-category_role' % test_role_a.pk): role_full.pk,
                 ('%s-category_role' % test_role_b.pk): role_comments.pk,
-            })
+            },
+        )
         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)
+            category_role_set.get(role=test_role_b).category_role_id, 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=fake_post_data(Role(), {'name': 'Test CategoryRole'}))
+            data=fake_post_data(Role(), {'name': 'Test CategoryRole'})
+        )
 
         test_role = Role.objects.get(name='Test CategoryRole')
 
@@ -180,10 +203,10 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         self.assertEqual(Category.objects.count(), 2)
         response = self.client.get(
             reverse('misago:admin:permissions:users:categories', kwargs={
-                'pk': test_role.pk
-            }))
+                'pk': test_role.pk,
+            })
+        )
         self.assertEqual(response.status_code, 302)
-
         """
         Create categories tree for test cases:
 
@@ -193,36 +216,48 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
           + Category D
         """
         root = Category.objects.root_category()
-        self.client.post(reverse('misago:admin:categories:nodes:new'), data={
-            'name': 'Category A',
-            'new_parent': root.pk,
-            'prune_started_after': 0,
-            'prune_replied_after': 0,
-        })
-        self.client.post(reverse('misago:admin:categories:nodes:new'), data={
-            'name': 'Category C',
-            'new_parent': root.pk,
-            'prune_started_after': 0,
-            'prune_replied_after': 0,
-        })
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                'name': 'Category A',
+                'new_parent': root.pk,
+                'prune_started_after': 0,
+                'prune_replied_after': 0,
+            },
+        )
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                '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')
 
-        self.client.post(reverse('misago:admin:categories:nodes:new'), data={
-            'name': 'Category B',
-            'new_parent': category_a.pk,
-            'prune_started_after': 0,
-            'prune_replied_after': 0,
-        })
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                'name': 'Category B',
+                'new_parent': category_a.pk,
+                'prune_started_after': 0,
+                'prune_replied_after': 0,
+            },
+        )
         category_b = Category.objects.get(slug='category-b')
 
-        self.client.post(reverse('misago:admin:categories:nodes:new'), data={
-            'name': 'Category D',
-            'new_parent': category_c.pk,
-            'prune_started_after': 0,
-            'prune_replied_after': 0,
-        })
+        self.client.post(
+            reverse('misago:admin:categories:nodes:new'),
+            data={
+                'name': 'Category D',
+                'new_parent': category_c.pk,
+                'prune_started_after': 0,
+                'prune_replied_after': 0,
+            },
+        )
         category_d = Category.objects.get(slug='category-d')
 
         self.assertEqual(Category.objects.count(), 6)
@@ -230,8 +265,9 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         # See if form page is rendered
         response = self.client.get(
             reverse('misago:admin:permissions:users:categories', kwargs={
-                'pk': test_role.pk
-            }))
+                'pk': test_role.pk,
+            })
+        )
         self.assertContains(response, category_a.name)
         self.assertContains(response, category_b.name)
         self.assertContains(response, category_c.name)
@@ -240,46 +276,50 @@ class CategoryRoleAdminViewsTests(AdminTestCase):
         # Set test roles
         self.client.post(
             reverse('misago:admin:permissions:categories:new'),
-            data=fake_data({'name': 'Test Comments'}))
+            data=fake_data({
+                'name': 'Test Comments',
+            }),
+        )
         role_comments = CategoryRole.objects.get(name='Test Comments')
 
         self.client.post(
             reverse('misago:admin:permissions:categories:new'),
-            data=fake_data({'name': 'Test Full'}))
+            data=fake_data({
+                '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
-            }))
+                '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
+                '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,
-            })
+            },
+        )
         self.assertEqual(response.status_code, 302)
 
         # Check that roles were assigned
         categories_acls = test_role.categories_acls
         self.assertEqual(
-            categories_acls.get(category=category_a).category_role_id,
-            role_comments.pk)
-        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)
+            categories_acls.get(category=category_a).category_role_id, role_comments.pk
+        )
         self.assertEqual(
-            categories_acls.get(category=category_d).category_role_id,
-            role_full.pk)
+            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)

+ 8 - 9
misago/categories/tests/test_prunecategories.py

@@ -4,7 +4,6 @@ from django.core.management import call_command
 from django.test import TestCase
 from django.utils import timezone
 from django.utils.six import StringIO
-from django.utils.six.moves import range
 
 from misago.categories.management.commands import prunecategories
 from misago.categories.models import Category
@@ -22,12 +21,12 @@ class PruneCategoriesTests(TestCase):
         # post old threads with recent replies
         started_on = timezone.now() - timedelta(days=30)
         posted_on = timezone.now()
-        for t in range(10):
+        for _ in range(10):
             thread = testutils.post_thread(category, started_on=started_on)
             testutils.reply_thread(thread, posted_on=posted_on)
 
         # post recent threads that will be preserved
-        threads = [testutils.post_thread(category) for t in range(10)]
+        threads = [testutils.post_thread(category) for _ in range(10)]
 
         category.synchronize()
         self.assertEqual(category.threads, 20)
@@ -58,12 +57,12 @@ class PruneCategoriesTests(TestCase):
 
         # post old threads with recent replies
         started_on = timezone.now() - timedelta(days=30)
-        for t in range(10):
+        for _ in range(10):
             thread = testutils.post_thread(category, started_on=started_on)
             testutils.reply_thread(thread)
 
         # post recent threads that will be preserved
-        threads = [testutils.post_thread(category) for t in range(10)]
+        threads = [testutils.post_thread(category) for _ in range(10)]
 
         category.synchronize()
         self.assertEqual(category.threads, 20)
@@ -104,12 +103,12 @@ class PruneCategoriesTests(TestCase):
         # post old threads with recent replies
         started_on = timezone.now() - timedelta(days=30)
         posted_on = timezone.now()
-        for t in range(10):
+        for _ in range(10):
             thread = testutils.post_thread(category, started_on=started_on)
             testutils.reply_thread(thread, posted_on=posted_on)
 
         # post recent threads that will be preserved
-        threads = [testutils.post_thread(category) for t in range(10)]
+        threads = [testutils.post_thread(category) for _ in range(10)]
 
         category.synchronize()
         self.assertEqual(category.threads, 20)
@@ -153,12 +152,12 @@ class PruneCategoriesTests(TestCase):
 
         # post old threads with recent replies
         started_on = timezone.now() - timedelta(days=30)
-        for t in range(10):
+        for _ in range(10):
             thread = testutils.post_thread(category, started_on=started_on)
             testutils.reply_thread(thread)
 
         # post recent threads that will be preserved
-        threads = [testutils.post_thread(category) for t in range(10)]
+        threads = [testutils.post_thread(category) for _ in range(10)]
 
         category.synchronize()
         self.assertEqual(category.threads, 20)

+ 2 - 3
misago/categories/tests/test_synchronizecategories.py

@@ -1,7 +1,6 @@
 from django.core.management import call_command
 from django.test import TestCase
 from django.utils.six import StringIO
-from django.utils.six.moves import range
 
 from misago.categories.management.commands import synchronizecategories
 from misago.categories.models import Category
@@ -13,9 +12,9 @@ class SynchronizeCategoriesTests(TestCase):
         """command synchronizes categories"""
         category = Category.objects.all_categories()[:1][0]
 
-        threads = [testutils.post_thread(category) for t in range(10)]
+        threads = [testutils.post_thread(category) for _ in range(10)]
         for thread in threads:
-            [testutils.reply_thread(thread) for r in range(5)]
+            [testutils.reply_thread(thread) for _ in range(5)]
 
         category.threads = 0
         category.posts = 0

+ 44 - 25
misago/categories/tests/test_utils.py

@@ -7,12 +7,6 @@ from misago.users.testutils import AuthenticatedUserTestCase
 
 class CategoriesUtilsTests(AuthenticatedUserTestCase):
     def setUp(self):
-        super(CategoriesUtilsTests, self).setUp()
-        threadstore.clear()
-
-        self.root = Category.objects.root_category()
-        self.first_category = Category.objects.get(slug='first-category')
-
         """
         Create categories tree for test cases:
 
@@ -26,46 +20,74 @@ class CategoriesUtilsTests(AuthenticatedUserTestCase):
         Category E
           + Subcategory F
         """
+
+        super(CategoriesUtilsTests, self).setUp()
+        threadstore.clear()
+
+        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)
+        ).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)
+        ).insert_at(
+            self.root,
+            position='last-child',
+            save=True,
+        )
 
         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)
+        ).insert_at(
+            self.category_a,
+            position='last-child',
+            save=True,
+        )
 
         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)
+        ).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)
+        ).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)
+        ).insert_at(
+            self.category_e,
+            position='last-child',
+            save=True,
+        )
 
         categories_acl = {'categories': {}, 'visible_categories': []}
         for category in Category.objects.all_categories():
             categories_acl['visible_categories'].append(category.pk)
-            categories_acl['categories'][category.pk] = {
-                'can_see': 1,
-                'can_browse': 1
-            }
+            categories_acl['categories'][category.pk] = {'can_see': 1, 'can_browse': 1}
         override_acl(self.user, categories_acl)
 
     def test_root_categories_tree_no_parent(self):
@@ -73,24 +95,21 @@ class CategoriesUtilsTests(AuthenticatedUserTestCase):
         categories_tree = get_categories_tree(self.user)
         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.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, Category.objects.get(slug='subcategory-f'))
+            self.user, Category.objects.get(slug='subcategory-f')
+        )
         self.assertEqual(len(categories_tree), 0)
 
     def test_get_category_path(self):

+ 0 - 2
misago/categories/urls/__init__.py

@@ -5,13 +5,11 @@ from misago.core.views import home_redirect
 
 from misago.categories.views.categorieslist import categories
 
-
 if settings.MISAGO_THREADS_ON_INDEX:
     URL_PATH = r'^categories/$'
 else:
     URL_PATH = r'^$'
 
-
 urlpatterns = [
     url(URL_PATH, categories, name='categories'),
 

+ 1 - 6
misago/categories/utils.py

@@ -1,5 +1,4 @@
 from misago.acl import add_acl
-from misago.core import threadstore
 from misago.readtracker import categoriestracker
 
 from .models import Category
@@ -14,9 +13,7 @@ def get_categories_tree(user, parent=None):
     else:
         queryset = Category.objects.all_categories()
 
-    queryset_with_acl = queryset.filter(
-        id__in=user.acl_cache['visible_categories']
-    )
+    queryset_with_acl = queryset.filter(id__in=user.acl_cache['visible_categories'])
 
     visible_categories = list(queryset_with_acl)
 
@@ -74,8 +71,6 @@ def get_category_path(category):
     if category.special_role:
         return [category]
 
-    categories_dict = Category.objects.get_cached_categories_dict()
-
     category_path = []
     while category and category.level > 0:
         category_path.append(category)

+ 11 - 9
misago/categories/views/categoriesadmin.py

@@ -39,7 +39,7 @@ class CategoriesList(CategoryAdmin, generic.ListView):
 
         children_lists = {}
 
-        for i, item in enumerate(context['items']):
+        for item in context['items']:
             item.level_range = range(item.level - 1)
             item.first = False
             item.last = False
@@ -59,13 +59,13 @@ 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')
+                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:
                 Category.objects.clear_cache()
         else:
-            form.instance.insert_at(form.cleaned_data['new_parent'],
+            form.instance.insert_at(
+                form.cleaned_data['new_parent'],
                 position='last-child',
                 save=True,
             )
@@ -77,11 +77,13 @@ class CategoryFormMixin(object):
 
             copied_acls = []
             for acl in copy_from.category_role_set.all():
-                copied_acls.append(RoleCategoryACL(
-                    role_id=acl.role_id,
-                    category=form.instance,
-                    category_role_id=acl.category_role_id,
-                ))
+                copied_acls.append(
+                    RoleCategoryACL(
+                        role_id=acl.role_id,
+                        category=form.instance,
+                        category_role_id=acl.category_role_id,
+                    )
+                )
 
             if copied_acls:
                 RoleCategoryACL.objects.bulk_create(copied_acls)

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

@@ -10,7 +10,7 @@ def categories(request):
 
     request.frontend_context.update({
         'CATEGORIES': CategorySerializer(categories_tree, many=True).data,
-        'CATEGORIES_API': reverse('misago:api:category-list')
+        'CATEGORIES_API': reverse('misago:api:category-list'),
     })
 
     return render(request, 'misago/categories/list.html', {

+ 23 - 25
misago/categories/views/permsadmin.py

@@ -22,7 +22,7 @@ class CategoryRoleAdmin(generic.AdminBaseMixin):
 
 
 class CategoryRolesList(CategoryRoleAdmin, generic.ListView):
-    ordering = (('name', None),)
+    ordering = (('name', None), )
 
 
 class RoleFormMixin(object):
@@ -48,8 +48,7 @@ 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:
                     return redirect(request.path)
@@ -64,7 +63,8 @@ class RoleFormMixin(object):
                 'form': form,
                 'target': target,
                 'perms_forms': perms_forms,
-            })
+            },
+        )
 
 
 class NewCategoryRole(RoleFormMixin, CategoryRoleAdmin, generic.ModelFormView):
@@ -78,8 +78,7 @@ class EditCategoryRole(RoleFormMixin, CategoryRoleAdmin, generic.ModelFormView):
 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.')
+            message = _('Role "%(name)s" is special role and can\'t be deleted.')
             return message % {'name': target.name}
 
     def button_action(self, request, target):
@@ -88,11 +87,8 @@ class DeleteCategoryRole(CategoryRoleAdmin, generic.ButtonView):
         messages.success(request, message % {'name': target.name})
 
 
-"""
-Create category roles view for assinging roles to category,
-add link to it in categories list
-"""
 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'
 
@@ -107,7 +103,8 @@ class CategoryPermissions(CategoryAdmin, generic.ModelFormView):
         forms_are_valid = True
         for role in Role.objects.order_by('name'):
             FormType = CategoryRolesACLFormFactory(
-                role, category_roles, assigned_roles.get(role.pk))
+                role, category_roles, assigned_roles.get(role.pk)
+            )
 
             if request.method == 'POST':
                 forms.append(FormType(request.POST, prefix=role.pk))
@@ -125,8 +122,9 @@ class CategoryPermissions(CategoryAdmin, generic.ModelFormView):
                         RoleCategoryACL(
                             role=form.role,
                             category=target,
-                            category_role=form.cleaned_data['category_role']
-                        ))
+                            category_role=form.cleaned_data['category_role'],
+                        )
+                    )
             if new_permissions:
                 RoleCategoryACL.objects.bulk_create(new_permissions)
 
@@ -144,19 +142,17 @@ class CategoryPermissions(CategoryAdmin, generic.ModelFormView):
             'target': target,
         })
 
+
 CategoriesList.add_item_action(
     name=_("Category permissions"),
     icon='fa fa-adjust',
     link='misago:admin:categories:nodes:permissions',
-    style='success'
+    style='success',
 )
 
 
-"""
-Create role categories view for assinging categories to role,
-add link to it in user roles list
-"""
 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'
 
@@ -176,9 +172,7 @@ class RoleCategoriesACL(RoleAdmin, generic.ModelFormView):
         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':
                 forms.append(FormType(request.POST, prefix=category.pk))
@@ -193,9 +187,12 @@ class RoleCategoriesACL(RoleAdmin, generic.ModelFormView):
             for form in forms:
                 if form.cleaned_data['role']:
                     new_permissions.append(
-                        RoleCategoryACL(role=target,
-                                     category=form.category,
-                                     category_role=form.cleaned_data['role']))
+                        RoleCategoryACL(
+                            role=target,
+                            category=form.category,
+                            category_role=form.cleaned_data['role'],
+                        )
+                    )
             if new_permissions:
                 RoleCategoryACL.objects.bulk_create(new_permissions)
 
@@ -213,9 +210,10 @@ class RoleCategoriesACL(RoleAdmin, generic.ModelFormView):
             'target': target,
         })
 
+
 RolesList.add_item_action(
     name=_("Categories permissions"),
     icon='fa fa-comments-o',
     link='misago:admin:permissions:users:categories',
-    style='success'
+    style='success',
 )

+ 0 - 1
misago/conf/__init__.py

@@ -1,4 +1,3 @@
 from .gateway import settings, db_settings  # noqa
 
-
 default_app_config = 'misago.conf.apps.MisagoConfConfig'

+ 2 - 1
misago/conf/admin.py

@@ -8,7 +8,8 @@ class MisagoAdminExtension(object):
     def register_urlpatterns(self, urlpatterns):
         urlpatterns.namespace(r'^settings/', 'settings', 'system')
 
-        urlpatterns.patterns('system:settings',
+        urlpatterns.patterns(
+            'system:settings',
             url(r'^$', views.index, name='index'),
             url(r'^(?P<key>(\w|-)+)/$', views.group, name='group'),
         )

+ 0 - 13
misago/conf/context_processors.py

@@ -1,5 +1,3 @@
-import json
-
 from django.contrib.staticfiles.templatetags.staticfiles import static
 from django.urls import reverse
 from django.utils.translation import get_language
@@ -14,15 +12,10 @@ BLANK_AVATAR_URL = static(misago_settings.MISAGO_BLANK_AVATAR)
 def settings(request):
     return {
         'DEBUG': misago_settings.DEBUG,
-
         'LANGUAGE_CODE_SHORT': get_language()[:2],
-
         'misago_settings': db_settings,
-
         'BLANK_AVATAR_URL': BLANK_AVATAR_URL,
-
         'THREADS_ON_INDEX': misago_settings.MISAGO_THREADS_ON_INDEX,
-
         'LOGIN_REDIRECT_URL': misago_settings.LOGIN_REDIRECT_URL,
         'LOGIN_URL': misago_settings.LOGIN_URL,
         'LOGOUT_URL': misago_settings.LOGOUT_URL,
@@ -34,23 +27,17 @@ def preload_settings_json(request):
 
     preloaded_settings.update({
         'LOGIN_API_URL': misago_settings.MISAGO_LOGIN_API_URL,
-
         'LOGIN_REDIRECT_URL': reverse(misago_settings.LOGIN_REDIRECT_URL),
         'LOGIN_URL': reverse(misago_settings.LOGIN_URL),
-
         'LOGOUT_URL': reverse(misago_settings.LOGOUT_URL),
     })
 
     request.frontend_context.update({
         'SETTINGS': preloaded_settings,
-
         'MISAGO_PATH': reverse('misago:index'),
-
         'BLANK_AVATAR_URL': BLANK_AVATAR_URL,
         'STATIC_URL': misago_settings.STATIC_URL,
-
         'CSRF_COOKIE_NAME': misago_settings.CSRF_COOKIE_NAME,
-
         'THREADS_ON_INDEX': misago_settings.MISAGO_THREADS_ON_INDEX,
     })
 

+ 0 - 5
misago/conf/defaults.py

@@ -6,11 +6,6 @@ If you rely on any of those in your code, make sure you use `misago.conf.setting
 instead of Django's `django.conf.settings`.
 """
 
-# Default JS debug to false
-# This setting used exclusively by test runner and isn't part of public API
-
-_MISAGO_JS_DEBUG = False
-
 
 # Permissions system extensions
 # https://misago.readthedocs.io/en/latest/developers/acls.html#extending-permissions-system

+ 13 - 19
misago/conf/forms.py

@@ -20,17 +20,17 @@ class ValidateChoicesNum(object):
             message = ungettext(
                 'You have to select at least %(choices)d option.',
                 'You have to select at least %(choices)d options.',
-                self.min_choices)
-            message = message % {'choices': self.min_choices}
-            raise forms.ValidationError(message)
+                self.min_choices,
+            )
+            raise forms.ValidationError(message % {'choices': self.min_choices})
 
         if self.max_choices and self.max_choices < data_len:
             message = ungettext(
                 'You cannot select more than %(choices)d option.',
                 'You cannot select more than %(choices)d options.',
-                self.max_choices)
-            message = message % {'choices': self.max_choices}
-            raise forms.ValidationError(message)
+                self.max_choices,
+            )
+            raise forms.ValidationError(message % {'choices': self.max_choices})
 
         return data
 
@@ -69,9 +69,7 @@ def create_checkbox(setting, kwargs, extra):
     kwargs['choices'] = localise_choices(extra)
 
     if extra.get('min') or extra.get('max'):
-        kwargs['validators'] = [
-            ValidateChoicesNum(extra.pop('min', 0), extra.pop('max', 0))
-        ]
+        kwargs['validators'] = [ValidateChoicesNum(extra.pop('min', 0), extra.pop('max', 0))]
 
     if setting.python_type == 'int':
         return forms.TypedMultipleChoiceField(coerce='int', **kwargs)
@@ -130,20 +128,16 @@ def setting_field(FormType, setting):
     field_factory = FIELD_STYPES[setting.form_field]
     field_extra = setting.field_extra
 
-    form_field = field_factory(
-        setting, basic_kwargs(setting, field_extra), field_extra)
+    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
 
 
 def ChangeSettingsForm(data=None, group=None):
-    """
-    Factory method that builds valid form for settings group
-    """
+    """factory method that builds valid form for settings group"""
+
     class FormType(forms.Form):
         pass
 
@@ -157,7 +151,7 @@ def ChangeSettingsForm(data=None, group=None):
             if fieldset_fields:
                 fieldsets.append({
                     'legend': fieldset_legend,
-                    'form': fieldset_form(data)
+                    'form': fieldset_form(data),
                 })
             fieldset_legend = setting.legend
             fieldset_form = FormType
@@ -168,7 +162,7 @@ def ChangeSettingsForm(data=None, group=None):
     if fieldset_fields:
         fieldsets.append({
             'legend': fieldset_legend,
-            'form': fieldset_form(data)
+            'form': fieldset_form(data),
         })
 
     return fieldsets

+ 15 - 10
misago/conf/migrations/0001_initial.py

@@ -7,14 +7,17 @@ from django.db import migrations, models
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-    ]
+    dependencies = []
 
     operations = [
         migrations.CreateModel(
             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)),
@@ -28,21 +31,23 @@ class Migration(migrations.Migration):
                 ('form_field', models.CharField(default='text', max_length=255)),
                 ('field_extra', JSONField()),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
         migrations.CreateModel(
             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)),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
         migrations.AddField(
             model_name='setting',

+ 4 - 5
misago/conf/migrationutils.py

@@ -65,14 +65,12 @@ def migrate_setting(Setting, group, setting_fixture, order, old_value):
     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')):
+    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)
@@ -87,7 +85,8 @@ def migrate_setting(Setting, group, setting_fixture, order, old_value):
 
     if setting_fixture.get("default_value"):
         setting.default_value = dehydrate_value(
-            setting.python_type, setting_fixture.get("default_value"))
+            setting.python_type, setting_fixture.get("default_value")
+        )
 
     setting.field_extra = field_extra or {}
 

+ 1 - 1
misago/conf/models.py

@@ -1,7 +1,7 @@
 from django.contrib.postgres.fields import JSONField
 from django.db import models
 
-from . import hydrators, utils
+from . import utils
 
 
 class SettingsGroupsManager(models.Manager):

+ 17 - 15
misago/conf/tests/test_admin_views.py

@@ -17,31 +17,33 @@ class AdminSettingsViewsTests(AdminTestCase):
 
         self.assertEqual(response.status_code, 200)
         for group in SettingsGroup.objects.all():
-            group_link = reverse('misago:admin:system:settings:group', kwargs={
-                'key': group.key
-            })
+            group_link = reverse(
+                'misago:admin:system:settings:group', kwargs={
+                    'key': group.key,
+                }
+            )
             self.assertContains(response, group.name)
             self.assertContains(response, group_link)
 
     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'
-        })
+        """invalid group results in redirect to settings list"""
+        group_link = reverse(
+            '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'])
 
     def test_groups_views(self):
-        """
-        each settings group view returns 200 and contains all settings in group
-        """
+        """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
-            })
+            group_link = reverse(
+                'misago:admin:system:settings:group', kwargs={
+                    'key': group.key,
+                }
+            )
             response = self.client.get(group_link)
 
             self.assertEqual(response.status_code, 200)

+ 24 - 24
misago/conf/tests/test_migrationutils.py

@@ -12,25 +12,25 @@ class DBConfMigrationUtilsTests(TestCase):
             'key': 'test_group',
             'name': "Test settings",
             'description': "Those are test settings.",
-            'settings': (
+            'settings': [
                 {
                     'setting': 'fish_name',
                     'name': "Fish's name",
                     'value': "Eric",
                     'field_extra': {
-                           'min_length': 2,
-                           'max_length': 255
-                        },
+                        'min_length': 2,
+                        'max_length': 255,
+                    },
                 },
                 {
                     'setting': 'fish_license_no',
                     'name': "Fish's license number",
                     'default_value': '123-456',
                     'field_extra': {
-                            'max_length': 255
-                        },
+                        'max_length': 255,
+                    },
                 },
-            )
+            ],
         }
 
         migrationutils.migrate_settings_group(apps, self.test_group)
@@ -42,16 +42,14 @@ 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.description, self.test_group['description'])
 
-        custom_settings = migrationutils.get_custom_settings_values(
-            custom_group)
+        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)
@@ -63,37 +61,39 @@ class DBConfMigrationUtilsTests(TestCase):
             'key': 'new_test_group',
             'name': "New test settings",
             'description': "Those are updated test settings.",
-            'settings': (
+            'settings': [
                 {
                     'setting': 'fish_new_name',
                     'name': "Fish's new name",
                     'value': "Eric",
                     'field_extra': {
-                            'min_length': 2,
-                            'max_length': 255
-                        },
+                        '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
-                        },
+                        '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.description, new_group['description'])
 
         for setting in new_group['settings']:
             db_setting = db_group.setting_set.get(setting=setting['setting'])

+ 28 - 19
misago/conf/tests/test_models.py

@@ -6,34 +6,43 @@ 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',
-                                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')
-        self.assertEqual(setting_model.value,
-                         ['Arthur', 'Robin', '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',
+            dry_value='Arthur,Robin,Patsy',
+            default_value='Arthur,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')
+
         setting_model.value = None
         self.assertEqual(setting_model.value, 9001)
         self.assertEqual(setting_model.dry_value, None)

+ 28 - 33
misago/conf/tests/test_settings.py

@@ -27,10 +27,8 @@ class GatewaySettingsTests(TestCase):
     def test_get_existing_setting(self):
         """forum_name is defined"""
         self.assertEqual(gateway.forum_name, db_settings.forum_name)
-        self.assertEqual(gateway.INSTALLED_APPS,
-                         dj_settings.INSTALLED_APPS)
-        self.assertEqual(gateway.MISAGO_THREADS_PER_PAGE,
-                         defaults.MISAGO_THREADS_PER_PAGE)
+        self.assertEqual(gateway.INSTALLED_APPS, dj_settings.INSTALLED_APPS)
+        self.assertEqual(gateway.MISAGO_THREADS_PER_PAGE, defaults.MISAGO_THREADS_PER_PAGE)
 
         with self.assertRaises(AttributeError):
             gateway.LoremIpsum
@@ -46,28 +44,28 @@ class GatewaySettingsTests(TestCase):
             'key': 'test_group',
             'name': "Test settings",
             'description': "Those are test settings.",
-            'settings': (
+            'settings': [
                 {
                     'setting': 'fish_name',
                     'name': "Fish's name",
                     'value': "Public Eric",
                     'field_extra': {
-                            'min_length': 2,
-                            'max_length': 255
-                        },
-                    'is_public': True
+                        'min_length': 2,
+                        'max_length': 255,
+                    },
+                    'is_public': True,
                 },
                 {
                     'setting': 'private_fish_name',
                     'name': "Fish's name",
                     'value': "Private Eric",
                     'field_extra': {
-                            'min_length': 2,
-                            'max_length': 255
-                        },
-                    'is_public': False
+                        'min_length': 2,
+                        'max_length': 255,
+                    },
+                    'is_public': False,
                 },
-            )
+            ],
         }
 
         migrate_settings_group(apps, test_group)
@@ -79,44 +77,43 @@ class GatewaySettingsTests(TestCase):
         self.assertIn('fish_name', public_settings)
         self.assertNotIn('private_fish_name', public_settings)
 
-
     def test_setting_lazy(self):
         """lazy settings work"""
         test_group = {
             'key': 'test_group',
             'name': "Test settings",
             'description': "Those are test settings.",
-            'settings': (
+            'settings': [
                 {
                     'setting': 'fish_name',
                     'name': "Fish's name",
                     'value': "Greedy Eric",
                     'field_extra': {
-                            'min_length': 2,
-                            'max_length': 255
-                        },
-                    'is_lazy': False
+                        'min_length': 2,
+                        'max_length': 255,
+                    },
+                    'is_lazy': False,
                 },
                 {
                     'setting': 'lazy_fish_name',
                     'name': "Fish's name",
                     'value': "Lazy Eric",
                     'field_extra': {
-                            'min_length': 2,
-                            'max_length': 255
-                        },
-                    'is_lazy': True
+                        'min_length': 2,
+                        'max_length': 255,
+                    },
+                    'is_lazy': True,
                 },
                 {
                     'setting': 'lazy_empty_setting',
                     'name': "Fish's name",
                     'field_extra': {
-                            'min_length': 2,
-                            'max_length': 255
-                        },
-                    'is_lazy': True
+                        'min_length': 2,
+                        'max_length': 255,
+                    },
+                    'is_lazy': True,
                 },
-            )
+            ],
         }
 
         migrate_settings_group(apps, test_group)
@@ -125,11 +122,9 @@ class GatewaySettingsTests(TestCase):
         self.assertTrue(db_settings.lazy_fish_name)
 
         self.assertTrue(gateway.lazy_fish_name)
-        self.assertEqual(
-            gateway.get_lazy_setting('lazy_fish_name'), 'Lazy Eric')
+        self.assertEqual(gateway.get_lazy_setting('lazy_fish_name'), 'Lazy Eric')
         self.assertTrue(db_settings.lazy_fish_name)
-        self.assertEqual(
-            db_settings.get_lazy_setting('lazy_fish_name'), 'Lazy Eric')
+        self.assertEqual(db_settings.get_lazy_setting('lazy_fish_name'), 'Lazy Eric')
 
         self.assertTrue(gateway.lazy_empty_setting is None)
         self.assertTrue(db_settings.lazy_empty_setting is None)

+ 3 - 6
misago/conf/utils.py

@@ -3,17 +3,14 @@ from . import hydrators
 
 def get_setting_value(setting):
     if not setting.dry_value and setting.default_value:
-        return hydrators.hydrate_value(
-            setting.python_type, setting.default_value)
+        return hydrators.hydrate_value(setting.python_type, setting.default_value)
     else:
-        return hydrators.hydrate_value(
-            setting.python_type, setting.dry_value)
+        return hydrators.hydrate_value(setting.python_type, setting.dry_value)
 
 
 def set_setting_value(setting, new_value):
     if new_value is not None:
-        setting.dry_value = hydrators.dehydrate_value(
-            setting.python_type, new_value)
+        setting.dry_value = hydrators.dehydrate_value(setting.python_type, new_value)
     else:
         setting.dry_value = None
     return setting.value

+ 10 - 11
misago/conf/views.py

@@ -34,8 +34,7 @@ def group(request, key):
     fieldsets = ChangeSettingsForm(group=active_group)
     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:
@@ -47,15 +46,15 @@ def group(request, key):
 
             db_settings.flush_cache()
 
-            messages.success(
-                request, _("Changes in settings have been saved!"))
+            messages.success(request, _("Changes in settings have been saved!"))
             return redirect('misago:admin:system:settings:group', key=key)
 
-    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,
-    })
+    return render(
+        request, 'misago/admin/conf/group.html', {
+            'active_group': active_group,
+            'fieldsets': fieldsets,
+            'use_single_form_template': use_single_form_template,
+        }
+    )

+ 3 - 4
misago/core/__init__.py

@@ -1,11 +1,10 @@
 from django.conf import settings
 from django.core.checks import register, Critical
 
-
-SUPPORTED_ENGINES = (
+SUPPORTED_ENGINES = [
     'django.db.backends.postgresql',
     'django.db.backends.postgresql_psycopg2',
-)
+]
 
 
 @register()
@@ -18,7 +17,7 @@ def check_db_engine(app_configs, **kwargs):
     except (AttributeError, KeyError, ValueError):
         errors.append(Critical(
             msg='Misago requires PostgreSQL database.',
-            id='misago.001'
+            id='misago.001',
         ))
 
     return errors

+ 1 - 1
misago/core/apipatch.py

@@ -40,7 +40,7 @@ class ApiPatch(object):
     def dispatch(self, request, target):
         if not isinstance(request.data, list):
             return Response({
-                'detail': "PATCH request should be list of operations"
+                'detail': "PATCH request should be list of operations",
             }, status=400)
 
         detail = []

+ 2 - 2
misago/core/apirouter.py

@@ -11,7 +11,7 @@ class MisagoApiRouter(DefaultRouter):
             url=r'^{prefix}{trailing_slash}$',
             mapping={
                 'get': 'list',
-                'post': 'create'
+                'post': 'create',
             },
             name='{basename}-list',
             initkwargs={'suffix': 'List'}
@@ -31,7 +31,7 @@ class MisagoApiRouter(DefaultRouter):
                 'get': 'retrieve',
                 'put': 'update',
                 'patch': 'partial_update',
-                'delete': 'destroy'
+                'delete': 'destroy',
             },
             name='{basename}-detail',
             initkwargs={'suffix': 'Instance'}

+ 1 - 2
misago/core/cachebuster.py

@@ -65,8 +65,7 @@ class CacheBusterController(object):
         from .models import CacheVersion
 
         self.cache[cache] += 1
-        CacheVersion.objects.filter(cache=cache).update(
-            version=F('version') + 1)
+        CacheVersion.objects.filter(cache=cache).update(version=F('version') + 1)
         default_cache.delete(CACHE_KEY)
 
     def invalidate_all(self):

+ 5 - 5
misago/core/context_processors.py

@@ -32,21 +32,21 @@ def current_link(request):
     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())
+        '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 {}

+ 2 - 0
misago/core/decorators.py

@@ -7,6 +7,7 @@ def ajax_only(f):
             return not_allowed(request)
         else:
             return f(request, *args, **kwargs)
+
     return decorator
 
 
@@ -16,4 +17,5 @@ def require_POST(f):
             return not_allowed(request)
         else:
             return f(request, *args, **kwargs)
+
     return decorator

+ 14 - 8
misago/core/errorpages.py

@@ -14,23 +14,27 @@ def _ajax_error(code=406, message=None):
 @admin_error_page
 def _error_page(request, code, message=None):
     request.frontend_context.update({
-        'CURRENT_LINK': 'misago:error-%s' % code
+        'CURRENT_LINK': 'misago:error-%s' % code,
     })
 
-    return render(request, 'misago/errorpages/%s.html' % code, {
-        'message': message
-    }, status=code)
+    return render(
+        request, 'misago/errorpages/%s.html' % code, {
+            'message': message,
+        }, status=code
+    )
 
 
 def banned(request, ban):
     request.frontend_context.update({
         'MESSAGE': ban.get_serialized_message(),
-        'CURRENT_LINK': 'misago:error-banned'
+        'CURRENT_LINK': 'misago:error-banned',
     })
 
-    return render(request, 'misago/errorpages/banned.html', {
-        'ban': ban
-    }, status=403)
+    return render(
+        request, 'misago/errorpages/banned.html', {
+            'ban': ban,
+        }, status=403
+    )
 
 
 def permission_denied(request, message=None):
@@ -68,6 +72,7 @@ def shared_403_exception_handler(f):
             return permission_denied(request)
         else:
             return f(request, *args, **kwargs)
+
     return page_decorator
 
 
@@ -77,4 +82,5 @@ def shared_404_exception_handler(f):
             return page_not_found(request)
         else:
             return f(request, *args, **kwargs)
+
     return page_decorator

+ 15 - 8
misago/core/exceptionhandler.py

@@ -4,13 +4,19 @@ from django.core.exceptions import PermissionDenied
 from django.http import Http404, HttpResponsePermanentRedirect, JsonResponse
 from django.urls import reverse
 from django.utils import six
-from django.utils.translation import gettext as _
 
 from . import errorpages
 from .exceptions import AjaxError, Banned, ExplicitFirstPage, OutdatedSlug
 
 
-HANDLED_EXCEPTIONS = (AjaxError, Banned, ExplicitFirstPage, Http404, OutdatedSlug, PermissionDenied)
+HANDLED_EXCEPTIONS = [
+    AjaxError,
+    Banned,
+    ExplicitFirstPage,
+    Http404,
+    OutdatedSlug,
+    PermissionDenied,
+]
 
 
 def is_misago_exception(exception):
@@ -18,7 +24,10 @@ def is_misago_exception(exception):
 
 
 def handle_ajax_error(request, exception):
-    json = {'is_error': 1, 'message': six.text_type(exception.message)}
+    json = {
+        'is_error': 1,
+        'message': six.text_type(exception.message),
+    }
     return JsonResponse(json, status=exception.code)
 
 
@@ -46,7 +55,6 @@ def handle_outdated_slug_exception(request, exception):
     view_name = request.resolver_match.view_name
 
     model = exception.args[0]
-    model_name = model.__class__.__name__.lower()
     url_kwargs = request.resolver_match.kwargs
     url_kwargs['slug'] = model.slug
 
@@ -63,14 +71,14 @@ def handle_permission_denied_exception(request, exception):
     return errorpages.permission_denied(request, error_message)
 
 
-EXCEPTION_HANDLERS = (
+EXCEPTION_HANDLERS = [
     (AjaxError, handle_ajax_error),
     (Banned, handle_banned_exception),
     (Http404, handle_http404_exception),
     (ExplicitFirstPage, handle_explicit_first_page_exception),
     (OutdatedSlug, handle_outdated_slug_exception),
     (PermissionDenied, handle_permission_denied_exception),
-)
+]
 
 
 def get_exception_handler(exception):
@@ -78,8 +86,7 @@ def get_exception_handler(exception):
         if isinstance(exception, exception_type):
             return handler
     else:
-        raise ValueError(
-            "%s is not Misago exception" % exception.__class__.__name__)
+        raise ValueError("%s is not Misago exception" % exception.__class__.__name__)
 
 
 def handle_misago_exception(request, exception):

+ 4 - 3
misago/core/exceptions.py

@@ -2,7 +2,8 @@ from django.core.exceptions import PermissionDenied
 
 
 class AjaxError(Exception):
-    """You've tried to do something over AJAX but misago blurped"""
+    """you've tried to do something over AJAX but misago blurped"""
+
     def __init__(self, message=None, code=406):
         self.message = message
         self.code = code
@@ -15,10 +16,10 @@ class Banned(PermissionDenied):
 
 
 class ExplicitFirstPage(Exception):
-    """The url that was used to reach view contained explicit first page"""
+    """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"""
+    """the url that was used to reach view contained outdated slug"""
     pass

+ 6 - 2
misago/core/forms.py

@@ -45,6 +45,10 @@ def YesNoSwitch(**kwargs):
 
     return YesNoSwitchBase(
         coerce=int,
-        choices=((1, yes_label), (0, no_label)),
+        choices=[
+            (1, yes_label),
+            (0, no_label),
+        ],
         widget=RadioSelect(attrs={'class': 'yesno-switch'}),
-        **kwargs)
+        **kwargs
+    )

+ 1 - 2
misago/core/mail.py

@@ -11,8 +11,7 @@ def build_mail(request, recipient, subject, template, context=None):
     message_plain = render_to_string('%s.txt' % template, context, request=request)
     message_html = render_to_string('%s.html' % template, context, request=request)
 
-    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

+ 6 - 6
misago/core/management/commands/misagodbrelations.py

@@ -32,12 +32,12 @@ class Command(BaseCommand):
 
                         # Finally list model relations
                         for field in model_relations:
-                            self.stdout.write(field_pattern % (
-                                field.name,
-                                field.__class__.__name__,
-                                field.related_model.__name__,
-                                field.rel.on_delete.__name__,
-                            ))
+                            self.stdout.write(
+                                field_pattern % (
+                                    field.name, field.__class__.__name__,
+                                    field.related_model.__name__, field.rel.on_delete.__name__,
+                                )
+                            )
 
     def print_app_header(self, app):
         # Fancy title

+ 0 - 30
misago/core/management/commands/testemailsetup.py

@@ -1,30 +0,0 @@
-from django.conf import settings
-from django.core import mail
-from django.core.exceptions import ValidationError
-from django.core.management.base import BaseCommand
-from django.core.validators import validate_email
-
-
-class Command(BaseCommand):
-    help = 'Sends test e-mail to given address'
-
-    def add_arguments(self, parser):
-        parser.add_argument('email', type=str)
-
-    def handle(self, *args, **options):
-        try:
-            email = options['email']
-            validate_email(email)
-            self.send_message(email)
-        except ValidationError:
-            self.stderr.write("This isn't valid e-mail address")
-
-    def send_message(self, email):
-        mail.send_mail(
-            'Test Message',
-            ("This message was sent to test if your "
-             "site e-mail is configured correctly."),
-            settings.DEFAULT_FROM_EMAIL,
-            [email],
-            fail_silently=False)
-        self.stdout.write("Test message was sent to %s" % email)

+ 1 - 2
misago/core/middleware/exceptionhandler.py

@@ -7,8 +7,7 @@ from misago.core.utils import is_request_to_misago
 class ExceptionHandlerMiddleware(MiddlewareMixin):
     def process_exception(self, request, exception):
         request_is_to_misago = is_request_to_misago(request)
-        misago_can_handle_exception = exceptionhandler.is_misago_exception(
-            exception)
+        misago_can_handle_exception = exceptionhandler.is_misago_exception(exception)
 
         if request_is_to_misago and misago_can_handle_exception:
             return exceptionhandler.handle_misago_exception(request, exception)

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

@@ -6,19 +6,21 @@ from django.db import migrations, models
 
 class Migration(migrations.Migration):
 
-    dependencies = [
-    ]
+    dependencies = []
 
     operations = [
         migrations.CreateModel(
             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)),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
     ]

+ 66 - 67
misago/core/migrations/0002_basic_settings.py

@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-from django.db import migrations, models
+from django.db import migrations
 
 from misago.conf.migrationutils import migrate_settings_group
 
@@ -10,77 +10,76 @@ _ = lambda x: x
 
 
 def create_basic_settings_group(apps, schema_editor):
-    migrate_settings_group(apps, {
-        'key': 'basic',
-        'name': _("Basic forum settings"),
-        'description': _("Those settings control most basic properties "
-                         "of your forum like its name or description."),
-        'settings': (
-            {
-                'setting': 'forum_name',
-                'name': _("Forum name"),
-                'legend': _("General"),
-                'value': "Misago",
-                'field_extra': {
-                    'min_length': 2,
-                    'max_length': 255
+    migrate_settings_group(
+        apps, {
+            'key': 'basic',
+            'name': _("Basic forum settings"),
+            'description': _(
+                "Those settings control most basic properties "
+                "of your forum like its name or description."
+            ),
+            'settings': [
+                {
+                    'setting': 'forum_name',
+                    'name': _("Forum name"),
+                    'legend': _("General"),
+                    'value': "Misago",
+                    'field_extra': {
+                        'min_length': 2,
+                        'max_length': 255
+                    },
+                    'is_public': True,
                 },
-                '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
+                {
+                    '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,
                 },
-                '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_text',
-                'name': _("Branding text"),
-                'description': _("Optional text displayed besides "
-                                 "brand image in navbar."),
-                'value': "Misago",
-                '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,
                 },
-                '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': '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
+                    },
+                },
+            ],
+        }
+    )
 
 
 class Migration(migrations.Migration):

+ 14 - 15
misago/core/page.py

@@ -5,6 +5,7 @@ class Page(object):
     Allows for adding custom views to "sectioned" pages like
     User Control Panel, Users List or Threads Lists
     """
+
     def __init__(self, name):
         self._finalized = False
         self.name = name
@@ -21,17 +22,16 @@ 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" % self._unsorted_list)
-                raise ValueError(message)
+                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'])
+                    section_added = self._insert_section(section, after=section['after'])
                 elif section['before']:
-                    section_added = self._insert_section(
-                        section, before=section['before'])
+                    section_added = self._insert_section(section, before=section['before'])
                 else:
                     section_added = self._insert_section(section)
 
@@ -42,7 +42,7 @@ class Page(object):
     def _insert_section(self, inserted_section, after=None, before=None):
         if after:
             new_sorted_list = []
-            for index, section in enumerate(self._sorted_list):
+            for section in self._sorted_list:
                 new_sorted_list.append(section)
                 if section['link'] == after:
                     new_sorted_list.append(inserted_section)
@@ -52,7 +52,7 @@ class Page(object):
                 return False
         elif before:
             new_sorted_list = []
-            for index, section in enumerate(self._sorted_list):
+            for section in self._sorted_list:
                 if section['link'] == before:
                     new_sorted_list.append(inserted_section)
                     new_sorted_list.append(section)
@@ -66,11 +66,11 @@ class Page(object):
             self._sorted_list.append(inserted_section)
             return True
 
-    def add_section(self, link, after=None, before=None,
-                visible_if=None, get_metadata=None, **kwargs):
+    def add_section(
+            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:
@@ -110,8 +110,7 @@ class Page(object):
 
             if is_visible:
                 if section['get_metadata']:
-                    section['metadata'] = section['get_metadata'](
-                        request, *args)
+                    section['metadata'] = section['get_metadata'](request, *args)
                 section['is_active'] = active_link.startswith(section['link'])
                 visible_sections.append(section)
         return visible_sections

+ 7 - 16
misago/core/pgutils.py

@@ -24,8 +24,7 @@ DROP INDEX %(index_name)s
     def state_forwards(self, app_label, state):
         pass
 
-    def database_forwards(self, app_label, schema_editor,
-                          from_state, to_state):
+    def database_forwards(self, app_label, schema_editor, from_state, to_state):
         model = from_state.apps.get_model(app_label, self.model)
 
         statement = self.CREATE_SQL % {
@@ -37,10 +36,8 @@ DROP INDEX %(index_name)s
 
         schema_editor.execute(statement)
 
-    def database_backwards(self, app_label, schema_editor,
-                           from_state, to_state):
-        schema_editor.execute(
-            self.REMOVE_SQL % {'index_name': self.index_name})
+    def database_backwards(self, app_label, schema_editor, from_state, to_state):
+        schema_editor.execute(self.REMOVE_SQL % {'index_name': self.index_name})
 
     def describe(self):
         message = "Create PostgreSQL partial index on field %s in %s for %s"
@@ -49,9 +46,7 @@ DROP INDEX %(index_name)s
 
 
 def batch_update(queryset, step=50):
-    """
-    Util because psycopg2 iterators aren't really memory effective
-    """
+    """util because psycopg2 iterators aren't memory effective in Dj<1.11"""
     paginator = Paginator(queryset.order_by('pk'), step)
     for page_number in paginator.page_range:
         for obj in paginator.page(page_number).object_list:
@@ -59,9 +54,7 @@ def batch_update(queryset, step=50):
 
 
 def batch_delete(queryset, step=50):
-    """
-    Another util cos paginator goes bobbins when you are deleting
-    """
+    """another util cos paginator goes bobbins when you are deleting"""
     queryset_exists = True
     while queryset_exists:
         for obj in queryset[:step]:
@@ -85,8 +78,7 @@ DROP INDEX %(index_name)s
         self.index_name = index_name
         self.condition = condition
 
-    def database_forwards(self, app_label, schema_editor,
-                          from_state, to_state):
+    def database_forwards(self, app_label, schema_editor, from_state, to_state):
         model = from_state.apps.get_model(app_label, self.model)
 
         statement = self.CREATE_SQL % {
@@ -99,7 +91,6 @@ DROP INDEX %(index_name)s
         schema_editor.execute(statement)
 
     def describe(self):
-        message = ("Create PostgreSQL partial composite "
-                   "index on fields %s in %s for %s")
+        message = ("Create PostgreSQL partial composite index on fields %s in %s for %s")
         formats = (', '.join(self.fields), self.model_name, self.values)
         return message % formats

+ 6 - 12
misago/core/serializers.py

@@ -7,11 +7,9 @@ class MutableFields(object):
         class Meta(cls.Meta):
             pass
 
-        Meta.fields = tuple(fields)
+        Meta.fields = list(fields)
 
-        return type(name, (cls,), {
-            'Meta': Meta
-        })
+        return type(name, (cls, ), {'Meta': Meta})
 
     @classmethod
     def exclude_fields(cls, *fields):
@@ -26,11 +24,9 @@ class MutableFields(object):
         class Meta(cls.Meta):
             pass
 
-        Meta.fields = tuple(final_fields)
+        Meta.fields = list(final_fields)
 
-        return type(name, (cls,), {
-            'Meta': Meta
-        })
+        return type(name, (cls, ), {'Meta': Meta})
 
     @classmethod
     def extend_fields(cls, *fields):
@@ -45,8 +41,6 @@ class MutableFields(object):
         class Meta(cls.Meta):
             pass
 
-        Meta.fields = tuple(final_fields)
+        Meta.fields = list(final_fields)
 
-        return type(name, (cls,), {
-            'Meta': Meta
-        })
+        return type(name, (cls, ), {'Meta': Meta})

+ 10 - 6
misago/core/setup.py

@@ -22,9 +22,11 @@ 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
 
@@ -36,14 +38,16 @@ def get_misago_project_template():
 
 def start_misago_project():
     parser = OptionParser(usage="usage: %prog project_name")
-    (options, args) = parser.parse_args()
+    _, args = parser.parse_args()
 
     if len(args) != 1:
         parser.error("project_name must be specified")
 
     project_name = validate_project_name(parser, args[0])
 
-    argv = ['start-misago.py', 'startproject', project_name,
-            '--template=%s' % get_misago_project_template()]
+    argv = [
+        'start-misago.py', 'startproject', project_name,
+        '--template=%s' % get_misago_project_template()
+    ]
 
     management.execute_from_command_line(argv)

+ 13 - 10
misago/core/shortcuts.py

@@ -5,10 +5,15 @@ from django.http import Http404
 import six
 
 
-def paginate(object_list, page, per_page, orphans=0,
-             allow_empty_first_page=True,
-             allow_explicit_first_page=False,
-             paginator=None):
+def paginate(
+        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
 
@@ -21,9 +26,9 @@ def paginate(object_list, page, per_page, orphans=0,
 
     try:
         return paginator(
-            object_list, per_page, orphans=orphans,
-            allow_empty_first_page=allow_empty_first_page).page(page)
-    except (EmptyPage, InvalidPage) as e:
+            object_list, per_page, orphans=orphans, allow_empty_first_page=allow_empty_first_page
+        ).page(page)
+    except (EmptyPage, InvalidPage):
         raise Http404()
 
 
@@ -62,9 +67,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)

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

@@ -31,7 +31,7 @@ def capture(parser, token):
     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)
 

+ 22 - 22
misago/core/templatetags/misago_forms.py

@@ -7,25 +7,25 @@ from django.template.loader import render_to_string
 register = template.Library()
 
 
-"""
-Form row: renders single row in form
-
-Syntax:
-{% form_row form.field %} # renders vertical field
-{% form_row form.field "col-md-3" "col-md-9" %} # renders horizontal field
-"""
 @register.tag
 def form_row(parser, token):
+    """
+    Form row: renders single row in form
+
+    Syntax:
+    {% form_row form.field %} # renders vertical field
+    {% form_row form.field "col-md-3" "col-md-9" %} # renders horizontal field
+    """
     args = token.split_contents()
 
     if len(args) < 2:
-        raise template.TemplateSyntaxError(
-            "form_row tag requires at least one argument")
+        raise template.TemplateSyntaxError("form_row tag requires at least one argument")
 
     if len(args) == 3 or len(args) > 4:
         raise template.TemplateSyntaxError(
             "form_row tag supports either one argument (form field) or "
-            "four arguments (form field, label class, field class)")
+            "four arguments (form field, label class, field class)"
+        )
 
     form_field = args[1]
 
@@ -61,18 +61,18 @@ class FormRowNode(template.Node):
             field_class = None
 
         template_pack = crispy_forms_filters.TEMPLATE_PACK
-        return render_to_string('%s/field.html' % template_pack, {
-            'field': field,
-            'form_show_errors': True,
-            'form_show_labels': True,
-            'label_class': label_class or '',
-            'field_class': field_class or ''
-        })
-
-
-"""
-Form input: renders given field input
-"""
+        return render_to_string(
+            '%s/field.html' % template_pack, {
+                'field': field,
+                'form_show_errors': True,
+                'form_show_labels': True,
+                'label_class': label_class or '',
+                'field_class': field_class or '',
+            }
+        )
+
+
 @register.tag
 def form_input(parser, token):
+    """form input: renders given field input"""
     return crispy_forms_field.crispy_field(parser, token)

+ 0 - 2
misago/core/templatetags/misago_pagetitle.py

@@ -1,8 +1,6 @@
 from django import template
 from django.utils.translation import gettext as _
 
-from misago.conf import settings
-
 
 register = template.Library()
 

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

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

+ 35 - 11
misago/core/testproject/urls.py

@@ -11,24 +11,48 @@ from . import views
 admin.autodiscover()
 admin.site.login_form = AdminAuthenticationForm
 
-
-
 urlpatterns = [
     url(r'^forum/', include('misago.urls', namespace='misago')),
     url(r'^django-admin/', include(admin.site.urls)),
-
     url(r'^django-i18n.js$', javascript_catalog, name='django-i18n'),
-
     url(r'^forum/test-mail-user/$', views.test_mail_user, name='test-mail-user'),
     url(r'^forum/test-mail-users/$', views.test_mail_users, name='test-mail-users'),
     url(r'^forum/test-pagination/$', views.test_pagination, name='test-pagination'),
-    url(r'^forum/test-pagination/(?P<page>[1-9][0-9]*)/$', views.test_pagination, name='test-pagination'),
-    url(r'^forum/test-paginated-response/$', views.test_paginated_response, name='test-paginated-response'),
-    url(r'^forum/test-paginated-response-data/$', views.test_paginated_response_data, name='test-paginated-response-data'),
-    url(r'^forum/test-paginated-response-serializer/$', views.test_paginated_response_serializer, name='test-paginated-response-serializer'),
-    url(r'^forum/test-paginated-response-data-serializer/$', views.test_paginated_response_data_serializer, name='test-paginated-response-data-serializer'),
-    url(r'^forum/test-paginated-response-data-extra/$', views.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+)/$', views.validate_slug_view, name='validate-slug-view'),
+    url(
+        r'^forum/test-pagination/(?P<page>[1-9][0-9]*)/$',
+        views.test_pagination,
+        name='test-pagination'
+    ),
+    url(
+        r'^forum/test-paginated-response/$',
+        views.test_paginated_response,
+        name='test-paginated-response'
+    ),
+    url(
+        r'^forum/test-paginated-response-data/$',
+        views.test_paginated_response_data,
+        name='test-paginated-response-data'
+    ),
+    url(
+        r'^forum/test-paginated-response-serializer/$',
+        views.test_paginated_response_serializer,
+        name='test-paginated-response-serializer'
+    ),
+    url(
+        r'^forum/test-paginated-response-data-serializer/$',
+        views.test_paginated_response_data_serializer,
+        name='test-paginated-response-data-serializer'
+    ),
+    url(
+        r'^forum/test-paginated-response-data-extra/$',
+        views.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+)/$',
+        views.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'),

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

@@ -20,19 +20,15 @@ UserModel = get_user_model()
 
 def test_mail_user(request):
     test_user = UserModel.objects.all().first()
-    mail.mail_user(request,
-                   test_user,
-                   "Misago Test Mail",
-                   "misago/emails/base")
+    mail.mail_user(request, test_user, "Misago Test Mail", "misago/emails/base")
 
     return HttpResponse("Mailed user!")
 
 
 def test_mail_users(request):
-    mail.mail_users(request,
-                    UserModel.objects.iterator(),
-                    "Misago Test Spam",
-                    "misago/emails/base")
+    mail.mail_users(
+        request, UserModel.objects.iterator(), "Misago Test Spam", "misago/emails/base"
+    )
 
     return HttpResponse("Mailed users!")
 
@@ -75,7 +71,7 @@ def test_paginated_response_data_serializer(request):
     return paginated_response(
         page,
         data=['a', 'b', 'c', 'd'],
-        serializer=MockSerializer
+        serializer=MockSerializer,
     )
 
 
@@ -89,8 +85,8 @@ def test_paginated_response_data_extra(request):
         data=['a', 'b', 'c', 'd'],
         extra={
             'next': 'EXTRA',
-            'lorem': 'ipsum'
-        }
+            'lorem': 'ipsum',
+        },
     )
 
 

+ 135 - 52
misago/core/tests/test_apipatch.py

@@ -23,6 +23,7 @@ class ApiPatchTests(TestCase):
 
         def mock_function():
             pass
+
         patch.add('test-add', mock_function)
 
         self.assertEqual(len(patch._actions), 1)
@@ -36,6 +37,7 @@ class ApiPatchTests(TestCase):
 
         def mock_function():
             pass
+
         patch.remove('test-remove', mock_function)
 
         self.assertEqual(len(patch._actions), 1)
@@ -49,6 +51,7 @@ class ApiPatchTests(TestCase):
 
         def mock_function():
             pass
+
         patch.replace('test-replace', mock_function)
 
         self.assertEqual(len(patch._actions), 1)
@@ -60,21 +63,29 @@ class ApiPatchTests(TestCase):
         """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},
-        )
+        VALID_ACTIONS = [
+            {
+                '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:
@@ -96,13 +107,20 @@ class ApiPatchTests(TestCase):
 
         # 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], u'"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], u'"add" op has to specify value')
 
@@ -116,12 +134,14 @@ class ApiPatchTests(TestCase):
             self.assertEqual(request, 'request')
             self.assertEqual(target, mock_target)
             return {'a': value * 2, 'b': 111}
+
         patch.replace('abc', action_a)
 
         def action_b(request, target, value):
             self.assertEqual(request, 'request')
             self.assertEqual(target, mock_target)
             return {'b': value * 10}
+
         patch.replace('abc', action_b)
 
         def action_fail(request, target, value):
@@ -131,15 +151,15 @@ class ApiPatchTests(TestCase):
         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.dispatch_action(
+            patch_dict, 'request', mock_target, {
+                'op': 'replace',
+                'path': 'abc',
+                'value': 5,
+            }
+        )
 
         self.assertEqual(len(patch_dict), 3)
         self.assertEqual(patch_dict['id'], 123)
@@ -155,26 +175,40 @@ class ApiPatchTests(TestCase):
                 raise Http404()
             if value == 'perm':
                 raise PermissionDenied("yo ain't doing that!")
+
         patch.replace('error', action_error)
 
         def action_mutate(request, target, value):
             return {'value': value * 2}
+
         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))
+        response = patch.dispatch(
+            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)
 
@@ -186,47 +220,97 @@ class ApiPatchTests(TestCase):
         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))
+        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)
+        )
 
         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['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))
+        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)
+        )
 
         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['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))
+        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)
+        )
 
         self.assertEqual(response.status_code, 400)
 
@@ -234,7 +318,6 @@ class ApiPatchTests(TestCase):
         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['detail'][3], "yo ain't doing that!")
         self.assertEqual(response.data['id'], 13)
         self.assertEqual(response.data['value'], 18)

+ 1 - 2
misago/core/tests/test_cachebuster.py

@@ -1,5 +1,4 @@
-from misago.core import cachebuster, threadstore
-from misago.core.cache import cache
+from misago.core import cachebuster
 from misago.core.models import CacheVersion
 from misago.core.testutils import MisagoTestCase
 

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

@@ -5,11 +5,11 @@ from django.test import TestCase
 from misago.core import SUPPORTED_ENGINES, check_db_engine
 
 
-INVALID_ENGINES = (
+INVALID_ENGINES = [
     'django.db.backends.sqlite3',
     'django.db.backends.mysql',
     'django.db.backends.oracle',
-)
+]
 
 
 class TestCheckDBEngine(TestCase):

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

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

+ 42 - 28
misago/core/tests/test_context_processors.py

@@ -27,46 +27,58 @@ class MomentjsLocaleTests(TestCase):
     def test_momentjs_locale(self):
         """momentjs_locale adds MOMENTJS_LOCALE_URL to context"""
         with translation.override('no-no'):
-            self.assertEqual(context_processors.momentjs_locale(True), {
-                'MOMENTJS_LOCALE_URL': None,
-            })
+            self.assertEqual(
+                context_processors.momentjs_locale(True), {
+                    'MOMENTJS_LOCALE_URL': None,
+                }
+            )
 
         with translation.override('en-us'):
-            self.assertEqual(context_processors.momentjs_locale(True), {
-                'MOMENTJS_LOCALE_URL': None,
-            })
+            self.assertEqual(
+                context_processors.momentjs_locale(True), {
+                    'MOMENTJS_LOCALE_URL': None,
+                }
+            )
 
         with translation.override('de'):
-            self.assertEqual(context_processors.momentjs_locale(True), {
-                'MOMENTJS_LOCALE_URL': 'misago/momentjs/de.js',
-            })
+            self.assertEqual(
+                context_processors.momentjs_locale(True), {
+                    'MOMENTJS_LOCALE_URL': 'misago/momentjs/de.js',
+                }
+            )
 
         with translation.override('pl-de'):
-            self.assertEqual(context_processors.momentjs_locale(True), {
-                'MOMENTJS_LOCALE_URL': 'misago/momentjs/pl.js',
-            })
+            self.assertEqual(
+                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')
-        self.assertEqual(context_processors.site_address(mock_request), {
-            'REQUEST_PATH': '/',
-            'SITE_ADDRESS': 'http://somewhere.com',
-            'SITE_HOST': 'somewhere.com',
-            'SITE_PROTOCOL': 'http',
-        })
+        self.assertEqual(
+            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')
-        self.assertEqual(context_processors.site_address(mock_request), {
-            'REQUEST_PATH': '/',
-            'SITE_ADDRESS': 'https://somewhere.com',
-            'SITE_HOST': 'somewhere.com',
-            'SITE_PROTOCOL': 'https',
-        })
+        self.assertEqual(
+            context_processors.site_address(mock_request), {
+                'REQUEST_PATH': '/',
+                'SITE_ADDRESS': 'https://somewhere.com',
+                'SITE_HOST': 'somewhere.com',
+                'SITE_PROTOCOL': 'https',
+            }
+        )
 
 
 class FrontendContextTests(TestCase):
@@ -76,11 +88,13 @@ class FrontendContextTests(TestCase):
         mock_request.include_frontend_context = True
         mock_request.frontend_context = {'someValue': 'Something'}
 
-        self.assertEqual(context_processors.frontend_context(mock_request), {
-            'frontend_context': {
-                'someValue': 'Something'
+        self.assertEqual(
+            context_processors.frontend_context(mock_request), {
+                'frontend_context': {
+                    'someValue': 'Something',
+                },
             }
-        })
+        )
 
         mock_request.include_frontend_context = False
         self.assertEqual(context_processors.frontend_context(mock_request), {})

+ 0 - 1
misago/core/tests/test_decorators.py

@@ -1,4 +1,3 @@
-from django.contrib.auth import get_user_model
 from django.test import TestCase, override_settings
 from django.urls import reverse
 

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

@@ -1,6 +1,6 @@
 import warnings
 
-from django.test import TestCase, override_settings
+from django.test import TestCase
 from django.utils import six
 
 from misago.core.deprecations import RemovedInMisagoWarning, warn

+ 2 - 6
misago/core/tests/test_errorpages.py

@@ -11,21 +11,17 @@ 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')
 class ErrorPageViewsTests(TestCase):
     def test_banned_returns_403(self):
         """banned error page has no show-stoppers"""
         response = self.client.get(reverse('raise-misago-banned'))
         self.assertContains(response, "misago:error-banned", 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"""

+ 10 - 18
misago/core/tests/test_exceptionhandlers.py

@@ -8,28 +8,23 @@ from misago.core.exceptions import Banned
 from misago.users.models import Ban
 
 
-INVALID_EXCEPTIONS = (
+INVALID_EXCEPTIONS = [
     django_exceptions.ObjectDoesNotExist,
     django_exceptions.ViewDoesNotExist,
     TypeError,
     ValueError,
     KeyError,
-)
+]
 
 
 class IsMisagoExceptionTests(TestCase):
     def test_is_misago_exception_true_for_handled_exceptions(self):
-        """
-        exceptionhandler.is_misago_exception recognizes handled exceptions
-        """
+        """exceptionhandler.is_misago_exception recognizes handled exceptions"""
         for exception in exceptionhandler.HANDLED_EXCEPTIONS:
             self.assertTrue(exceptionhandler.is_misago_exception(exception()))
 
     def test_is_misago_exception_false_for_not_handled_exceptions(self):
-        """
-        exceptionhandler.is_misago_exception fails to recognize other
-        exceptions
-        """
+        """exceptionhandler.is_misago_exception fails to recognize other exceptions"""
         for exception in INVALID_EXCEPTIONS:
             self.assertFalse(exceptionhandler.is_misago_exception(exception()))
 
@@ -37,8 +32,9 @@ class IsMisagoExceptionTests(TestCase):
 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))
+        self.assertEqual(
+            len(exceptionhandler.HANDLED_EXCEPTIONS), len(exceptionhandler.EXCEPTION_HANDLERS)
+        )
 
     def test_get_exception_handler_for_handled_exceptions(self):
         """Exception handler has correct handler for every Misago exception"""
@@ -57,18 +53,14 @@ class HandleAPIExceptionTests(TestCase):
         """banned exception is correctly handled"""
         ban = Ban(user_message="This is test ban!")
 
-        response = exceptionhandler.handle_api_exception(
-            Banned(ban), None)
+        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)
+        response = exceptionhandler.handle_api_exception(PermissionDenied(), None)
         self.assertEqual(response.status_code, 403)
         self.assertEqual(response.data['detail'], "Permission denied.")
 

+ 1 - 4
misago/core/tests/test_mailer.py

@@ -20,10 +20,7 @@ class MisagoMailerTests(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)
 

+ 2 - 7
misago/core/tests/test_migrationutils.py

@@ -7,18 +7,13 @@ from misago.core.models import CacheVersion
 
 class CacheBusterUtilsTests(TestCase):
     def test_cachebuster_register_cache(self):
-        """
-        cachebuster_register_cache registers cache on migration successfully
-        """
+        """cachebuster_register_cache registers cache on migration successfully"""
         cache_name = 'eric_licenses'
         migrationutils.cachebuster_register_cache(apps, cache_name)
         CacheVersion.objects.get(cache=cache_name)
 
     def test_cachebuster_unregister_cache(self):
-        """
-        cachebuster_unregister_cache removes cache on migration successfully
-        """
-
+        """cachebuster_unregister_cache removes cache on migration successfully"""
         cache_name = 'eric_licenses'
         migrationutils.cachebuster_register_cache(apps, cache_name)
         CacheVersion.objects.get(cache=cache_name)

+ 4 - 19
misago/core/tests/test_momentjs.py

@@ -1,4 +1,3 @@
-from django.conf import settings
 from django.test import TestCase
 
 from misago.core.momentjs import clean_language_name, get_locale_url
@@ -7,14 +6,14 @@ from misago.core.momentjs import clean_language_name, get_locale_url
 class MomentJSTests(TestCase):
     def test_clean_language_name(self):
         """clean_language_name returns valid name"""
-        TEST_CASES = (
+        TEST_CASES = [
             ('AF', 'af'),
             ('ar-SA', 'ar-sa'),
             ('de', 'de'),
             ('de-NO', 'de'),
             ('pl-pl', 'pl'),
             ('zz', None),
-        )
+        ]
 
         for dirty, clean in TEST_CASES:
             self.assertEqual(clean_language_name(dirty), clean)
@@ -22,27 +21,13 @@ 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-tw'
+            'af', 'ar-sa', 'ar-sasa', 'de', 'et', 'pl', 'pl-pl', 'ru', 'pt-br', 'zh-tw'
         )
 
         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))

+ 6 - 3
misago/core/tests/test_page.py

@@ -12,16 +12,19 @@ class SiteTests(TestCase):
         self.page.add_section(
             link='misago:user-posts',
             name='Posts',
-            after='misago:user-threads')
+            after='misago:user-threads',
+        )
 
         self.page.add_section(
             link='misago:user-threads',
-            name='Threads')
+            name='Threads',
+        )
 
         self.page.add_section(
             link='misago:user-follows',
             name='Follows',
-            before='misago:user-posts')
+            before='misago:user-posts',
+        )
 
         self.page.assert_is_finalized()
 

+ 22 - 23
misago/core/tests/test_serializers.py

@@ -14,22 +14,21 @@ class MutableFieldsSerializerTests(TestCase):
         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,
-        })
+        self.assertEqual(
+            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)
 
@@ -38,19 +37,21 @@ class MutableFieldsSerializerTests(TestCase):
         category = Category.objects.get(slug='first-category')
         thread = testutils.post_thread(category=category)
 
-        kept_fields = ('id', 'title', 'weight')
-        removed_fields = tuple(set(TestSerializer.Meta.fields) - set(kept_fields))
+        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.Meta.fields, kept_fields)
 
         serialized_thread = serializer(thread).data
-        self.assertEqual(serialized_thread, {
-            'id': thread.id,
-            'title': thread.title,
-            'weight': thread.weight,
-        })
+        self.assertEqual(
+            serialized_thread, {
+                'id': thread.id,
+                'title': thread.title,
+                'weight': thread.weight,
+            }
+        )
 
         self.assertFalse(TestSerializer.Meta.fields == serializer.Meta.fields)
 
@@ -59,9 +60,7 @@ class MutableFieldsSerializerTests(TestCase):
         category = Category.objects.get(slug='first-category')
         thread = testutils.post_thread(category=category)
 
-        added_fields = ('category',)
-
-        serializer = TestSerializer.extend_fields(*added_fields)
+        serializer = TestSerializer.extend_fields('category')
 
         serialized_thread = serializer(thread).data
         self.assertEqual(serialized_thread['category'], category.pk)
@@ -72,7 +71,7 @@ class TestSerializer(serializers.ModelSerializer, MutableFields):
 
     class Meta:
         model = Thread
-        fields = (
+        fields = [
             'id',
             'title',
             'replies',
@@ -86,4 +85,4 @@ class TestSerializer(serializers.ModelSerializer, MutableFields):
             'is_hidden',
             'is_closed',
             'weight',
-        )
+        ]

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

@@ -33,9 +33,9 @@ class SetupTests(TestCase):
 
     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__)))
+        misago_path = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
         test_project_path = os.path.join(misago_path, 'project_template')
 
-        self.assertEqual(smart_str(setup.get_misago_project_template()),
-                         smart_str(test_project_path))
+        self.assertEqual(
+            smart_str(setup.get_misago_project_template()), smart_str(test_project_path)
+        )

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

@@ -9,26 +9,22 @@ from misago.core.shortcuts import get_int_or_404
 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)
 
@@ -37,18 +33,22 @@ class PaginateTests(TestCase):
 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,
-        }))
+        response = self.client.get(
+            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,
-        }))
+        response = self.client.get(
+            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)
@@ -57,19 +57,19 @@ class ValidateSlugTests(TestCase):
 class GetIntOr404Tests(TestCase):
     def test_valid_inputs(self):
         """get_int_or_404 returns int for valid values"""
-        VALID_VALUES = (
+        VALID_VALUES = [
             ('0', 0),
             ('123', 123),
             ('000123', 123),
             ('1', 1),
-        )
+        ]
 
         for value, result in VALID_VALUES:
             self.assertEqual(get_int_or_404(value), result)
 
     def test_invalid_inputs(self):
         """get_int_or_404 raises Http404 for invalid values"""
-        INVALID_VALUES = (
+        INVALID_VALUES = [
             None,
             '',
             'bob',
@@ -79,7 +79,7 @@ class GetIntOr404Tests(TestCase):
             '12.321',
             '.4',
             '5.',
-        )
+        ]
 
         for value in INVALID_VALUES:
             with self.assertRaises(Http404):
@@ -92,94 +92,120 @@ class PaginatedResponseTests(TestCase):
         """utility returns response for only page arg"""
         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,
-        })
+        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,
+            }
+        )
 
     def test_explicit_data_response(self):
         """utility returns response with explicit 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,
-        })
+        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,
+            }
+        )
 
     def test_explicit_serializer_response(self):
         """utility returns response with data serialized via 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,
-        })
+        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,
+            }
+        )
 
     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'))
         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,
-        })
+        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,
+            }
+        )
 
     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'))
         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'
-        })
+        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'
+            }
+        )

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

@@ -2,9 +2,7 @@ from django import forms
 from django.template import Context, Template, TemplateSyntaxError
 from django.test import TestCase
 
-from misago.core.shortcuts import paginate
 from misago.core.templatetags import misago_batch
-from misago.core.utils import encode_json_html
 
 
 class CaptureTests(TestCase):
@@ -47,12 +45,12 @@ class BatchTests(TestCase):
     def test_batch(self):
         """standard batch yields valid results"""
         batch = 'loremipsum'
-        yields = (
+        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])
@@ -60,12 +58,12 @@ class BatchTests(TestCase):
     def test_batchnonefilled(self):
         """none-filled batch yields valid results"""
         batch = 'loremipsum'
-        yields = (
+        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])
@@ -173,10 +171,7 @@ 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"""
@@ -187,10 +182,7 @@ 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"""
@@ -201,10 +193,7 @@ 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"""
@@ -215,10 +204,7 @@ 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):
@@ -231,9 +217,13 @@ 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!"}')
+        self.assertEqual(
+            tpl.render(Context({
+                'value': {
+                    'he</script>llo': 'bo"b!'
+                }
+            })).strip(), r'{"he\u003C/script>llo": "bo\"b!"}'
+        )
 
 
 class PageTitleTests(TestCase):
@@ -246,9 +236,7 @@ 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"""
@@ -259,10 +247,12 @@ class PageTitleTests(TestCase):
         """
 
         tpl = Template(tpl_content)
-        self.assertEqual(tpl.render(Context({
-            'item': 'Lorem Ipsum',
-            'parent': 'Some Thread'
-        })).strip(), 'Lorem Ipsum | Some Thread')
+        self.assertEqual(
+            tpl.render(Context({
+                'item': 'Lorem Ipsum',
+                'parent': 'Some Thread',
+            })).strip(), 'Lorem Ipsum | Some Thread'
+        )
 
     def test_paged_title(self):
         """tag builds full title from title and page number"""
@@ -273,9 +263,11 @@ class PageTitleTests(TestCase):
         """
 
         tpl = Template(tpl_content)
-        self.assertEqual(tpl.render(Context({
-            'item': 'Lorem Ipsum'
-        })).strip(), 'Lorem Ipsum (page: 3)')
+        self.assertEqual(
+            tpl.render(Context({
+                'item': 'Lorem Ipsum',
+            })).strip(), 'Lorem Ipsum (page: 3)'
+        )
 
     def test_kitchensink_title(self):
         """tag builds full title from all options"""
@@ -286,7 +278,9 @@ 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')
+        self.assertEqual(
+            tpl.render(Context({
+                'item': 'Lorem Ipsum',
+                'parent': 'Some Thread',
+            })).strip(), 'Lorem Ipsum (page: 3) | Some Thread'
+        )

+ 0 - 32
misago/core/tests/test_testmailsetup.py

@@ -1,32 +0,0 @@
-from django.core import mail
-from django.core.management import call_command
-from django.test import TestCase
-from django.utils.six import StringIO
-
-from misago.core.management.commands import testemailsetup
-
-
-class TestEmailSetupTests(TestCase):
-    def test_email_setup(self):
-        """command sets test email in outbox"""
-        command = testemailsetup.Command()
-
-        out = StringIO()
-        call_command(command, "t@mail.com", stdout=out)
-        command_output = out.getvalue().splitlines()[0].strip()
-
-        self.assertEqual(command_output, 'Test message was sent to t@mail.com')
-        self.assertEqual('Test Message', mail.outbox[0].subject)
-
-    def test_invalid_args(self):
-        """
-        there are no unhandled exceptions when command receives invalid args
-        """
-        command = testemailsetup.Command()
-
-        out = StringIO()
-        err = StringIO()
-
-        call_command(command, "bawww", stdout=out, stderr=err)
-        command_output = err.getvalue().splitlines()[-1].strip()
-        self.assertEqual(command_output, "This isn't valid e-mail address")

+ 103 - 93
misago/core/tests/test_utils.py

@@ -4,22 +4,16 @@ from __future__ import unicode_literals
 from django.test import TestCase
 from django.test.client import RequestFactory
 from django.urls import reverse
-from django.utils import six, timezone
+from django.utils import six
 
 from misago.core.utils import (
     clean_return_path, format_plaintext_for_html, is_referer_local, is_request_to_misago,
     parse_iso8601_string, resolve_slugify, slugify)
 
 
-VALID_PATHS = (
-    "/",
-    "/threads/",
-)
+VALID_PATHS = ("/", "/threads/", )
 
-INVALID_PATHS = (
-    "",
-    "somewhere/",
-)
+INVALID_PATHS = ("", "somewhere/", )
 
 
 class IsRequestToMisagoTests(TestCase):
@@ -34,14 +28,16 @@ class IsRequestToMisagoTests(TestCase):
             request.path_info = 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.path_info = path
             self.assertFalse(
                 is_request_to_misago(request),
-                '"%s" is overlapped by "%s"' % (path, misago_prefix))
+                '"%s" is overlapped by "%s"' % (path, misago_prefix)
+            )
 
 
 class SlugifyTests(TestCase):
@@ -65,8 +61,7 @@ class SlugifyTests(TestCase):
             resolve_slugify('misago.threads.invalidname')
         except ImportError as e:
             error_message = six.text_type(e)
-            self.assertEqual(
-                error_message, 'name invalidname not found in misago.threads module')
+            self.assertEqual(error_message, 'name invalidname not found in misago.threads module')
 
     def test_resolve_valid_name(self):
         """resolve_slugify resolves valid paths"""
@@ -75,7 +70,7 @@ class SlugifyTests(TestCase):
 
     def test_valid_slugify_output(self):
         """Misago's slugify correctly slugifies string"""
-        test_cases = (
+        test_cases = [
             ('Bob', 'bob'),
             ('Eric The Fish', 'eric-the-fish'),
             ('John   Snow', 'john-snow'),
@@ -83,7 +78,7 @@ class SlugifyTests(TestCase):
             ('An###ne', 'anne'),
             ('S**t', 'st'),
             ('Łók', 'lok'),
-        )
+        ]
 
         for original, slug in test_cases:
             self.assertEqual(slugify(original), slug)
@@ -92,56 +87,41 @@ class SlugifyTests(TestCase):
 class ParseIso8601StringTests(TestCase):
     def test_valid_input(self):
         """util parses iso 8601 strings"""
-        INPUTS = (
+        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',
-        )
+        ]
 
         for test_input in INPUTS:
             self.assertTrue(parse_iso8601_string(test_input))
 
     def test_invalid_input(self):
         """util throws ValueError on invalid input"""
-        INPUTS = (
+        INPUTS = [
             '',
             '2016-10-22',
             '2016-10-22T30:55:39.185085+11:00',
             '2016-10-22T20:55:39.18SSSSS5085Z',
-        )
+        ]
 
         for test_input in INPUTS:
             with self.assertRaises(ValueError):
                 self.assertTrue(parse_iso8601_string(test_input))
 
 
-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>'
-    ),
+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>'),
     (
         'http://misago-project.org/login/',
         '<p><a href="http://misago-project.org/login/">http://misago-project.org/login/</a></p>'
     ),
-)
+]
 
 
 class FormatPlaintextForHtmlTests(TestCase):
@@ -171,88 +151,118 @@ class MockRequest(object):
 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'
-        })
+        bad_request = MockRequest(
+            '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/'
-        })
+        bad_request = MockRequest(
+            '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/'
-        })
+        bad_request = MockRequest(
+            '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/'
-        })
+        ok_request = MockRequest(
+            'GET', {
+                'HTTP_REFERER': 'http://misago-project.org/',
+                'HTTP_HOST': 'misago-project.org/',
+            }
+        )
         self.assertEqual(clean_return_path(ok_request), '/')
 
-        ok_request = MockRequest('GET', {
-            'HTTP_REFERER': 'http://misago-project.org/login/',
-            'HTTP_HOST': 'misago-project.org/'
-        })
+        ok_request = MockRequest(
+            'GET', {
+                'HTTP_REFERER': 'http://misago-project.org/login/',
+                'HTTP_HOST': 'misago-project.org/',
+            }
+        )
         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/'})
+        bad_request = MockRequest(
+            '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/'})
+        ok_request = MockRequest(
+            'POST', {
+                'HTTP_REFERER': 'http://misago-project.org/',
+                'HTTP_HOST': 'misago-project.org/',
+            }, {
+                'return_path': '/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/'
-        })
+        ok_request = MockRequest(
+            '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/'
-        })
+        ok_request = MockRequest(
+            '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/'
-        })
+        ok_request = MockRequest(
+            '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/'
-        })
+        bad_request = MockRequest(
+            '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/'
-        })
+        bad_request = MockRequest(
+            '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/'
-        })
+        bad_request = MockRequest(
+            'GET', {
+                'HTTP_REFERER': 'http://misago-project.org/',
+                'HTTP_HOST': 'misago-project.org/assadsa/',
+            }
+        )
         self.assertFalse(is_referer_local(bad_request))

+ 7 - 5
misago/core/tests/test_validators.py

@@ -22,11 +22,13 @@ class ValidateSluggableTests(TestCase):
         with self.assertRaises(ValidationError):
             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')
+            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'
+            )
 
     def test_valid_input_validation(self):
         """valid values don't raise errors"""

+ 2 - 3
misago/core/testutils.py

@@ -5,9 +5,8 @@ from .cache import cache
 
 
 class MisagoTestCase(TestCase):
-    """
-    TestCase class that empties global state before and after each test
-    """
+    """TestCase class that empties global state before and after each test"""
+
     def clear_state(self):
         cache.clear()
         threadstore.clear()

+ 17 - 34
misago/core/utils.py

@@ -6,19 +6,11 @@ from django.http import Http404
 from django.urls import resolve, reverse
 from django.utils import html, timezone
 from django.utils.encoding import force_text
-from django.utils.translation import ugettext_lazy as _
-from django.utils.translation import ungettext_lazy
 
 
 MISAGO_SLUGIFY = getattr(settings, 'MISAGO_SLUGIFY', 'misago.core.slugify.default')
 
 
-def slugify(string):
-    string = six.text_type(string)
-    string = unidecode(string)
-    return django_slugify(string.replace('_', ' ').strip())
-
-
 def resolve_slugify(path):
     path_bits = path.split('.')
     module, name = '.'.join(path_bits[:-1]), path_bits[-1]
@@ -42,15 +34,11 @@ def encode_json_html(string):
     return string.replace('<', r'\u003C')
 
 
-"""
-Turn ISO 8601 string into datetime object
-"""
-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')
 
     for format in ISO8601_FORMATS:
@@ -79,19 +67,17 @@ def parse_iso8601_string(value):
     return timezone.make_aware(parsed_value, tz_correction)
 
 
-"""
-Mark request as having sensitive parameters
-We can't use decorator because of DRF uses custom HttpRequest
-that is incompatibile with Django's decorator
-"""
 def hide_post_parameters(request):
+    """
+    Mark request as having sensitive parameters
+    We can't use decorator because of DRF uses custom HttpRequest
+    that is incompatibile with Django's decorator
+    """
     request.sensitive_post_parameters = '__ALL__'
 
 
-"""
-Return path utility
-"""
 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:
         return _get_return_path_from_post(request)
     else:
@@ -130,9 +116,14 @@ def _get_return_path_from_referer(request):
         return None
 
 
-"""
-Utils for resolving requests destination
-"""
+def is_request_to_misago(request):
+    try:
+        return request._request_to_misago
+    except AttributeError:
+        request._request_to_misago = _is_request_path_under_misago(request)
+        return request._request_to_misago
+
+
 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')
@@ -143,14 +134,6 @@ def _is_request_path_under_misago(request):
     return path_info[:len(forum_index)] == forum_index
 
 
-def is_request_to_misago(request):
-    try:
-        return request._request_to_misago
-    except AttributeError:
-        request._request_to_misago = _is_request_path_under_misago(request)
-        return request._request_to_misago
-
-
 def is_referer_local(request):
     referer = request.META.get('HTTP_REFERER')
 

+ 1 - 2
misago/core/validators.py

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

+ 1 - 3
misago/core/views.py

@@ -4,11 +4,9 @@ from django.views import i18n
 from django.views.decorators.cache import cache_page
 from django.views.decorators.http import last_modified
 
-from . import momentjs
-
 
 def forum_index(request):
-    return # blow up as this view is normally non-reachable!
+    return  # blow up as this view is normally non-reachable!
 
 
 def home_redirect(*args, **kwargs):

+ 7 - 17
misago/datamover/attachments.py

@@ -5,7 +5,7 @@ import os
 from django.contrib.auth import get_user_model
 from django.core.files import File
 
-from misago.threads.models import Attachment, AttachmentType, Post, Thread
+from misago.threads.models import Attachment, AttachmentType, Post
 from misago.threads.serializers import AttachmentSerializer
 
 from . import OLD_FORUM, fetch_assoc, localise_datetime, movedids
@@ -13,21 +13,11 @@ from . import OLD_FORUM, fetch_assoc, localise_datetime, movedids
 
 UserModel = get_user_model()
 
-IMAGE_TYPES = (
-    'image/gif',
-    'image/jpeg',
-    'image/png',
-)
+IMAGE_TYPES = ('image/gif', 'image/jpeg', 'image/png', )
 
 
 def move_attachments(stdout, style):
-    query = '''
-        SELECT *
-        FROM
-            misago_attachment
-        ORDER BY
-            id
-    '''
+    query = 'SELECT * FROM misago_attachment ORDER BY id'
 
     posts = []
 
@@ -38,13 +28,13 @@ def move_attachments(stdout, style):
 
     for attachment in fetch_assoc(query):
         if attachment['content_type'] not in attachment_types:
-            stdout.write(style.WARNING(
-                "Skipping attachment: %s (invalid type)" % attachment['name']))
+            stdout.write(
+                style.WARNING("Skipping attachment: %s (invalid type)" % attachment['name'])
+            )
             continue
 
         if not attachment['post_id']:
-            stdout.write(style.WARNING(
-                "Skipping attachment: %s (orphaned)" % attachment['name']))
+            stdout.write(style.WARNING("Skipping attachment: %s (orphaned)" % attachment['name']))
             continue
 
         filetype = attachment_types[attachment['content_type']]

+ 5 - 5
misago/datamover/avatars.py

@@ -6,7 +6,7 @@ from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
 
 from misago.conf import settings
-from misago.users.avatars import dynamic, gallery, gravatar, store, uploaded
+from misago.users.avatars import dynamic, gravatar, store, uploaded
 
 from . import OLD_FORUM, fetch_assoc, movedids
 
@@ -26,15 +26,15 @@ def move_avatars(stdout, style):
                     gravatar.set_avatar(user)
                 except gravatar.GravatarError:
                     dynamic.set_avatar(user)
-                    print_warning(
-                        '%s: failed to download Gravatar' % user, stdout, style)
+                    print_warning('%s: failed to download Gravatar' % user, stdout, style)
             else:
                 try:
                     if not old_user['avatar_original'] or not old_user['avatar_crop']:
                         raise ValidationError("Invalid avatar upload data.")
 
                     image_path = os.path.join(
-                        OLD_FORUM['MEDIA'], 'avatars', old_user['avatar_original'])
+                        OLD_FORUM['MEDIA'], 'avatars', old_user['avatar_original']
+                    )
                     image = uploaded.validate_dimensions(image_path)
 
                     cleaned_crop = convert_crop(image, old_user)
@@ -64,5 +64,5 @@ def convert_crop(image, user):
             'x': x * zoom * -1,
             'y': y * zoom * -1,
         },
-        'zoom': zoom
+        'zoom': zoom,
     }

+ 4 - 8
misago/datamover/bans.py

@@ -5,11 +5,7 @@ from misago.users.models import Ban
 from . import fetch_assoc, localise_datetime
 
 
-CHECK_MAPPING = {
-  1: 0,
-  2: 1,
-  3: 2
-}
+CHECK_MAPPING = {1: 0, 2: 1, 3: 2}
 
 
 def move_bans():
@@ -20,7 +16,7 @@ def move_bans():
                 banned_value=ban['ban'],
                 user_message=ban['reason_user'],
                 staff_message=ban['reason_admin'],
-                expires_on=localise_datetime(ban['expires'])
+                expires_on=localise_datetime(ban['expires']),
             )
         else:
             Ban.objects.create(
@@ -28,7 +24,7 @@ def move_bans():
                 banned_value=ban['ban'],
                 user_message=ban['reason_user'],
                 staff_message=ban['reason_admin'],
-                expires_on=localise_datetime(ban['expires'])
+                expires_on=localise_datetime(ban['expires']),
             )
 
             Ban.objects.create(
@@ -36,7 +32,7 @@ def move_bans():
                 banned_value=ban['ban'],
                 user_message=ban['reason_user'],
                 staff_message=ban['reason_admin'],
-                expires_on=localise_datetime(ban['expires'])
+                expires_on=localise_datetime(ban['expires']),
             )
 
     Ban.objects.invalidate_cache()

+ 21 - 13
misago/datamover/categories.py

@@ -28,14 +28,18 @@ def move_categories(stdout, style):
             new_parent_id = movedids.get('category', forum['parent_id'])
             parent = Category.objects.get(pk=new_parent_id)
 
-        category = Category.objects.insert_node(Category(
-            name=forum['name'],
-            slug=forum['slug'],
-            description=forum['description'],
-            is_closed=forum['closed'],
-            prune_started_after=forum['prune_start'],
-            prune_replied_after=forum['prune_last'],
-        ), parent, save=True)
+        category = Category.objects.insert_node(
+            Category(
+                name=forum['name'],
+                slug=forum['slug'],
+                description=forum['description'],
+                is_closed=forum['closed'],
+                prune_started_after=forum['prune_start'],
+                prune_replied_after=forum['prune_last'],
+            ),
+            parent,
+            save=True,
+        )
 
         movedids.set('category', forum['id'], category.pk)
 
@@ -48,7 +52,7 @@ def move_categories(stdout, style):
         new_archive_pk = movedids.get('category', forum['pruned_archive_id'])
 
         Category.objects.filter(pk=new_category_pk).update(
-            archive_pruned_in=Category.objects.get(pk=new_archive_pk)
+            archive_pruned_in=Category.objects.get(pk=new_archive_pk),
         )
 
 
@@ -69,10 +73,14 @@ def move_labels():
             parent_id = movedids.get('category', parent_row['forum_id'])
             parent = Category.objects.get(pk=parent_id)
 
-            category = Category.objects.insert_node(Category(
-                name=label['name'],
-                slug=label['slug'],
-            ), parent, save=True)
+            category = Category.objects.insert_node(
+                Category(
+                    name=label['name'],
+                    slug=label['slug'],
+                ),
+                parent,
+                save=True,
+            )
 
             label_id = '%s-%s' % (label['id'], parent_row['forum_id'])
             movedids.set('label', label_id, category.pk)

+ 1 - 3
misago/datamover/db.py

@@ -2,9 +2,7 @@ from django.db import connections
 
 
 def fetch_assoc(query, *args):
-    """
-    Return all rows from a cursor as a dict
-    """
+    """return all rows from a cursor as a dict"""
     with connections['misago05'].cursor() as cursor:
         cursor.execute(query, *args)
 

+ 1 - 3
misago/datamover/management/commands/buildmovesindex.py

@@ -12,9 +12,7 @@ MAPPINGS = {
 
 
 class Command(BaseCommand):
-    help = (
-        "Builds moves index for redirects from old urls to new ones."
-    )
+    help = ("Builds moves index for redirects from old urls to new ones.")
 
     def handle(self, *args, **options):
         self.stdout.write("Building moves index...")

+ 2 - 4
misago/datamover/management/commands/movecategories.py

@@ -10,10 +10,8 @@ class Command(BaseCommand):
 
         self.start_timer()
         categories.move_categories(self.stdout, self.style)
-        self.stdout.write(
-            self.style.SUCCESS("Moved categories in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved categories in %s" % self.stop_timer()))
 
         self.start_timer()
         categories.move_labels()
-        self.stdout.write(
-            self.style.SUCCESS("Moved labels in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved labels in %s" % self.stop_timer()))

+ 1 - 2
misago/datamover/management/commands/movesettings.py

@@ -11,5 +11,4 @@ class Command(BaseCommand):
         self.start_timer()
         move_settings(self.stdout)
 
-        self.stdout.write(
-            self.style.SUCCESS("Moved settings in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved settings in %s" % self.stop_timer()))

+ 11 - 19
misago/datamover/management/commands/movethreads.py

@@ -10,50 +10,42 @@ class Command(BaseCommand):
 
         self.start_timer()
         threads.move_threads(self.stdout, self.style)
-        self.stdout.write(
-            self.style.SUCCESS("Moved threads in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved threads in %s" % self.stop_timer()))
 
         self.start_timer()
         threads.move_posts()
-        self.stdout.write(
-            self.style.SUCCESS("Moved posts in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved posts in %s" % self.stop_timer()))
 
         self.start_timer()
         threads.move_mentions()
-        self.stdout.write(
-            self.style.SUCCESS("Moved mentions in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved mentions in %s" % self.stop_timer()))
 
         self.start_timer()
         threads.move_edits()
-        self.stdout.write(
-            self.style.SUCCESS("Moved edits histories in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved edits histories in %s" % self.stop_timer()))
 
         self.start_timer()
         threads.move_likes()
-        self.stdout.write(
-            self.style.SUCCESS("Moved likes in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved likes in %s" % self.stop_timer()))
 
         self.start_timer()
         attachments.move_attachments(self.stdout, self.style)
-        self.stdout.write(
-            self.style.SUCCESS("Moved attachments in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved attachments in %s" % self.stop_timer()))
 
         self.start_timer()
         polls.move_polls()
-        self.stdout.write(
-            self.style.SUCCESS("Moved polls in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved polls in %s" % self.stop_timer()))
 
         self.start_timer()
         threads.move_participants()
         self.stdout.write(
-            self.style.SUCCESS("Moved threads participants in %s" % self.stop_timer()))
+            self.style.SUCCESS("Moved threads participants in %s" % self.stop_timer())
+        )
 
         self.start_timer()
         threads.clean_private_threads(self.stdout, self.style)
-        self.stdout.write(
-            self.style.SUCCESS("Cleaned private threads in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Cleaned private threads in %s" % self.stop_timer()))
 
         self.start_timer()
         markup.clean_posts()
-        self.stdout.write(
-            self.style.SUCCESS("Cleaned markup in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Cleaned markup in %s" % self.stop_timer()))

+ 7 - 16
misago/datamover/management/commands/moveusers.py

@@ -3,40 +3,31 @@ from misago.datamover.management.base import BaseCommand
 
 
 class Command(BaseCommand):
-    help = (
-        "Moves users, avatars, followers, blocks "
-        "and bans from Misago 0.5"
-    )
+    help = ("Moves users, avatars, followers, blocks and bans from Misago 0.5")
 
     def handle(self, *args, **options):
         self.stdout.write("Moving users from Misago 0.5:")
 
         self.start_timer()
         users.move_users(self.stdout, self.style)
-        self.stdout.write(
-            self.style.SUCCESS("Moved users in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved users in %s" % self.stop_timer()))
 
         self.start_timer()
         avatars.move_avatars(self.stdout, self.style)
-        self.stdout.write(
-            self.style.SUCCESS("Moved avatars in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved avatars in %s" % self.stop_timer()))
 
         self.start_timer()
         users.move_followers()
-        self.stdout.write(
-            self.style.SUCCESS("Moved followers in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved followers in %s" % self.stop_timer()))
 
         self.start_timer()
         users.move_blocks()
-        self.stdout.write(
-            self.style.SUCCESS("Moved blocks in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved blocks in %s" % self.stop_timer()))
 
         self.start_timer()
         users.move_namehistory()
-        self.stdout.write(
-            self.style.SUCCESS("Moved name history in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved name history in %s" % self.stop_timer()))
 
         self.start_timer()
         bans.move_bans()
-        self.stdout.write(
-            self.style.SUCCESS("Moved bans in %s" % self.stop_timer()))
+        self.stdout.write(self.style.SUCCESS("Moved bans in %s" % self.stop_timer()))

+ 3 - 5
misago/datamover/management/commands/runmigration.py

@@ -3,7 +3,7 @@ from django.core.management import call_command
 from misago.datamover.management.base import BaseCommand
 
 
-MOVE_COMMANDS = (
+MOVE_COMMANDS = [
     'movesettings',
     'moveusers',
     'movecategories',
@@ -15,13 +15,11 @@ MOVE_COMMANDS = (
     'invalidatebans',
     'populateonlinetracker',
     'synchronizeusers',
-)
+]
 
 
 class Command(BaseCommand):
-    help = (
-        "Executes complete migration from Misago 0.5 together with cleanups."
-    )
+    help = ("Executes complete migration from Misago 0.5 together with cleanups.")
 
     def handle(self, *args, **options):
         self.stdout.write("Running complete migration...")

+ 0 - 3
misago/datamover/markup/__init__.py

@@ -29,9 +29,6 @@ def clean_original(post):
     return post
 
 
-"""
-Fake request and user for parser
-"""
 class FakeUser(object):
     slug = get_random_string(40)
 

+ 1 - 1
misago/datamover/markup/quotes.py

@@ -11,7 +11,7 @@ def convert_quotes_to_bbcode(post):
     quote_author = None
     quote = []
 
-    for i, line in enumerate(post.splitlines() + ['']):
+    for line in post.splitlines() + ['']:
         if in_quote:
             if line.startswith('>'):
                 quote.append(line[1:].lstrip())

+ 11 - 4
misago/datamover/migrations/0001_initial.py

@@ -9,14 +9,17 @@ class Migration(migrations.Migration):
 
     initial = True
 
-    dependencies = [
-    ]
+    dependencies = []
 
     operations = [
         migrations.CreateModel(
             name='MovedId',
             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'
+                    )
+                ),
                 ('model', models.CharField(max_length=255)),
                 ('old_id', models.CharField(max_length=255)),
                 ('new_id', models.CharField(max_length=255)),
@@ -25,7 +28,11 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             name='OldIdRedirect',
             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'
+                    )
+                ),
                 ('model', models.PositiveIntegerField()),
                 ('old_id', models.PositiveIntegerField()),
                 ('new_id', models.PositiveIntegerField()),

+ 1 - 0
misago/datamover/movedids.py

@@ -5,6 +5,7 @@ def get(model, id):
     except MovedId.DoesNotExist:
         return None
 
+
 def set(model, old_id, new_id):
     from .models import MovedId
     MovedId.objects.create(

+ 1 - 1
misago/datamover/polls.py

@@ -27,7 +27,7 @@ def move_polls():
             choices.append({
                 'hash': get_random_string(12),
                 'label': choice['name'],
-                'votes': choice['votes']
+                'votes': choice['votes'],
             })
 
             choices_map[choice['id']] = choices[-1]['hash']

+ 10 - 6
misago/datamover/settings.py

@@ -12,6 +12,7 @@ def copy_value(setting):
         setting_obj.value = old_value
         setting_obj.save()
         return setting_obj
+
     return closure
 
 
@@ -21,6 +22,7 @@ def map_value(setting, translation):
         setting_obj.value = translation[old_value]
         setting_obj.save()
         return setting_obj
+
     return closure
 
 
@@ -44,12 +46,14 @@ SETTING_CONVERTER = {
     'thread_name_min': copy_value('thread_title_length_min'),
     'thread_name_max': copy_value('thread_title_length_max'),
     'post_length_min': copy_value('post_length_min'),
-    'account_activation': map_value('account_activation', {
-        'none': 'none',
-        'user': 'user',
-        'admin': 'admin',
-        'block': 'closed',
-    }),
+    'account_activation': map_value(
+        'account_activation', {
+            'none': 'none',
+            'user': 'user',
+            'admin': 'admin',
+            'block': 'closed',
+        }
+    ),
     'username_length_min': copy_value('username_length_min'),
     'username_length_max': copy_value('username_length_max'),
     'password_length': copy_value('password_length_min'),

+ 21 - 33
misago/datamover/threads.py

@@ -4,7 +4,6 @@ from django.contrib.auth import get_user_model
 from django.utils import timezone
 
 from misago.categories.models import Category
-from misago.threads.checksums import update_post_checksum
 from misago.threads.models import Post, PostEdit, PostLike, Thread, ThreadParticipant
 
 from . import fetch_assoc, localise_datetime, markup, movedids
@@ -18,13 +17,11 @@ def move_threads(stdout, style):
 
     for thread in fetch_assoc('SELECT * FROM misago_thread ORDER BY id'):
         if special_categories.get(thread['forum_id']) == 'reports':
-            stdout.write(style.WARNING(
-                "Skipping report: %s" % thread['name']))
+            stdout.write(style.WARNING("Skipping report: %s" % thread['name']))
             continue
 
         if not thread['start_post_id']:
-            stdout.write(style.ERROR(
-                "Corrupted thread: %s" % thread['name']))
+            stdout.write(style.ERROR("Corrupted thread: %s" % thread['name']))
             continue
 
         if special_categories.get(thread['forum_id']) == 'private_threads':
@@ -78,9 +75,7 @@ def move_posts():
         deleter_name = None
         deleter_slug = None
         if post['deleted']:
-            deleter = UserModel.objects.filter(
-                is_staff=True
-            ).order_by('id').last()
+            deleter = UserModel.objects.filter(is_staff=True).order_by('id').last()
 
             if deleter:
                 deleter_name = deleter.username
@@ -161,18 +156,20 @@ def move_post_edits(post, old_id):
         if changelog:
             changelog[-1].edited_to = markup.clean_original(edit['post_content'])
 
-        changelog.append(PostEdit(
-            category=post.category,
-            thread=post.thread,
-            post=post,
-            edited_on=localise_datetime(edit['date']),
-            editor=editor,
-            editor_name=edit['user_name'],
-            editor_slug=edit['user_slug'],
-            editor_ip=edit['ip'],
-            edited_from=markup.clean_original(edit['post_content']),
-            edited_to=markup.clean_original(post.original),
-        ))
+        changelog.append(
+            PostEdit(
+                category=post.category,
+                thread=post.thread,
+                post=post,
+                edited_on=localise_datetime(edit['date']),
+                editor=editor,
+                editor_name=edit['user_name'],
+                editor_slug=edit['user_slug'],
+                editor_ip=edit['ip'],
+                edited_from=markup.clean_original(edit['post_content']),
+                edited_to=markup.clean_original(post.original),
+            )
+        )
 
     if changelog:
         PostEdit.objects.bulk_create(changelog)
@@ -216,10 +213,7 @@ def move_likes():
     for post in Post.objects.filter(id__in=posts).iterator():
         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=['last_likes'])
 
 
@@ -233,19 +227,14 @@ def move_participants():
 
         starter = thread.post_set.order_by('id').first().poster
 
-        ThreadParticipant.objects.create(
-            thread=thread,
-            user=user,
-            is_owner=(user == starter)
-        )
+        ThreadParticipant.objects.create(thread=thread, user=user, is_owner=(user == starter))
 
 
 def clean_private_threads(stdout, style):
     category = Category.objects.private_threads()
 
     # prune threads without participants
-    participated_threads = ThreadParticipant.objects.values_list(
-        'thread_id', flat=True).distinct()
+    participated_threads = ThreadParticipant.objects.values_list('thread_id', flat=True).distinct()
     for thread in category.thread_set.exclude(pk__in=participated_threads):
         thread.delete()
 
@@ -257,8 +246,7 @@ def clean_private_threads(stdout, style):
             thread.save()
         elif participants_count == 0:
             thread.delete()
-            stdout.write(style.ERROR(
-                "Delete empty private thread: %s" % thread.title))
+            stdout.write(style.ERROR("Delete empty private thread: %s" % thread.title))
 
 
 def get_special_categories_dict():

+ 194 - 51
misago/datamover/urls.py

@@ -6,9 +6,18 @@ from . import views
 urlpatterns = [
     url(r'^category/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', views.category_redirect),
     url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/$', views.category_redirect),
-    url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/(?P<page>[1-9]([0-9]+)?)/$', views.category_redirect),
-    url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/prefix/(?P<prefix>(\w|-)+)/$', views.category_redirect),
-    url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/prefix/(?P<prefix>(\w|-)+)/(?P<page>[1-9]([0-9]+)?)/$', views.category_redirect),
+    url(
+        r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/(?P<page>[1-9]([0-9]+)?)/$',
+        views.category_redirect
+    ),
+    url(
+        r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/prefix/(?P<prefix>(\w|-)+)/$',
+        views.category_redirect
+    ),
+    url(
+        r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/prefix/(?P<prefix>(\w|-)+)/(?P<page>[1-9]([0-9]+)?)/$',
+        views.category_redirect
+    ),
     url(r'^forum/(?P<slug>(\w|-)+)-(?P<forum>\d+)/start/$', views.category_redirect),
 ]
 
@@ -20,7 +29,10 @@ urlpatterns += [
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/reply/$', views.thread_redirect),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/edit/$', views.thread_redirect),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/$', views.thread_redirect),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<page>[1-9]([0-9]+)?)/$', views.thread_redirect),
+    url(
+        r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<page>[1-9]([0-9]+)?)/$',
+        views.thread_redirect
+    ),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/last/$', views.thread_redirect),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/find-(?P<post>\d+)/$', views.thread_redirect),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/new/$', views.thread_redirect),
@@ -31,68 +43,199 @@ urlpatterns += [
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/email/$', views.thread_redirect),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/$', views.thread_redirect),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/email/$', views.thread_redirect),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/upvote/$', views.thread_redirect),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/downvote/$', views.thread_redirect),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/report/$', views.thread_redirect),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/show-report/$', views.thread_redirect),
+    url(
+        r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/upvote/$', views.thread_redirect
+    ),
+    url(
+        r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/downvote/$',
+        views.thread_redirect
+    ),
+    url(
+        r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/report/$', views.thread_redirect
+    ),
+    url(
+        r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/show-report/$',
+        views.thread_redirect
+    ),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/delete/$', views.thread_redirect),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/hide/$', views.thread_redirect),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/show/$', views.thread_redirect),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/delete/$', views.thread_redirect),
+    url(
+        r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/delete/$', views.thread_redirect
+    ),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/hide/$', views.thread_redirect),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/show/$', views.thread_redirect),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/checkpoint/(?P<checkpoint>\d+)/delete/$', views.thread_redirect),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/checkpoint/(?P<checkpoint>\d+)/hide/$', views.thread_redirect),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/checkpoint/(?P<checkpoint>\d+)/show/$', views.thread_redirect),
+    url(
+        r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/checkpoint/(?P<checkpoint>\d+)/delete/$',
+        views.thread_redirect
+    ),
+    url(
+        r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/checkpoint/(?P<checkpoint>\d+)/hide/$',
+        views.thread_redirect
+    ),
+    url(
+        r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/checkpoint/(?P<checkpoint>\d+)/show/$',
+        views.thread_redirect
+    ),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/info/$', views.thread_redirect),
     url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/votes/$', views.thread_redirect),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/$', views.thread_redirect),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/$', views.thread_redirect),
-    url(r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/revert/$', views.thread_redirect),
+    url(
+        r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/$',
+        views.thread_redirect
+    ),
+    url(
+        r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/$',
+        views.thread_redirect
+    ),
+    url(
+        r'^thread/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/revert/$',
+        views.thread_redirect
+    ),
 ]
 
 urlpatterns += [
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/edit/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/reply/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/vote/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/poll/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/reply/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/edit/$', views.private_thread_redirect),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/edit/$', views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/reply/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/vote/$', views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/poll/$', views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/reply/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/edit/$',
+        views.private_thread_redirect
+    ),
     url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<page>[1-9]([0-9]+)?)/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/last/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/find-(?P<post>\d+)/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/new/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/moderated/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/reported/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/show-hidden/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/email/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/email/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/upvote/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/downvote/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/report/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/show-report/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/delete/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/hide/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/show/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/delete/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/hide/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/show/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/checkpoint/(?P<checkpoint>\d+)/delete/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/checkpoint/(?P<checkpoint>\d+)/hide/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/checkpoint/(?P<checkpoint>\d+)/show/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/info/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/votes/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/$', views.private_thread_redirect),
-    url(r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/revert/$', views.private_thread_redirect),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<page>[1-9]([0-9]+)?)/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/last/$', views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/find-(?P<post>\d+)/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/new/$', views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/moderated/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/reported/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/show-hidden/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/watch/email/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/unwatch/email/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/upvote/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/downvote/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/report/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/show-report/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/delete/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/hide/$', views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/show/$', views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/delete/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/hide/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/show/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/checkpoint/(?P<checkpoint>\d+)/delete/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/checkpoint/(?P<checkpoint>\d+)/hide/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/checkpoint/(?P<checkpoint>\d+)/show/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/info/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/votes/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/$',
+        views.private_thread_redirect
+    ),
+    url(
+        r'^private-threads/(?P<slug>(\w|-)+)-(?P<thread>\d+)/(?P<post>\d+)/changelog/(?P<change>\d+)/revert/$',
+        views.private_thread_redirect
+    ),
 ]
 
 urlpatterns += [
     url(r'^users/(?P<username>\w+)-(?P<user>\d+)/', views.user_redirect),
     url(r'^users/(?P<username>\w+)-(?P<user>\d+)/(?P<page>\d+)/', views.user_redirect),
     url(r'^users/(?P<username>\w+)-(?P<user>\d+)/(?P<subpage>(\w|-)+)/', views.user_redirect),
-    url(r'^users/(?P<username>\w+)-(?P<user>\d+)/(?P<subpage>(\w|-)+)/(?P<page>\d+)/', views.user_redirect),
+    url(
+        r'^users/(?P<username>\w+)-(?P<user>\d+)/(?P<subpage>(\w|-)+)/(?P<page>\d+)/',
+        views.user_redirect
+    ),
 ]

+ 17 - 16
misago/datamover/users.py

@@ -12,12 +12,11 @@ from . import fetch_assoc, localise_datetime, movedids
 
 UserModel = get_user_model()
 
-
 PRIVATE_THREAD_INVITES = {
     0: 0,
     1: 0,
     2: 1,
-    3: 2
+    3: 2,
 }
 
 
@@ -31,15 +30,14 @@ def move_users(stdout, style):
         else:
             try:
                 new_user = UserModel.objects.create_user(
-                    user['username'], user['email'], 'Pass.123')
+                    user['username'], user['email'], 'Pass.123'
+                )
             except ValidationError:
                 new_name = ''.join([user['username'][:10], get_random_string(4)])
-                new_user = UserModel.objects.create_user(
-                    new_name, user['email'], 'Pass.123')
+                new_user = UserModel.objects.create_user(new_name, user['email'], 'Pass.123')
 
                 formats = (user['username'], new_name)
-                stdout.write(style.ERROR(
-                    '"%s" has been registered as "%s"' % formats))
+                stdout.write(style.ERROR('"%s" has been registered as "%s"' % formats))
 
             new_user.password = user['password']
 
@@ -64,7 +62,8 @@ def move_users(stdout, style):
             new_user.signature = user['signature']
             new_user.signature_parsed = user['signature_preparsed']
             new_user.signature_checksum = make_signature_checksum(
-                user['signature_preparsed'], new_user)
+                user['signature_preparsed'], new_user
+            )
 
         new_user.is_signature_locked = user['signature_ban']
         new_user.signature_lock_user_message = user['signature_ban_reason_user'] or None
@@ -125,13 +124,15 @@ def move_users_namehistory(user, old_id):
         if username_history:
             username_history[-1].new_username = namechange['old_username']
 
-        username_history.append(UsernameChange(
-            user=user,
-            changed_by=user,
-            changed_by_username=user.username,
-            changed_on=localise_datetime(namechange['date']),
-            new_username=user.username,
-            old_username=namechange['old_username']
-        ))
+        username_history.append(
+            UsernameChange(
+                user=user,
+                changed_by=user,
+                changed_by_username=user.username,
+                changed_on=localise_datetime(namechange['date']),
+                new_username=user.username,
+                old_username=namechange['old_username']
+            )
+        )
 
     UsernameChange.objects.bulk_create(username_history)

+ 1 - 2
misago/faker/management/commands/createfakebans.py

@@ -6,7 +6,6 @@ from faker import Factory
 
 from django.core.management.base import BaseCommand
 from django.utils import timezone
-from django.utils.six.moves import range
 
 from misago.core.management.progressbar import show_progress
 from misago.users.models import Ban
@@ -98,7 +97,7 @@ class Command(BaseCommand):
 
         created_count = 0
         show_progress(self, created_count, fake_bans_to_create)
-        for i in range(fake_bans_to_create):
+        for _ in range(fake_bans_to_create):
             ban = Ban(check_type=random.randint(Ban.USERNAME, Ban.IP))
             ban.banned_value = create_fake_test(fake, ban.check_type)
 

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

@@ -1,11 +1,9 @@
 import random
-import sys
 import time
 
 from faker import Factory
 
 from django.core.management.base import BaseCommand
-from django.utils.six.moves import range
 
 from misago.acl import version as acl_version
 from misago.categories.models import Category, RoleCategoryACL
@@ -21,7 +19,7 @@ class Command(BaseCommand):
             help="number of categories to create",
             nargs='?',
             type=int,
-            default=5
+            default=5,
         )
 
         parser.add_argument(
@@ -29,7 +27,7 @@ class Command(BaseCommand):
             help="min. level of created categories",
             nargs='?',
             type=int,
-            default=0
+            default=0,
         )
 
     def handle(self, *args, **options):
@@ -67,25 +65,27 @@ class Command(BaseCommand):
                 else:
                     new_category.description = fake.paragraph()
 
-            new_category.insert_at(parent,
+            new_category.insert_at(
+                parent,
                 position='last-child',
                 save=True,
             )
 
             copied_acls = []
             for acl in copy_acl_from.category_role_set.all():
-                copied_acls.append(RoleCategoryACL(
-                    role_id=acl.role_id,
-                    category=new_category,
-                    category_role_id=acl.category_role_id,
-                ))
+                copied_acls.append(
+                    RoleCategoryACL(
+                        role_id=acl.role_id,
+                        category=new_category,
+                        category_role_id=acl.category_role_id,
+                    )
+                )
 
             if copied_acls:
                 RoleCategoryACL.objects.bulk_create(copied_acls)
 
             created_count += 1
-            show_progress(
-                self, created_count, items_to_create, start_time)
+            show_progress(self, created_count, items_to_create, start_time)
 
         acl_version.invalidate()
 

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

@@ -32,7 +32,7 @@ class Command(BaseCommand):
             if random.randint(1, 100) > 10:
                 processed_count += 1
                 show_progress(self, processed_count, total_users)
-                continue # 10% active users
+                continue  # 10% active users
 
             users_to_add = random.randint(1, total_users / 5)
             random_queryset = UserModel.objects.exclude(id=user.id).order_by('?')

+ 8 - 14
misago/faker/management/commands/createfakethreads.py

@@ -9,7 +9,6 @@ from django.contrib.auth import get_user_model
 from django.core.management.base import BaseCommand
 from django.db.transaction import atomic
 from django.utils import timezone
-from django.utils.six.moves import range
 
 from misago.categories.models import Category
 from misago.core.management.progressbar import show_progress
@@ -20,10 +19,8 @@ from misago.threads.models import Post, Thread
 
 PLACEKITTEN_URL = 'https://placekitten.com/g/%s/%s'
 
-
 UserModel = get_user_model()
 
-
 corpus = EnglishCorpus()
 corpus_short = EnglishCorpus(max_length=150)
 
@@ -37,7 +34,7 @@ class Command(BaseCommand):
             help="number of threads to create",
             nargs='?',
             type=int,
-            default=5
+            default=5,
         )
 
     def handle(self, *args, **options):
@@ -47,8 +44,6 @@ class Command(BaseCommand):
 
         fake = Factory.create()
 
-        total_users = UserModel.objects.count()
-
         self.stdout.write('Creating fake threads...\n')
 
         message = '\nSuccessfully created %s fake threads in %s'
@@ -78,7 +73,7 @@ class Command(BaseCommand):
                     replies=0,
                     is_unapproved=thread_is_unapproved,
                     is_hidden=thread_is_hidden,
-                    is_closed=thread_is_closed
+                    is_closed=thread_is_closed,
                 )
                 thread.set_title(corpus_short.random_choice())
                 thread.save()
@@ -94,7 +89,7 @@ class Command(BaseCommand):
                     original=original,
                     parsed=parsed,
                     posted_on=datetime,
-                    updated_on=datetime
+                    updated_on=datetime,
                 )
                 update_post_checksum(post)
                 post.save(update_fields=['checksum'])
@@ -115,7 +110,7 @@ class Command(BaseCommand):
                 else:
                     thread_replies = random.randint(0, 10)
 
-                for x in range(thread_replies):
+                for _ in range(thread_replies):
                     datetime = timezone.now()
                     user = UserModel.objects.order_by('?')[:1][0]
 
@@ -133,7 +128,7 @@ class Command(BaseCommand):
                         parsed=parsed,
                         is_unapproved=is_unapproved,
                         posted_on=datetime,
-                        updated_on=datetime
+                        updated_on=datetime,
                     )
 
                     if not is_unapproved:
@@ -163,12 +158,11 @@ class Command(BaseCommand):
                 thread.save()
 
                 created_threads += 1
-                show_progress(
-                    self, created_threads, items_to_create, start_time)
+                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)
-        for i in range(0, pinned_threads):
+        for _ in range(0, pinned_threads):
             thread = Thread.objects.order_by('?')[:1][0]
             if random.randint(0, 100) > 75:
                 thread.weight = 2
@@ -193,7 +187,7 @@ class Command(BaseCommand):
         else:
             paragraphs_to_make = random.randint(1, 5)
 
-        for i in range(paragraphs_to_make):
+        for _ in range(paragraphs_to_make):
             if random.randint(0, 100) > 95:
                 cat_width = random.randint(1, 16) * random.choice([100, 90, 80])
                 cat_height = random.randint(1, 12) * random.choice([100, 90, 80])

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

@@ -1,5 +1,4 @@
 import random
-import sys
 import time
 
 from faker import Factory
@@ -8,7 +7,6 @@ from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
 from django.core.management.base import BaseCommand
 from django.db import IntegrityError
-from django.utils.six.moves import range
 
 from misago.core.management.progressbar import show_progress
 from misago.users.avatars import dynamic, gallery
@@ -27,7 +25,7 @@ class Command(BaseCommand):
             help="number of users to create",
             nargs='?',
             type=int,
-            default=5
+            default=5,
         )
 
     def handle(self, *args, **options):
@@ -48,13 +46,13 @@ class Command(BaseCommand):
 
         while created_count < items_to_create:
             try:
-                kwargs = {
-                    'rank': random.choice(ranks),
-                }
-
                 user = UserModel.objects.create_user(
-                    fake.first_name(), fake.email(), 'pass123',
-                    set_default_avatar=False, **kwargs)
+                    fake.first_name(),
+                    fake.email(),
+                    'pass123',
+                    set_default_avatar=False,
+                    rank=random.choice(ranks),
+                )
 
                 if random.randint(0, 100) > 90:
                     dynamic.set_avatar(user)
@@ -65,8 +63,7 @@ class Command(BaseCommand):
                 pass
             else:
                 created_count += 1
-                show_progress(
-                    self, created_count, items_to_create, start_time)
+                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))

+ 1 - 2
misago/legal/context_processors.py

@@ -9,8 +9,7 @@ def legal_links(request):
     if settings.terms_of_service_link:
         legal_context['TERMS_OF_SERVICE_URL'] = settings.terms_of_service_link
     elif settings.terms_of_service:
-        legal_context['TERMS_OF_SERVICE_URL'] = reverse(
-            'misago:terms-of-service')
+        legal_context['TERMS_OF_SERVICE_URL'] = reverse('misago:terms-of-service')
 
     if settings.privacy_policy_link:
         legal_context['PRIVACY_POLICY_URL'] = settings.privacy_policy_link

+ 97 - 97
misago/legal/migrations/0001_initial.py

@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-from django.db import migrations, models
+from django.db import migrations
 
 from misago.conf.migrationutils import migrate_settings_group
 
@@ -10,109 +10,109 @@ _ = lambda x: x
 
 
 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': (
-            {
-                '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,
+    migrate_settings_group(
+        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,
                 },
-                '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,
+                {
+                    '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,
                 },
-                'is_public': True,
-            },
-            {
-                '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,
+                {
+                    '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,
                 },
-                '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,
+                {
+                    '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,
                 },
-                '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,
+                {
+                    '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,
                 },
-                'is_public': True,
-            },
-            {
-                '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,
+                {
+                    '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,
                 },
-                '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
+                {
+                    'setting': 'forum_footnote',
+                    'name': _("Footnote"),
+                    'description': _("Short message displayed in forum footer."),
+                    'legend': _("Forum footer"),
+                    'field_extra': {
+                        'max_length': 300,
+                    },
+                    'is_public': True,
                 },
-                'is_public': True,
-            },
-        )
-    })
+            ],
+        }
+    )
 
 
 class Migration(migrations.Migration):

+ 10 - 8
misago/legal/tests.py

@@ -57,7 +57,7 @@ class PrivacyPolicyTests(TestCase):
         context_dict = legal_links(MockRequest())
 
         self.assertEqual(context_dict, {
-            'PRIVACY_POLICY_URL': reverse('misago:privacy-policy')
+            'PRIVACY_POLICY_URL': reverse('misago:privacy-policy'),
         })
 
     def test_context_processor_remote_policy(self):
@@ -66,7 +66,7 @@ class PrivacyPolicyTests(TestCase):
         context_dict = legal_links(MockRequest())
 
         self.assertEqual(context_dict, {
-            'PRIVACY_POLICY_URL': 'http://test.com'
+            'PRIVACY_POLICY_URL': 'http://test.com',
         })
 
         # set misago view too
@@ -74,7 +74,7 @@ class PrivacyPolicyTests(TestCase):
         context_dict = legal_links(MockRequest())
 
         self.assertEqual(context_dict, {
-            'PRIVACY_POLICY_URL': 'http://test.com'
+            'PRIVACY_POLICY_URL': 'http://test.com',
         })
 
 
@@ -123,9 +123,11 @@ class TermsOfServiceTests(TestCase):
         settings.override_setting('terms_of_service', 'Lorem ipsum')
         context_dict = legal_links(MockRequest())
 
-        self.assertEqual(context_dict, {
-            'TERMS_OF_SERVICE_URL': reverse('misago:terms-of-service')
-        })
+        self.assertEqual(
+            context_dict, {
+                'TERMS_OF_SERVICE_URL': reverse('misago:terms-of-service'),
+            }
+        )
 
     def test_context_processor_remote_tos(self):
         """context processor has TOS link to remote url"""
@@ -133,7 +135,7 @@ class TermsOfServiceTests(TestCase):
         context_dict = legal_links(MockRequest())
 
         self.assertEqual(context_dict, {
-            'TERMS_OF_SERVICE_URL': 'http://test.com'
+            'TERMS_OF_SERVICE_URL': 'http://test.com',
         })
 
         # set misago view too
@@ -141,5 +143,5 @@ class TermsOfServiceTests(TestCase):
         context_dict = legal_links(MockRequest())
 
         self.assertEqual(context_dict, {
-            'TERMS_OF_SERVICE_URL': 'http://test.com'
+            'TERMS_OF_SERVICE_URL': 'http://test.com',
         })

+ 8 - 4
misago/legal/views.py

@@ -40,12 +40,14 @@ def privacy_policy(request):
 
     parsed_content = get_parsed_content(request, 'privacy_policy')
 
-    return render(request, 'misago/privacy_policy.html', {
+    return render(
+        request, 'misago/privacy_policy.html', {
             'id': 'privacy-policy',
             'title': settings.privacy_policy_title or _("Privacy policy"),
             'link': settings.privacy_policy_link,
             'body': parsed_content,
-        })
+        }
+    )
 
 
 def terms_of_service(request):
@@ -57,9 +59,11 @@ def terms_of_service(request):
 
     parsed_content = get_parsed_content(request, 'terms_of_service')
 
-    return render(request, 'misago/terms_of_service.html', {
+    return render(
+        request, 'misago/terms_of_service.html', {
             'id': 'terms-of-service',
             'title': settings.terms_of_service_title or _("Terms of service"),
             'link': settings.terms_of_service_link,
             'body': parsed_content,
-        })
+        }
+    )

+ 0 - 1
misago/markup/__init__.py

@@ -2,5 +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'

+ 2 - 7
misago/markup/api.py

@@ -4,7 +4,6 @@ from rest_framework.response import Response
 
 from django.core.exceptions import ValidationError
 from django.utils import six
-from django.utils.translation import ugettext as _
 
 from misago.threads.validators import validate_post
 
@@ -18,13 +17,9 @@ def parse_markup(request):
     try:
         validate_post(post)
     except ValidationError 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)
 
     parsed = common_flavour(request, request.user, post, force_shva=True)['parsed_text']
     finalised = finalise_markup(parsed)
 
-    return Response({
-        'parsed': finalised
-    })
+    return Response({'parsed': finalised})

+ 18 - 11
misago/markup/bbcode/blocks.py

@@ -27,17 +27,22 @@ class QuoteExtension(markdown.Extension):
         md.registerExtension(self)
 
         md.preprocessors.add('misago_bbcode_quote', QuotePreprocessor(md), '_end')
-        md.parser.blockprocessors.add('misago_bbcode_quote', QuoteBlockProcessor(md.parser), '>code')
+        md.parser.blockprocessors.add(
+            'misago_bbcode_quote', QuoteBlockProcessor(md.parser), '>code'
+        )
 
 
 class QuotePreprocessor(Preprocessor):
-    QUOTE_BLOCK_RE = re.compile(r'''
+    QUOTE_BLOCK_RE = re.compile(
+        r'''
 \[quote\](?P<text>.*?)\[/quote\]
-'''.strip(), re.IGNORECASE | re.MULTILINE | re.DOTALL);
-    QUOTE_BLOCK_TITLE_RE = re.compile(r'''
+'''.strip(), re.IGNORECASE | re.MULTILINE | re.DOTALL
+    )
+    QUOTE_BLOCK_TITLE_RE = re.compile(
+        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)
@@ -106,12 +111,14 @@ class CodeBlockExtension(markdown.Extension):
     def extendMarkdown(self, md):
         md.registerExtension(self)
 
-        md.preprocessors.add('misago_code_bbcode',
-                             CodeBlockPreprocessor(md),
-                             ">normalize_whitespace")
+        md.preprocessors.add(
+            'misago_code_bbcode', CodeBlockPreprocessor(md), ">normalize_whitespace"
+        )
 
 
 class CodeBlockPreprocessor(FencedBlockPreprocessor):
-        FENCED_BLOCK_RE = re.compile(r'''
+    FENCED_BLOCK_RE = re.compile(
+        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
+    )

+ 4 - 5
misago/markup/bbcode/inline.py

@@ -10,10 +10,12 @@ class SimpleBBCodePattern(SimpleTagPattern):
     """
     Case insensitive simple BBCode
     """
+
     def __init__(self, bbcode, tag=None):
         self.pattern = r'(\[%s\](.*?)\[/%s\])' % (bbcode, bbcode)
-        self.compiled_re = re.compile("^(.*?)%s(.*?)$" % self.pattern,
-                                      re.DOTALL | re.UNICODE | re.IGNORECASE)
+        self.compiled_re = re.compile(
+            "^(.*?)%s(.*?)$" % self.pattern, re.DOTALL | re.UNICODE | re.IGNORECASE
+        )
 
         # Api for Markdown to pass safe_mode into instance
         self.safe_mode = False
@@ -22,9 +24,6 @@ class SimpleBBCodePattern(SimpleTagPattern):
         self.tag = tag or bbcode.lower()
 
 
-"""
-Register basic BBCodes
-"""
 bold = SimpleBBCodePattern('b')
 italics = SimpleBBCodePattern('i')
 underline = SimpleBBCodePattern('u')

+ 1 - 1
misago/markup/context_processors.py

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

+ 4 - 2
misago/markup/finalise.py

@@ -5,9 +5,11 @@ import re
 from django.utils.translation import gettext as _
 
 
-HEADER_RE = re.compile(r'''
+HEADER_RE = re.compile(
+    r'''
 <div class="quote-heading">(?P<title>.*?)</div>
-'''.strip(), re.IGNORECASE | re.MULTILINE | re.DOTALL);
+'''.strip(), re.IGNORECASE | re.MULTILINE | re.DOTALL
+)
 
 
 def finalise_markup(post):

+ 25 - 7
misago/markup/flavours.py

@@ -12,7 +12,13 @@ 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)
+    return parse(
+        text,
+        request,
+        poster,
+        allow_mentions=allow_mentions,
+        force_shva=force_shva,
+    )
 
 
 def limited(request, text):
@@ -24,16 +30,28 @@ def limited(request, text):
 
     Returns parsed text
     """
-    result = parse(text, request, request.user, allow_mentions=False,
-                   allow_links=True, allow_images=False, allow_blocks=False)
+    result = parse(
+        text,
+        request,
+        request.user,
+        allow_mentions=False,
+        allow_links=True,
+        allow_images=False,
+        allow_blocks=False,
+    )
 
     return result['parsed_text']
 
 
 def signature(request, owner, text):
-    result = parse(text, request, owner, allow_mentions=False,
-                   allow_blocks=owner.acl_cache['allow_signature_blocks'],
-                   allow_links=owner.acl_cache['allow_signature_links'],
-                   allow_images=owner.acl_cache['allow_signature_images'])
+    result = parse(
+        text,
+        request,
+        owner,
+        allow_mentions=False,
+        allow_blocks=owner.acl_cache['allow_signature_blocks'],
+        allow_links=owner.acl_cache['allow_signature_links'],
+        allow_images=owner.acl_cache['allow_signature_images'],
+    )
 
     return result['parsed_text']

+ 2 - 3
misago/markup/md/shortimgs.py

@@ -3,14 +3,13 @@ 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):

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

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

+ 1 - 1
misago/markup/mentions.py

@@ -1,6 +1,6 @@
 import re
 
-from bs4 import BeautifulSoup, NavigableString
+from bs4 import BeautifulSoup
 
 from django.contrib.auth import get_user_model
 from django.utils import six

+ 14 - 9
misago/markup/parser.py

@@ -22,8 +22,17 @@ from .pipeline import pipeline
 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):
+def parse(
+        text,
+        request,
+        poster,
+        allow_mentions=True,
+        allow_links=True,
+        allow_images=True,
+        allow_blocks=True,
+        force_shva=False,
+        minify=True
+):
     """
     Message parser
 
@@ -47,7 +56,7 @@ def parse(text, request, poster, allow_mentions=True, allow_links=True,
         'mentions': [],
         'images': [],
         'outgoing_links': [],
-        'inside_links': []
+        'inside_links': [],
     }
 
     # Parse text
@@ -73,9 +82,7 @@ def parse(text, request, poster, allow_mentions=True, allow_links=True,
 
 
 def md_factory(allow_links=True, allow_images=True, allow_blocks=True):
-    """
-    Create and configure markdown object
-    """
+    """creates and configures markdown object"""
     md = markdown.Markdown(safe_mode='escape', extensions=['nl2br'])
 
     # Remove references
@@ -133,8 +140,7 @@ def md_factory(allow_links=True, allow_images=True, allow_blocks=True):
 
 
 def linkify_paragraphs(result):
-    result['parsed_text'] = bleach.linkify(
-        result['parsed_text'], skip_pre=True, parse_email=True)
+    result['parsed_text'] = bleach.linkify(result['parsed_text'], skip_pre=True, parse_email=True)
 
     # dirty fix for
     if '<code>' in result['parsed_text'] and '<a' in result['parsed_text']:
@@ -150,7 +156,6 @@ def linkify_paragraphs(result):
 
 def clean_links(request, result, force_shva=False):
     host = request.get_host()
-    site_address = '%s://%s' % (request.scheme, request.get_host())
 
     soup = BeautifulSoup(result['parsed_text'], 'html5lib')
     for link in soup.find_all('a'):

+ 3 - 3
misago/markup/pipeline.py

@@ -8,9 +8,8 @@ from misago.conf import settings
 
 
 class MarkupPipeline(object):
-    """
-    Small framework for extending parser
-    """
+    """small framework for extending parser"""
+
     def extend_markdown(self, md):
         for extension in settings.MISAGO_MARKUP_EXTENSIONS:
             module = import_module(extension)
@@ -31,4 +30,5 @@ class MarkupPipeline(object):
         result['parsed_text'] = souped_text.strip()
         return result
 
+
 pipeline = MarkupPipeline()

+ 4 - 0
misago/markup/templatetags/misago_editor.py

@@ -15,9 +15,13 @@ def _render_editor_template(context, editor, tpl):
 
 def editor_body(context, editor):
     return _render_editor_template(context, editor, editor.body_template)
+
+
 register.simple_tag(takes_context=True)(editor_body)
 
 
 def editor_js(context, editor):
     return _render_editor_template(context, editor, editor.js_template)
+
+
 register.simple_tag(takes_context=True)(editor_js)

+ 8 - 12
misago/markup/tests/test_api.py

@@ -14,7 +14,7 @@ class ParseMarkupApiTests(AuthenticatedUserTestCase):
 
     def test_is_anonymous(self):
         """api requires authentication"""
-        self.logout_user();
+        self.logout_user()
 
         response = self.client.post(self.api_link)
         self.assertContains(response, "This action is not available to guests.", status_code=403)
@@ -26,23 +26,19 @@ class ParseMarkupApiTests(AuthenticatedUserTestCase):
 
     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.assertContains(response, "You have to enter a message.", status_code=400)
 
     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.assertContains(
-            response, "Posted message should be at least 5 characters long (it has 3).",
-            status_code=400)
+            response,
+            "Posted message should be at least 5 characters long (it has 3).",
+            status_code=400
+        )
 
     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.assertContains(response, "<p>Lorem ipsum dolor met!</p>")

+ 2 - 4
misago/markup/tests/test_checksums.py

@@ -10,7 +10,5 @@ class ChecksumsTests(TestCase):
 
         checksum = checksums.make_checksum(fake_message, [post_pk])
 
-        self.assertTrue(
-            checksums.is_checksum_valid(fake_message, checksum, [post_pk]))
-        self.assertFalse(
-            checksums.is_checksum_valid(fake_message, checksum, [3]))
+        self.assertTrue(checksums.is_checksum_valid(fake_message, checksum, [post_pk]))
+        self.assertFalse(checksums.is_checksum_valid(fake_message, checksum, [3]))

+ 17 - 36
misago/markup/tests/test_mentions.py

@@ -10,19 +10,10 @@ class MockRequest(object):
 class MentionsTests(AuthenticatedUserTestCase):
     def test_single_mention(self):
         """markup extension parses single mention"""
-        TEST_CASES = (
-            (
-                '<p>Hello, @{}!</p>',
-                '<p>Hello, <a href="{}">@{}</a>!</p>'
-            ),
-            (
-                '<h1>Hello, @{}!</h1>',
-                '<h1>Hello, <a href="{}">@{}</a>!</h1>'
-            ),
-            (
-                '<div>Hello, @{}!</div>',
-                '<div>Hello, <a href="{}">@{}</a>!</div>'
-            ),
+        TEST_CASES = [
+            ('<p>Hello, @{}!</p>', '<p>Hello, <a href="{}">@{}</a>!</p>'),
+            ('<h1>Hello, @{}!</h1>', '<h1>Hello, <a href="{}">@{}</a>!</h1>'),
+            ('<div>Hello, @{}!</div>', '<div>Hello, <a href="{}">@{}</a>!</div>'),
             (
                 '<h1>Hello, <strong>@{}!</strong></h1>',
                 '<h1>Hello, <strong><a href="{}">@{}</a>!</strong></h1>'
@@ -31,13 +22,10 @@ class MentionsTests(AuthenticatedUserTestCase):
                 '<h1>Hello, <strong>@{}</strong>!</h1>',
                 '<h1>Hello, <strong><a href="{}">@{}</a></strong>!</h1>'
             ),
-        )
+        ]
 
         for before, after in TEST_CASES:
-            result = {
-                'parsed_text': before.format(self.user.username),
-                'mentions': []
-            }
+            result = {'parsed_text': before.format(self.user.username), 'mentions': []}
 
             add_mentions(MockRequest(self.user), result)
 
@@ -47,18 +35,15 @@ class MentionsTests(AuthenticatedUserTestCase):
 
     def test_invalid_mentions(self):
         """markup extension leaves invalid mentions alone"""
-        TEST_CASES = (
+        TEST_CASES = [
             '<p>Hello, Bob!</p>',
             '<p>Hello, @Bob!</p>',
             '<p>Hello, <a href="/">@{}</a>!</p>'.format(self.user.username),
             '<p>Hello, <a href="/"><b>@{}</b></a>!</p>'.format(self.user.username),
-        )
+        ]
 
         for markup in TEST_CASES:
-            result = {
-                'parsed_text': markup,
-                'mentions': []
-            }
+            result = {'parsed_text': markup, 'mentions': []}
 
             add_mentions(MockRequest(self.user), result)
 
@@ -69,13 +54,11 @@ class MentionsTests(AuthenticatedUserTestCase):
         """markup extension handles multiple mentions"""
         before = '<p>Hello @{0} and @{0}, how is it going?</p>'.format(self.user.username)
 
-        formats = (self.user.get_absolute_url(), self.user.username)
-        after = '<p>Hello <a href="{0}">@{1}</a> and <a href="{0}">@{1}</a>, how is it going?</p>'.format(*formats)
+        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)
@@ -85,13 +68,11 @@ class MentionsTests(AuthenticatedUserTestCase):
         """markup extension handles mentions across document"""
         before = '<p>Hello @{0}</p><p>@{0}, how is it going?</p>'.format(self.user.username)
 
-        formats = (self.user.get_absolute_url(), self.user.username)
-        after = '<p>Hello <a href="{0}">@{1}</a></p><p><a href="{0}">@{1}</a>, how is it going?</p>'.format(*formats)
+        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)

+ 1 - 3
misago/markup/urls.py

@@ -3,6 +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/apps.py

@@ -7,4 +7,4 @@ class MisagoReadTrackerConfig(AppConfig):
     verbose_name = "Misago Read Tracker"
 
     def ready(self):
-        from . import signals
+        from . import signals as _

+ 9 - 10
misago/readtracker/categoriestracker.py

@@ -24,9 +24,7 @@ def make_read_aware(user, categories):
             categories_dict[category.pk] = category
 
     if categories_dict:
-        categories_records = user.categoryread_set.filter(
-            category__in=categories_dict.keys()
-        )
+        categories_records = user.categoryread_set.filter(category__in=categories_dict.keys())
 
         for record in categories_records:
             category = categories_dict[record.category_id]
@@ -61,14 +59,13 @@ def sync_record(user, category):
         category_record = None
 
     all_threads = category.thread_set.filter(last_post_on__gt=cutoff_date)
-    all_threads_count = exclude_invisible_threads(
-        user, [category], all_threads).count()
+    all_threads_count = exclude_invisible_threads(user, [category], all_threads).count()
 
     read_threads_count = user.threadread_set.filter(
         category=category,
         thread__in=all_threads,
         last_read_on__gt=cutoff_date,
-        thread__last_post_on__lte=F("last_read_on")
+        thread__last_post_on__lte=F("last_read_on"),
     ).count()
 
     category_is_read = read_threads_count == all_threads_count
@@ -88,8 +85,7 @@ def sync_record(user, category):
         else:
             last_read_on = cutoff_date
         category_record = user.categoryread_set.create(
-            category=category,
-            last_read_on=last_read_on
+            category=category, last_read_on=last_read_on
         )
 
 
@@ -97,8 +93,11 @@ def read_category(user, category):
     categories = [category.pk]
     if not category.is_leaf_node():
         categories += category.get_descendants().filter(
-            id__in=user.acl_cache['visible_categories']
-        ).values_list('id', flat=True)
+            id__in=user.acl_cache['visible_categories'],
+        ).values_list(
+            'id',
+            flat=True,
+        )
 
     user.categoryread_set.filter(category_id__in=categories).delete()
     user.threadread_set.filter(category_id__in=categories).delete()

+ 14 - 8
misago/readtracker/migrations/0001_initial.py

@@ -16,26 +16,32 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             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()),
                 ('category', models.ForeignKey(to='misago_categories.Category')),
                 ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
         migrations.CreateModel(
             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()),
                 ('category', models.ForeignKey(to='misago_categories.Category')),
                 ('thread', models.ForeignKey(to='misago_threads.Thread')),
                 ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
     ]

+ 0 - 3
misago/readtracker/models.py

@@ -1,8 +1,5 @@
-from datetime import timedelta
-
 from django.conf import settings
 from django.db import models
-from django.utils import timezone
 
 
 class CategoryRead(models.Model):

+ 0 - 3
misago/readtracker/signals.py

@@ -11,9 +11,6 @@ thread_tracked = Signal(providing_args=["thread"])
 thread_read = Signal(providing_args=["thread"])
 
 
-"""
-Signal handlers
-"""
 @receiver(delete_category_content)
 def delete_category_threads(sender, **kwargs):
     sender.categoryread_set.all().delete()

+ 2 - 4
misago/readtracker/tests/test_dates.py

@@ -28,9 +28,7 @@ class ReadTrackerDatesTests(TestCase):
         past_date = timezone.now() + timedelta(minutes=10)
 
         category_cutoff = timezone.now() + timedelta(minutes=20)
-        self.assertFalse(
-            is_date_tracked(past_date, MockUser(), category_cutoff))
+        self.assertFalse(is_date_tracked(past_date, MockUser(), category_cutoff))
 
         category_cutoff = timezone.now() - timedelta(minutes=20)
-        self.assertTrue(
-            is_date_tracked(past_date, MockUser(), category_cutoff))
+        self.assertTrue(is_date_tracked(past_date, MockUser(), category_cutoff))

+ 5 - 5
misago/readtracker/tests/test_readtracker.py

@@ -19,14 +19,13 @@ class ReadTrackerTests(TestCase):
         self.categories = list(Category.objects.all_categories()[:1])
         self.category = self.categories[0]
 
-        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.anon = AnonymousUser()
 
     def post_thread(self, datetime):
         return testutils.post_thread(
             category=self.category,
-            started_on=datetime
+            started_on=datetime,
         )
 
 
@@ -93,7 +92,8 @@ class CategoriesTrackerTests(ReadTrackerTests):
         categoriestracker.make_read_aware(self.user, self.categories)
         self.assertTrue(self.category.is_read)
 
-        thread = self.post_thread(self.user.joined_on + timedelta(days=1))
+        self.post_thread(self.user.joined_on + timedelta(days=1))
+
         categoriestracker.sync_record(self.user, self.category)
         categoriestracker.make_read_aware(self.user, self.categories)
         self.assertFalse(self.category.is_read)
@@ -209,7 +209,7 @@ class ThreadsTrackerTests(ReadTrackerTests):
             thread=self.thread,
             is_hidden=is_hidden,
             is_unapproved=is_unapproved,
-            posted_on=timezone.now()
+            posted_on=timezone.now(),
         )
         return self.post
 

+ 6 - 5
misago/readtracker/threadstracker.py

@@ -88,8 +88,7 @@ def make_thread_read_aware(user, thread):
         thread.is_new = True
 
         try:
-            category_record = user.categoryread_set.get(
-                category_id=thread.category_id)
+            category_record = user.categoryread_set.get(category_id=thread.category_id)
             thread.last_read_on = category_record.last_read_on
 
             if thread.last_post_on > category_record.last_read_on:
@@ -113,8 +112,10 @@ def make_posts_read_aware(user, thread, posts):
     try:
         is_thread_read = thread.is_read
     except AttributeError:
-        raise ValueError("thread passed make_posts_read_aware should be "
-                         "made read aware via make_thread_read_aware")
+        raise ValueError(
+            "thread passed make_posts_read_aware should be "
+            "made read aware via make_thread_read_aware"
+        )
 
     if is_thread_read:
         for post in posts:
@@ -146,7 +147,7 @@ def sync_record(user, thread, last_read_reply):
         user.threadread_set.create(
             category=thread.category,
             thread=thread,
-            last_read_on=last_read_reply.posted_on
+            last_read_on=last_read_reply.posted_on,
         )
         signals.thread_tracked.send(sender=user, thread=thread)
         notification_triggers.append('see_thread_%s' % thread.pk)

+ 3 - 16
misago/search/permissions.py

@@ -6,16 +6,10 @@ from misago.acl.models import Role
 from misago.core.forms import YesNoSwitch
 
 
-"""
-Admin Permissions Form
-"""
 class PermissionsForm(forms.Form):
     legend = _("Search")
 
-    can_search = YesNoSwitch(
-        label=_("Can search site"),
-        initial=1
-    )
+    can_search = YesNoSwitch(label=_("Can search site"), initial=1)
 
 
 def change_permissions_form(role):
@@ -25,15 +19,8 @@ def change_permissions_form(role):
         return None
 
 
-"""
-ACL Builder
-"""
 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)

+ 2 - 1
misago/search/searchprovider.py

@@ -7,4 +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__
+        )

+ 2 - 4
misago/search/searchproviders.py

@@ -24,15 +24,13 @@ class SearchProviders(object):
             try:
                 module = import_module(module_path)
             except ImportError:
-                raise ImportError(
-                    'search module %s could not be imported' % modulename)
+                raise ImportError('search module %s could not be imported' % modulename)
 
             try:
                 classdef = getattr(module, classname)
                 self._providers.append(classdef)
             except AttributeError:
-                raise ImportError(
-                    'search module %s could not be imported' % modulename)
+                raise ImportError('search module %s could not be imported' % modulename)
 
     def get_providers(self, request):
         if not self._initialized:

+ 11 - 11
misago/search/tests/test_api.py

@@ -14,14 +14,11 @@ class SearchApiTests(AuthenticatedUserTestCase):
 
     def test_no_permission(self):
         """api validates permission to search"""
-        override_acl(self.user, {
-            'can_search': 0
-        })
+        override_acl(self.user, {'can_search': 0})
 
         response = self.client.get(self.test_link)
 
-        self.assertContains(
-            response, "have permission to search site", status_code=403)
+        self.assertContains(response, "have permission to search site", status_code=403)
 
     def test_no_phrase(self):
         """api handles no search query"""
@@ -30,9 +27,11 @@ 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
-            })
+            provider_api = reverse(
+                'misago:api:search', kwargs={
+                    'search_provider': providers[i].url,
+                }
+            )
             self.assertEqual(provider_api, provider['api'])
 
             self.assertEqual(six.text_type(providers[i].name), provider['name'])
@@ -46,9 +45,10 @@ 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
-            })
+            provider_api = reverse(
+                'misago:api:search',
+                kwargs={'search_provider': providers[i].url},
+            )
             self.assertEqual(provider_api, provider['api'])
 
             self.assertEqual(six.text_type(providers[i].name), provider['name'])

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

@@ -23,8 +23,7 @@ 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]
@@ -37,9 +36,8 @@ 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"""
@@ -48,18 +46,14 @@ 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
-        """
+        """get_allowed_providers returns only providers that didn't raise in allow_search"""
         searchproviders = SearchProviders([])
 
         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])

+ 21 - 20
misago/search/tests/test_views.py

@@ -13,14 +13,11 @@ class LandingTests(AuthenticatedUserTestCase):
 
     def test_no_permission(self):
         """view validates permission to search forum"""
-        override_acl(self.user, {
-            'can_search': 0
-        })
+        override_acl(self.user, {'can_search': 0})
 
         response = self.client.get(self.test_link)
 
-        self.assertContains(
-            response, "have permission to search site", status_code=403)
+        self.assertContains(response, "have permission to search site", status_code=403)
 
     def test_redirect_to_provider(self):
         """view validates permission to search forum"""
@@ -33,38 +30,42 @@ class LandingTests(AuthenticatedUserTestCase):
 class SearchTests(AuthenticatedUserTestCase):
     def test_no_permission(self):
         """view validates permission to search forum"""
-        override_acl(self.user, {
-            'can_search': 0
-        })
+        override_acl(self.user, {'can_search': 0})
 
         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)
+        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)
 
     def test_provider_no_permission(self):
         """provider raises 403 without permission"""
-        override_acl(self.user, {
-            'can_search_users': 0
-        })
+        override_acl(self.user, {'can_search_users': 0})
 
         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': 'threads'}))
+            reverse('misago:search', kwargs={
+                'search_provider': 'threads',
+            })
+        )
 
         self.assertContains(response, "Loading search...")

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

@@ -2,9 +2,7 @@ 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'),
 ]
-

+ 1 - 1
misago/search/views.py

@@ -39,7 +39,7 @@ def search(request, search_provider):
             'url': reverse('misago:search', kwargs={'search_provider': provider.url}),
             'api': reverse('misago:api:search', kwargs={'search_provider': provider.url}),
             'results': None,
-            'time': None
+            'time': None,
         })
 
     return render(request, 'misago/search.html')

+ 6 - 4
misago/threads/admin.py

@@ -10,7 +10,8 @@ class MisagoAdminExtension(object):
     def register_urlpatterns(self, urlpatterns):
         # Attachment
         urlpatterns.namespace(r'^attachments/', 'attachments', 'system')
-        urlpatterns.patterns('system:attachments',
+        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'),
@@ -18,7 +19,8 @@ class MisagoAdminExtension(object):
 
         # AttachmentType
         urlpatterns.namespace(r'^attachment-types/', 'attachment-types', 'system')
-        urlpatterns.patterns('system:attachment-types',
+        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'),
@@ -31,12 +33,12 @@ class MisagoAdminExtension(object):
             icon='fa fa-cubes',
             parent='misago:admin:system',
             after='misago:admin:system:settings:index',
-            link='misago:admin:system:attachments: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'
+            link='misago:admin:system:attachment-types:index',
         )

+ 16 - 12
misago/threads/api/attachments.py

@@ -21,9 +21,7 @@ class AttachmentViewSet(viewsets.ViewSet):
         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')
@@ -86,17 +84,23 @@ 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).")
-        raise ValidationError(message % {
-            'upload': filesizeformat(upload.size).rstrip('.0'),
-            'limit': filesizeformat(hard_limit * 1024).rstrip('.0')
-        })
+        raise ValidationError(
+            message % {
+                'upload': filesizeformat(upload.size).rstrip('.0'),
+                'limit': filesizeformat(hard_limit * 1024).rstrip('.0'),
+            }
+        )
 
     if filetype.size_limit and upload.size > filetype.size_limit * 1024:
-        message = _("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 = _(
+            "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'),
+            }
+        )
 
 
 def is_upload_image(upload):

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

@@ -46,7 +46,8 @@ def validate_votes(poll, votes):
             message = ungettext(
                 "This poll disallows voting for more than %(choices)s choice.",
                 "This poll disallows voting for more than %(choices)s choices.",
-                poll.allowed_choices)
+                poll.allowed_choices,
+            )
             raise ValidationError(message % {'choices': poll.allowed_choices})
     except TypeError:
         raise ValidationError(_("One or more of poll choices were invalid."))
@@ -94,5 +95,5 @@ def set_new_votes(request, poll, final_votes):
                 voter_name=request.user.username,
                 voter_slug=request.user.slug,
                 choice_hash=choice['hash'],
-                voter_ip=request.user_ip
+                voter_ip=request.user_ip,
             )

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

@@ -2,7 +2,6 @@ from rest_framework.response import Response
 
 from django.core.exceptions import PermissionDenied
 from django.db.models import F
-from django.http import Http404
 from django.shortcuts import get_object_or_404
 from django.utils import timezone
 from django.utils.translation import ugettext as _
@@ -50,7 +49,7 @@ def revert_post_endpoint(request, post):
         editor_slug=request.user.slug,
         editor_ip=request.user_ip,
         edited_from=post.original,
-        edited_to=edit.edited_from
+        edited_to=edit.edited_from,
     )
 
     parsing_result = common_flavour(request, post.poster, edit.edited_from)

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

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

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

@@ -68,7 +68,8 @@ def clean_posts_for_merge(request, thread):
         message = ungettext(
             "No more than %(limit)s post can be merged at single time.",
             "No more than %(limit)s posts can be merged at single time.",
-            MERGE_LIMIT)
+            MERGE_LIMIT,
+        )
         raise MergeError(message % {'limit': MERGE_LIMIT})
 
     posts_queryset = exclude_invisible_posts(request.user, thread.category, thread.post_set)
@@ -78,7 +79,9 @@ def clean_posts_for_merge(request, thread):
     for post in posts_queryset:
         if post.is_event:
             raise MergeError(_("Events can't be merged."))
-        if post.is_hidden and not (post.pk == thread.first_post_id or thread.category.acl['can_hide_posts']):
+        if post.is_hidden and not (
+                post.pk == thread.first_post_id or thread.category.acl['can_hide_posts']
+        ):
             raise MergeError(_("You can't merge posts the content you can't see."))
 
         if not posts:
@@ -93,7 +96,8 @@ def clean_posts_for_merge(request, thread):
                     raise MergeError(authorship_error)
 
             if posts[0].pk != thread.first_post_id:
-                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 MergeError(_("Posts with different visibility can't be merged."))
 
             posts.append(post)

+ 8 - 2
misago/threads/api/postendpoints/move.py

@@ -54,7 +54,12 @@ def clean_thread_for_move(request, thread, viewmodel):
     try:
         new_thread = viewmodel(request, new_thread_id, select_for_update=True).unwrap()
     except Http404:
-        raise PermissionDenied(_("The thread you have entered link to doesn't exist or you don't have permission to see it."))
+        raise PermissionDenied(
+            _(
+                "The thread you have entered link to doesn't "
+                "exist or you don't have permission to see it."
+            )
+        )
 
     if not new_thread.acl['can_reply']:
         raise PermissionDenied(_("You can't move posts to threads you can't reply."))
@@ -74,7 +79,8 @@ def clean_posts_for_move(request, thread):
         message = ungettext(
             "No more than %(limit)s post can be moved at single time.",
             "No more than %(limit)s posts can be moved at single time.",
-            MOVE_LIMIT)
+            MOVE_LIMIT,
+        )
         raise PermissionDenied(message % {'limit': MOVE_LIMIT})
 
     posts_queryset = exclude_invisible_posts(request.user, thread.category, thread.post_set)

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

@@ -16,6 +16,8 @@ def patch_acl(request, event, value):
         return {'acl': event.acl}
     else:
         return {'acl': None}
+
+
 event_patch_dispatcher.add('acl', patch_acl)
 
 
@@ -29,6 +31,8 @@ def patch_is_hidden(request, event, value):
         return {'is_hidden': event.is_hidden}
     else:
         raise PermissionDenied(_("You don't have permission to hide this event."))
+
+
 event_patch_dispatcher.replace('is-hidden', patch_is_hidden)
 
 

+ 12 - 2
misago/threads/api/postendpoints/patch_post.py

@@ -19,6 +19,8 @@ def patch_acl(request, post, value):
         return {'acl': post.acl}
     else:
         return {'acl': None}
+
+
 post_patch_dispatcher.add('acl', patch_acl)
 
 
@@ -48,7 +50,7 @@ def patch_is_liked(request, post, value):
             liker=request.user,
             liker_name=request.user.username,
             liker_slug=request.user.slug,
-            liker_ip=request.user_ip
+            liker_ip=request.user_ip,
         )
         post.likes += 1
 
@@ -61,7 +63,7 @@ def patch_is_liked(request, post, value):
     for like in post.postlike_set.all()[:4]:
         post.last_likes.append({
             'id': like.liker_id,
-            'username': like.liker_name
+            'username': like.liker_name,
         })
 
     post.save(update_fields=['likes', 'last_likes'])
@@ -71,6 +73,8 @@ def patch_is_liked(request, post, value):
         'last_likes': post.last_likes or [],
         'is_liked': value,
     }
+
+
 post_patch_dispatcher.replace('is-liked', patch_is_liked)
 
 
@@ -81,6 +85,8 @@ def patch_is_protected(request, post, value):
     else:
         moderation.unprotect_post(request.user, post)
     return {'is_protected': post.is_protected}
+
+
 post_patch_dispatcher.replace('is-protected', patch_is_protected)
 
 
@@ -89,6 +95,8 @@ def patch_is_unapproved(request, post, value):
         allow_approve_post(request.user, post)
         moderation.approve_post(request.user, post)
     return {'is_unapproved': post.is_unapproved}
+
+
 post_patch_dispatcher.replace('is-unapproved', patch_is_unapproved)
 
 
@@ -101,6 +109,8 @@ def patch_is_hidden(request, post, value):
         moderation.unhide_post(request.user, post)
 
     return {'is_hidden': post.is_hidden}
+
+
 post_patch_dispatcher.replace('is-hidden', patch_is_hidden)
 
 

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

@@ -11,6 +11,6 @@ def post_read_endpoint(request, thread, post):
             thread.subscription.last_read_on = post.posted_on
             thread.subscription.save()
         return Response({
-            'thread_is_read': thread.last_post_on <= post.posted_on
+            'thread_is_read': thread.last_post_on <= post.posted_on,
         })
     return Response({'thread_is_read': True})

+ 3 - 4
misago/threads/api/postendpoints/split.py

@@ -1,4 +1,3 @@
-from rest_framework import serializers
 from rest_framework.response import Response
 
 from django.core.exceptions import PermissionDenied
@@ -6,7 +5,6 @@ from django.utils.translation import ugettext as _
 from django.utils.translation import ungettext
 
 from misago.conf import settings
-from misago.threads.events import record_event
 from misago.threads.models import Thread
 from misago.threads.moderation import threads as moderation
 from misago.threads.permissions import exclude_invisible_posts
@@ -50,7 +48,8 @@ def clean_posts_for_split(request, thread):
         message = ungettext(
             "No more than %(limit)s post can be split at single time.",
             "No more than %(limit)s posts can be split at single time.",
-            SPLIT_LIMIT)
+            SPLIT_LIMIT,
+        )
         raise SplitError(message % {'limit': SPLIT_LIMIT})
 
     posts_queryset = exclude_invisible_posts(request.user, thread.category, thread.post_set)
@@ -77,7 +76,7 @@ def split_posts_to_new_thread(request, thread, validated_data, posts):
     new_thread = Thread(
         category=validated_data['category'],
         started_on=thread.started_on,
-        last_post_on=thread.last_post_on
+        last_post_on=thread.last_post_on,
     )
 
     new_thread.set_title(validated_data['title'])

+ 12 - 11
misago/threads/api/postingendpoint/__init__.py

@@ -20,11 +20,7 @@ class PostingEndpoint(object):
 
     def __init__(self, request, mode, **kwargs):
         self.kwargs = kwargs
-        self.kwargs.update({
-            'mode': mode,
-            'request': request,
-            'user': request.user
-        })
+        self.kwargs.update({'mode': mode, 'request': request, 'user': request.user})
 
         self.__dict__.update(kwargs)
 
@@ -102,13 +98,17 @@ class PostingEndpoint(object):
     def save(self):
         """save new state to backend"""
         if not self._is_validated or self.errors:
-            raise RuntimeError("You need to validate posting data successfully before calling save")
+            raise RuntimeError(
+                "You need to validate posting data successfully before calling save"
+            )
 
         try:
             for middleware, obj in self.middlewares:
                 obj.pre_save(self._serializers.get(middleware))
         except PostingInterrupt as e:
-            raise ValueError("Posting process can only be interrupted from within interrupt_posting method")
+            raise ValueError(
+                "Posting process can only be interrupted from within interrupt_posting method"
+            )
 
         try:
             for middleware, obj in self.middlewares:
@@ -122,13 +122,14 @@ class PostingEndpoint(object):
             for middleware, obj in self.middlewares:
                 obj.post_save(self._serializers.get(middleware))
         except PostingInterrupt as e:
-            raise ValueError("Posting process can only be interrupted from within interrupt_posting method")
+            raise ValueError(
+                "Posting process can only be interrupted from within interrupt_posting method"
+            )
 
 
 class PostingMiddleware(object):
-    """
-    Abstract middleware class
-    """
+    """abstract middleware class"""
+
     def __init__(self, **kwargs):
         self.kwargs = kwargs
         self.__dict__.update(kwargs)

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

@@ -7,7 +7,7 @@ from misago.acl import add_acl
 from misago.conf import settings
 from misago.threads.serializers import AttachmentSerializer
 
-from . import PostingEndpoint, PostingInterrupt, PostingMiddleware
+from . import PostingEndpoint, PostingMiddleware
 
 
 class AttachmentsMiddleware(PostingMiddleware):
@@ -15,21 +15,21 @@ class AttachmentsMiddleware(PostingMiddleware):
         return bool(self.user.acl_cache['max_attachment_size'])
 
     def get_serializer(self):
-        return AttachmentsSerializer(data=self.request.data, context={
-            'mode': self.mode,
-            'user': self.user,
-            'post': self.post,
-        })
+        return AttachmentsSerializer(
+            data=self.request.data,
+            context={
+                'mode': self.mode,
+                'user': self.user,
+                'post': self.post,
+            }
+        )
 
     def save(self, serializer):
         serializer.save()
 
 
 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
@@ -41,11 +41,12 @@ class AttachmentsSerializer(serializers.Serializer):
         validate_attachments_count(ids)
 
         attachments = self.get_initial_attachments(
-            self.context['mode'], self.context['user'], self.context['post'])
+            self.context['mode'], self.context['user'], self.context['post']
+        )
         new_attachments = self.get_new_attachments(self.context['user'], ids)
 
         if not attachments and not new_attachments:
-            return [] # no attachments
+            return []  # no attachments
 
         # clean existing attachments
         for attachment in attachments:
@@ -56,8 +57,12 @@ class AttachmentsSerializer(serializers.Serializer):
                     self.update_attachments = True
                     self.removed_attachments.append(attachment)
                 else:
-                    message = _("You don't have permission to remove \"%(attachment)s\" attachment.")
-                    raise serializers.ValidationError(message % {'attachment': attachment.filename})
+                    message = _(
+                        "You don't have permission to remove \"%(attachment)s\" attachment."
+                    )
+                    raise serializers.ValidationError(
+                        message % {'attachment': attachment.filename}
+                    )
 
         if new_attachments:
             self.update_attachments = True
@@ -78,7 +83,7 @@ class AttachmentsSerializer(serializers.Serializer):
 
         queryset = user.attachment_set.select_related('filetype').filter(
             post__isnull=True,
-            id__in=ids
+            id__in=ids,
         )
 
         return list(queryset)
@@ -122,8 +127,11 @@ def validate_attachments_count(data):
         message = ungettext(
             "You can't attach more than %(limit_value)s file to single post (added %(show_value)s).",
             "You can't attach more than %(limit_value)s flies to single post (added %(show_value)s).",
-            settings.MISAGO_POST_ATTACHMENTS_LIMIT)
-        raise serializers.ValidationError(message % {
-            'limit_value': settings.MISAGO_POST_ATTACHMENTS_LIMIT,
-            'show_value': total_attachments
-        })
+            settings.MISAGO_POST_ATTACHMENTS_LIMIT,
+        )
+        raise serializers.ValidationError(
+            message % {
+                'limit_value': settings.MISAGO_POST_ATTACHMENTS_LIMIT,
+                'show_value': total_attachments,
+            }
+        )

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

@@ -15,9 +15,8 @@ from . import PostingEndpoint, PostingMiddleware
 
 
 class CategoryMiddleware(PostingMiddleware):
-    """
-    Middleware that validates category id and sets category on thread and post instances
-    """
+    """middleware that validates category id and sets category on thread and post instances"""
+
     def use_this_middleware(self):
         if self.mode == PostingEndpoint.START:
             return self.tree_name == THREADS_ROOT_NAME
@@ -41,10 +40,12 @@ class CategoryMiddleware(PostingMiddleware):
 
 
 class CategorySerializer(serializers.Serializer):
-    category = serializers.IntegerField(error_messages={
-        'required': ugettext_lazy("You have to select category to post thread in."),
-        'invalid': ugettext_lazy("Selected category is invalid.")
-    })
+    category = serializers.IntegerField(
+        error_messages={
+            'required': ugettext_lazy("You have to select category to post thread in."),
+            'invalid': ugettext_lazy("Selected category is invalid."),
+        }
+    )
 
     def __init__(self, user, *args, **kwargs):
         self.user = user
@@ -55,8 +56,7 @@ class CategorySerializer(serializers.Serializer):
     def validate_category(self, value):
         try:
             self.category_cache = Category.objects.get(
-                pk=value,
-                tree_id=trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
+                pk=value, tree_id=trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
             )
 
             can_see = can_see_category(self.user, self.category_cache)
@@ -69,4 +69,5 @@ class CategorySerializer(serializers.Serializer):
             raise serializers.ValidationError(e.args[0])
         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.")
+            )

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

@@ -18,7 +18,7 @@ class EmailNotificationMiddleware(PostingMiddleware):
     def post_save(self, serializer):
         queryset = self.thread.subscription_set.filter(
             send_email=True,
-            last_read_on__gte=self.previous_last_post_on
+            last_read_on__gte=self.previous_last_post_on,
         ).exclude(user=self.user).select_related('user')
 
         notifications = []
@@ -40,10 +40,7 @@ class EmailNotificationMiddleware(PostingMiddleware):
         else:
             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(
             self.request,
@@ -52,6 +49,6 @@ class EmailNotificationMiddleware(PostingMiddleware):
             'misago/emails/thread/reply',
             {
                 'thread': self.thread,
-                'post': self.post
-            }
+                'post': self.post,
+            },
         )

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

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

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

@@ -23,9 +23,7 @@ class ParticipantsMiddleware(PostingMiddleware):
         return False
 
     def get_serializer(self):
-        return ParticipantsSerializer(data=self.request.data, context={
-            'user': self.user
-        })
+        return ParticipantsSerializer(data=self.request.data, context={'user': self.user})
 
     def save(self, serializer):
         set_owner(self.thread, self.user)
@@ -33,10 +31,7 @@ class ParticipantsMiddleware(PostingMiddleware):
 
 
 class ParticipantsSerializer(serializers.Serializer):
-    to = serializers.ListField(
-       child=serializers.CharField(),
-       required=True
-    )
+    to = serializers.ListField(child=serializers.CharField(), required=True)
 
     def validate_to(self, usernames):
         clean_usernames = self.clean_usernames(usernames)
@@ -49,7 +44,8 @@ class ParticipantsSerializer(serializers.Serializer):
 
             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:
                 clean_usernames.append(clean_name)
@@ -62,11 +58,14 @@ class ParticipantsSerializer(serializers.Serializer):
             message = ungettext(
                 "You can't add more than %(users)s user to private thread (you've added %(added)s).",
                 "You can't add more than %(users)s users to private thread (you've added %(added)s).",
-                max_participants)
-            raise serializers.ValidationError(message % {
-                'users': max_participants,
-                'added': len(clean_usernames)
-            })
+                max_participants,
+            )
+            raise serializers.ValidationError(
+                message % {
+                    'users': max_participants,
+                    'added': len(clean_usernames),
+                }
+            )
 
         return list(set(clean_usernames))
 
@@ -84,7 +83,6 @@ 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 - 3
misago/threads/api/postingendpoint/privatethread.py

@@ -6,9 +6,8 @@ from . import PostingEndpoint, PostingMiddleware
 
 
 class PrivateThreadMiddleware(PostingMiddleware):
-    """
-    Middleware that sets private threads category for thread and post
-    """
+    """middleware that sets private threads category for thread and post"""
+
     def use_this_middleware(self):
         if self.mode == PostingEndpoint.START:
             return self.tree_name == PRIVATE_THREADS_ROOT_NAME

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

@@ -21,13 +21,9 @@ class RecordEditMiddleware(PostingMiddleware):
         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',
-        ))
+        self.post.update_fields.extend(
+            ('updated_on', 'edits', 'last_editor', 'last_editor_name', 'last_editor_slug', )
+        )
 
         self.post.edits_record.create(
             category=self.post.category,
@@ -38,5 +34,5 @@ class RecordEditMiddleware(PostingMiddleware):
             editor_slug=self.user.slug,
             editor_ip=self.request.user_ip,
             edited_from=self.original_post,
-            edited_to=self.post.original
+            edited_to=self.post.original,
         )

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

@@ -1,9 +1,7 @@
 from rest_framework import serializers
 
-from django.db.models import F
 from django.utils.translation import ugettext_lazy
 
-from misago.conf import settings
 from misago.markup import common_flavour
 from misago.threads.checksums import update_post_checksum
 from misago.threads.validators import validate_post, validate_title
@@ -87,7 +85,7 @@ class ReplySerializer(serializers.Serializer):
     post = serializers.CharField(
         validators=[validate_post],
         error_messages={
-            'required': ugettext_lazy("You have to enter a message.")
+            'required': ugettext_lazy("You have to enter a message."),
         }
     )
 
@@ -96,6 +94,6 @@ class ThreadSerializer(ReplySerializer):
     title = serializers.CharField(
         validators=[validate_title],
         error_messages={
-        'required': ugettext_lazy("You have to enter thread title.")
+            'required': ugettext_lazy("You have to enter thread title."),
         }
     )

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

@@ -26,7 +26,7 @@ class SubscribeMiddleware(PostingMiddleware):
         self.user.subscription_set.create(
             category=self.thread.category,
             thread=self.thread,
-            send_email=self.user.subscribe_to_started_threads == UserModel.SUBSCRIBE_ALL
+            send_email=self.user.subscribe_to_started_threads == UserModel.SUBSCRIBE_ALL,
         )
 
     def subscribe_replied_thread(self):
@@ -37,8 +37,7 @@ class SubscribeMiddleware(PostingMiddleware):
             return
 
         try:
-            subscription = self.user.subscription_set.get(thread=self.thread)
-            return
+            return self.user.subscription_set.get(thread=self.thread)
         except Subscription.DoesNotExist:
             pass
 
@@ -49,5 +48,5 @@ class SubscribeMiddleware(PostingMiddleware):
         self.user.subscription_set.create(
             category=self.thread.category,
             thread=self.thread,
-            send_email=self.user.subscribe_to_replied_threads == UserModel.SUBSCRIBE_ALL
+            send_email=self.user.subscribe_to_replied_threads == UserModel.SUBSCRIBE_ALL,
         )

+ 3 - 4
misago/threads/api/postingendpoint/syncprivatethreads.py

@@ -5,9 +5,8 @@ from . import PostingEndpoint, PostingMiddleware
 
 
 class SyncPrivateThreadsMiddleware(PostingMiddleware):
-    """
-    Middleware that sets private thread participants to sync unread threads
-    """
+    """middleware that sets private thread participants to sync unread threads"""
+
     def use_this_middleware(self):
         if self.mode == PostingEndpoint.REPLY:
             return self.thread.thread_type.root_name == PRIVATE_THREADS_ROOT_NAME
@@ -16,5 +15,5 @@ class SyncPrivateThreadsMiddleware(PostingMiddleware):
     def post_save(self, serializer):
         set_users_unread_private_threads_sync(
             participants=self.thread.participants_list,
-            exclude_user=self.user
+            exclude_user=self.user,
         )

+ 5 - 3
misago/threads/api/threadendpoints/editor.py

@@ -31,7 +31,7 @@ def thread_start_editor(request):
             post = {
                 'close': bool(category.acl['can_close_threads']),
                 'hide': bool(category.acl['can_hide_threads']),
-                'pin': category.acl['can_pin_threads']
+                'pin': category.acl['can_pin_threads'],
             }
 
             available.append(category.pk)
@@ -43,7 +43,7 @@ def thread_start_editor(request):
             'id': category.pk,
             'name': category.name,
             'level': category.level - 1,
-            'post': post
+            'post': post,
         })
 
     # list only categories that allow new threads, or contains subcategory that allows one
@@ -53,6 +53,8 @@ def thread_start_editor(request):
             cleaned_categories.append(category)
 
     if not cleaned_categories:
-        raise PermissionDenied(_("No categories that allow new threads are available to you at the moment."))
+        raise PermissionDenied(
+            _("No categories that allow new threads are available to you at the moment.")
+        )
 
     return Response(cleaned_categories)

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

@@ -11,7 +11,7 @@ class ThreadsList(object):
     def __call__(self, request, **kwargs):
         page = get_int_or_404(request.query_params.get('page', 0))
         if page == 1:
-            page = 0 # api allows explicit first page
+            page = 0  # api allows explicit first page
 
         list_type = request.query_params.get('list', 'all')
 

+ 38 - 33
misago/threads/api/threadendpoints/merge.py

@@ -1,6 +1,6 @@
 from rest_framework.response import Response
 
-from django.core.exceptions import PermissionDenied, ValidationError
+from django.core.exceptions import PermissionDenied
 from django.http import Http404
 from django.utils.translation import gettext as _
 from django.utils.translation import ungettext
@@ -19,7 +19,7 @@ from misago.threads.utils import add_categories_to_items, get_thread_id_from_url
 from .pollmergehandler import PollMergeHandler
 
 
-MERGE_LIMIT = 20 # no more than 20 threads can be merged in single action
+MERGE_LIMIT = 20  # no more than 20 threads can be merged in single action
 
 
 class MergeError(Exception):
@@ -42,15 +42,21 @@ def thread_merge_endpoint(request, thread, viewmodel):
         if not can_reply_thread(request.user, other_thread):
             raise PermissionDenied(_("You can't merge this thread into thread you can't reply."))
         if not other_thread.acl['can_merge']:
-            raise PermissionDenied(_("You don't have permission to merge this thread with current one."))
+            raise PermissionDenied(
+                _("You don't have permission to merge this thread with current one.")
+            )
     except PermissionDenied as e:
-        return Response({
-            'detail': e.args[0]
-        }, status=400)
+        return Response({'detail': e.args[0]}, status=400)
     except Http404:
-        return Response({
-            'detail': _("The thread you have entered link to doesn't exist or you don't have permission to see it.")
-        }, status=400)
+        return Response(
+            {
+                'detail': _(
+                    "The thread you have entered link to doesn't "
+                    "exist or you don't have permission to see it."
+                )
+            },
+            status=400,
+        )
 
     polls_handler = PollMergeHandler([thread, other_thread])
     if len(polls_handler.polls) == 1:
@@ -67,13 +73,9 @@ def thread_merge_endpoint(request, thread, viewmodel):
                 elif not poll:
                     other_thread.poll.delete()
             else:
-                return Response({
-                    'detail': _("Invalid choice.")
-                }, status=400)
+                return Response({'detail': _("Invalid choice.")}, status=400)
         else:
-            return Response({
-                'polls': polls_handler.get_available_resolutions()
-            }, status=400)
+            return Response({'polls': polls_handler.get_available_resolutions()}, status=400)
 
     moderation.merge_thread(request, other_thread, thread)
 
@@ -90,7 +92,7 @@ def thread_merge_endpoint(request, thread, viewmodel):
     return Response({
         'id': other_thread.pk,
         'title': other_thread.title,
-        'url': other_thread.get_absolute_url()
+        'url': other_thread.get_absolute_url(),
     })
 
 
@@ -106,9 +108,7 @@ def threads_merge_endpoint(request):
             invalid_threads.append({
                 'id': thread.pk,
                 'title': thread.title,
-                'errors': [
-                    _("You don't have permission to merge this thread with others.")
-                ]
+                'errors': [_("You don't have permission to merge this thread with others.")]
             })
 
     if invalid_threads:
@@ -125,13 +125,9 @@ def threads_merge_endpoint(request):
                 if polls_handler.is_valid():
                     poll = polls_handler.get_resolution()
                 else:
-                    return Response({
-                        'detail': _("Invalid choice.")
-                    }, status=400)
+                    return Response({'detail': _("Invalid choice.")}, status=400)
             else:
-                return Response({
-                    'polls': polls_handler.get_available_resolutions()
-                }, status=400)
+                return Response({'polls': polls_handler.get_available_resolutions()}, status=400)
         else:
             poll = None
 
@@ -153,7 +149,8 @@ def clean_threads_for_merge(request):
         message = ungettext(
             "No more than %(limit)s thread can be merged at single time.",
             "No more than %(limit)s threads can be merged at single time.",
-            MERGE_LIMIT)
+            MERGE_LIMIT,
+        )
         raise MergeError(message % {'limit': MERGE_LIMIT})
 
     threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
@@ -179,7 +176,7 @@ def merge_threads(request, validated_data, threads, poll):
     new_thread = Thread(
         category=validated_data['category'],
         started_on=threads[0].started_on,
-        last_post_on=threads[0].last_post_on
+        last_post_on=threads[0].last_post_on,
     )
 
     new_thread.set_title(validated_data['title'])
@@ -194,9 +191,15 @@ def merge_threads(request, validated_data, threads, poll):
         new_thread.merge(thread)
         thread.delete()
 
-        record_event(request, new_thread, 'merged', {
-            'merged_thread': thread.title,
-        }, commit=False)
+        record_event(
+            request,
+            new_thread,
+            'merged',
+            {
+                'merged_thread': thread.title,
+            },
+            commit=False,
+        )
 
     new_thread.synchronize()
     new_thread.save()
@@ -223,9 +226,11 @@ def merge_threads(request, validated_data, threads, poll):
 
     # add top category to thread
     if validated_data.get('top_category'):
-        categories = list(Category.objects.all_categories().filter(
-            id__in=request.user.acl_cache['visible_categories']
-        ))
+        categories = list(
+            Category.objects.all_categories().filter(
+                id__in=request.user.acl_cache['visible_categories'],
+            )
+        )
         add_categories_to_items(validated_data['top_category'], categories, [new_thread])
     else:
         new_thread.top_category = None

+ 42 - 31
misago/threads/api/threadendpoints/patch.py

@@ -10,7 +10,6 @@ from misago.categories.permissions import allow_browse_category, allow_see_categ
 from misago.categories.serializers import CategorySerializer
 from misago.core.apipatch import ApiPatch
 from misago.core.shortcuts import get_int_or_404
-from misago.threads.models import ThreadParticipant
 from misago.threads.moderation import threads as moderation
 from misago.threads.participants import (
     add_participant, change_owner, make_participants_aware, remove_participant)
@@ -34,6 +33,8 @@ def patch_acl(request, thread, value):
         return {'acl': thread.acl}
     else:
         return {'acl': None}
+
+
 thread_patch_dispatcher.add('acl', patch_acl)
 
 
@@ -52,6 +53,8 @@ def patch_title(request, thread, value):
 
     moderation.change_thread_title(request, thread, value_cleaned)
     return {'title': thread.title}
+
+
 thread_patch_dispatcher.replace('title', patch_title)
 
 
@@ -73,6 +76,8 @@ def patch_weight(request, thread, value):
         moderation.unpin_thread(request, thread)
 
     return {'weight': thread.weight}
+
+
 thread_patch_dispatcher.replace('weight', patch_weight)
 
 
@@ -82,8 +87,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(request.user, new_category)
@@ -98,21 +102,24 @@ def patch_move(request, thread, value):
 
     return {'category': CategorySerializer(new_category).data}
 
+
 thread_patch_dispatcher.replace('category', patch_move)
 
 
 def patch_top_category(request, thread, value):
     category_pk = get_int_or_404(value)
     root_category = get_object_or_404(
-        Category.objects.all_categories(include_root=True),
-        pk=category_pk
+        Category.objects.all_categories(include_root=True), pk=category_pk
     )
 
-    categories = list(Category.objects.all_categories().filter(
-        id__in=request.user.acl_cache['visible_categories']
-    ))
+    categories = list(
+        Category.objects.all_categories()
+        .filter(id__in=request.user.acl_cache['visible_categories'])
+    )
     add_categories_to_items(root_category, categories, [thread])
     return {'top_category': CategorySerializer(thread.top_category).data}
+
+
 thread_patch_dispatcher.add('top-category', patch_top_category)
 
 
@@ -122,11 +129,10 @@ def patch_flatten_categories(request, thread, value):
             'category': thread.category_id,
             'top_category': thread.top_category.pk,
         }
-    except AttributeError as e:
-        return {
-            'category': thread.category_id,
-            'top_category': None
-        }
+    except AttributeError:
+        return {'category': thread.category_id, 'top_category': None}
+
+
 thread_patch_dispatcher.replace('flatten-categories', patch_flatten_categories)
 
 
@@ -143,6 +149,8 @@ def patch_is_unapproved(request, thread, value):
         }
     else:
         raise PermissionDenied(_("You don't have permission to approve this thread."))
+
+
 thread_patch_dispatcher.replace('is-unapproved', patch_is_unapproved)
 
 
@@ -159,6 +167,8 @@ def patch_is_closed(request, thread, value):
             raise PermissionDenied(_("You don't have permission to close this thread."))
         else:
             raise PermissionDenied(_("You don't have permission to open this thread."))
+
+
 thread_patch_dispatcher.replace('is-closed', patch_is_closed)
 
 
@@ -172,6 +182,8 @@ def patch_is_hidden(request, thread, value):
         return {'is_hidden': thread.is_hidden}
     else:
         raise PermissionDenied(_("You don't have permission to hide this thread."))
+
+
 thread_patch_dispatcher.replace('is-hidden', patch_is_hidden)
 
 
@@ -198,6 +210,8 @@ def patch_subscription(request, thread, value):
         return {'subscription': True}
     else:
         return {'subscription': None}
+
+
 thread_patch_dispatcher.replace('subscription', patch_subscription)
 
 
@@ -207,8 +221,7 @@ def patch_add_participant(request, thread, value):
     try:
         username = six.text_type(value).strip().lower()
         if not username:
-            raise PermissionDenied(
-                _("You have to enter new participant's username."))
+            raise PermissionDenied(_("You have to enter new participant's username."))
         participant = UserModel.objects.get(slug=username)
     except UserModel.DoesNotExist:
         raise PermissionDenied(_("No user with such name exists."))
@@ -220,12 +233,11 @@ def patch_add_participant(request, thread, value):
     add_participant(request, thread, participant)
 
     make_participants_aware(request.user, thread)
-    participants = ThreadParticipantSerializer(
-        thread.participants_list, many=True)
+    participants = ThreadParticipantSerializer(thread.participants_list, many=True)
+
+    return {'participants': participants.data}
+
 
-    return {
-        'participants': participants.data
-    }
 thread_patch_dispatcher.add('participants', patch_add_participant)
 
 
@@ -245,18 +257,17 @@ 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)
+        participants = ThreadParticipantSerializer(thread.participants_list, many=True)
 
         return {
             'deleted': False,
-            'participants': participants.data
+            'participants': participants.data,
         }
+
+
 thread_patch_dispatcher.remove('participants', patch_remove_participant)
 
 
@@ -280,9 +291,9 @@ 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)
 
 
@@ -299,9 +310,9 @@ def thread_patch_endpoint(request, thread):
     unapproved_changed = old_is_unapproved != thread.is_unapproved
     category_changed = old_category != thread.category
 
-    title_changed = old_is_hidden != thread.is_hidden
+    title_changed = old_title != thread.title
     if thread.category.last_thread_id != thread.pk:
-        title_changed = False # don't trigger resync on simple title change
+        title_changed = False  # don't trigger resync on simple title change
 
     if hidden_changed or unapproved_changed or category_changed:
         thread.category.synchronize()

+ 3 - 4
misago/threads/api/threadendpoints/read.py

@@ -14,7 +14,8 @@ def read_threads(user, pk):
     category_id = get_int_or_404(pk)
     threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
 
-    category = get_object_or_404(Category,
+    category = get_object_or_404(
+        Category,
         id=category_id,
         tree_id=threads_tree_id,
     )
@@ -32,6 +33,4 @@ def read_private_threads(user):
 
     user.sync_unread_private_threads = False
     user.unread_private_threads = 0
-    user.save(update_fields=[
-        'sync_unread_private_threads', 'unread_private_threads'
-    ])
+    user.save(update_fields=['sync_unread_private_threads', 'unread_private_threads'])

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

@@ -113,7 +113,7 @@ class ViewSet(viewsets.ViewSet):
         thread.save()
 
         return Response({
-            'can_start_poll': can_start_poll(request.user, thread)
+            'can_start_poll': can_start_poll(request.user, thread),
         })
 
     @detail_route(methods=['get', 'post'])
@@ -152,7 +152,8 @@ class ViewSet(viewsets.ViewSet):
             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)

+ 25 - 16
misago/threads/api/threadposts.py

@@ -32,22 +32,30 @@ class ViewSet(viewsets.ViewSet):
     posts = ThreadPosts
     post_ = ThreadPost
 
-    def get_thread(self, request, pk, read_aware=True, subscription_aware=True, select_for_update=False):
+    def get_thread(
+            self,
+            request,
+            pk,
+            read_aware=True,
+            subscription_aware=True,
+            select_for_update=False,
+    ):
         return self.thread(
             request,
             get_int_or_404(pk),
             None,
             read_aware,
             subscription_aware,
-            select_for_update
+            select_for_update,
         )
 
     def get_thread_for_update(self, request, pk):
         return self.get_thread(
-            request, pk,
+            request,
+            pk,
             read_aware=False,
             subscription_aware=False,
-            select_for_update=True
+            select_for_update=True,
         )
 
     def get_posts(self, request, thread, page):
@@ -62,7 +70,7 @@ class ViewSet(viewsets.ViewSet):
     def list(self, request, thread_pk):
         page = get_int_or_404(request.query_params.get('page', 0))
         if page == 1:
-            page = 0 # api allows explicit first page
+            page = 0  # api allows explicit first page
 
         thread = self.get_thread(request, thread_pk)
         posts = self.get_posts(request, thread, page)
@@ -95,14 +103,17 @@ class ViewSet(viewsets.ViewSet):
         thread = self.get_thread_for_update(request, thread_pk).unwrap()
         allow_reply_thread(request.user, 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
+            post=post,
         )
 
         if posting.is_valid():
@@ -132,7 +143,7 @@ class ViewSet(viewsets.ViewSet):
             request,
             PostingEndpoint.EDIT,
             thread=thread,
-            post=post
+            post=post,
         )
 
         if posting.is_valid():
@@ -197,7 +208,7 @@ class ViewSet(viewsets.ViewSet):
             request,
             thread_pk,
             read_aware=False,
-            subscription_aware=False
+            subscription_aware=False,
         )
         post = self.get_post(request, thread, pk).unwrap()
 
@@ -208,7 +219,8 @@ class ViewSet(viewsets.ViewSet):
             add_acl(request.user, attachment)
             attachments.append(attachment)
         attachments_json = AttachmentSerializer(
-            attachments, many=True, context={'user': request.user}).data
+            attachments, many=True, context={'user': request.user}
+        ).data
 
         return Response({
             'id': post.pk,
@@ -217,16 +229,13 @@ class ViewSet(viewsets.ViewSet):
             'attachments': attachments_json,
             'can_protect': bool(thread.category.acl['can_protect_posts']),
             'is_protected': post.is_protected,
-            'poster': post.poster_name
+            '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,
-            read_aware=False,
-            subscription_aware=False
+            request, thread_pk, read_aware=False, subscription_aware=False
         ).unwrap()
         allow_reply_thread(request.user, thread)
 
@@ -241,7 +250,7 @@ class ViewSet(viewsets.ViewSet):
             return Response({
                 'id': reply_to.pk,
                 'post': reply_to.original,
-                'poster': reply_to.poster_name
+                'poster': reply_to.poster_name,
             })
         else:
             return Response({})

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

@@ -24,22 +24,25 @@ from .threadendpoints.read import read_private_threads, read_threads
 class ViewSet(viewsets.ViewSet):
     thread = None
 
-    def get_thread(self, request, pk, read_aware=True, subscription_aware=True, select_for_update=False):
+    def get_thread(
+            self, request, pk, read_aware=True, subscription_aware=True, select_for_update=False
+    ):
         return self.thread(
             request,
             get_int_or_404(pk),
             None,
             read_aware,
             subscription_aware,
-            select_for_update
+            select_for_update,
         )
 
     def get_thread_for_update(self, request, pk):
         return self.get_thread(
-            request, pk,
+            request,
+            pk,
             read_aware=False,
             subscription_aware=False,
-            select_for_update=True
+            select_for_update=True,
         )
 
     def retrieve(self, request, pk):
@@ -80,7 +83,7 @@ class ThreadViewSet(ViewSet):
             PostingEndpoint.START,
             tree_name=THREADS_ROOT_NAME,
             thread=thread,
-            post=post
+            post=post,
         )
 
         if posting.is_valid():
@@ -89,7 +92,7 @@ class ThreadViewSet(ViewSet):
             return Response({
                 'id': thread.pk,
                 'title': thread.title,
-                'url': thread.get_absolute_url()
+                'url': thread.get_absolute_url(),
             })
         else:
             return Response(posting.errors, status=400)
@@ -138,7 +141,7 @@ class PrivateThreadViewSet(ViewSet):
             PostingEndpoint.START,
             tree_name=PRIVATE_THREADS_ROOT_NAME,
             thread=thread,
-            post=post
+            post=post,
         )
 
         if posting.is_valid():
@@ -147,7 +150,7 @@ class PrivateThreadViewSet(ViewSet):
             return Response({
                 'id': thread.pk,
                 'title': thread.title,
-                'url': thread.get_absolute_url()
+                'url': thread.get_absolute_url(),
             })
         else:
             return Response(posting.errors, status=400)

+ 1 - 1
misago/threads/apps.py

@@ -7,4 +7,4 @@ class MisagoThreadsConfig(AppConfig):
     verbose_name = "Misago Threads"
 
     def ready(self):
-        from . import signals
+        from . import signals as _

+ 0 - 4
misago/threads/context_processors.py

@@ -4,13 +4,9 @@ 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'),
     })
 

+ 24 - 16
misago/threads/forms.py

@@ -1,7 +1,7 @@
 from django import forms
 from django.utils.translation import ugettext as _
 
-from .models import Attachment, AttachmentType
+from .models import AttachmentType
 
 
 def get_searchable_filetypes():
@@ -18,16 +18,16 @@ class SearchAttachmentsForm(forms.Form):
         coerce=int,
         choices=get_searchable_filetypes,
         empty_value=0,
-        required=False
+        required=False,
     )
     is_orphan = forms.ChoiceField(
         label=_("State"),
         required=False,
-        choices=(
+        choices=[
             ('', _("All")),
             ('yes', _("Only orphaned")),
             ('no', _("Not orphaned")),
-        ),
+        ],
     )
 
     def filter_queryset(self, criteria, queryset):
@@ -59,18 +59,26 @@ class AttachmentTypeForm(forms.ModelForm):
         }
         help_texts = {
             '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': _("Maximum allowed uploaded file size for this type, in kb. "
-                            "May be overriden via user permission."),
+            'mimetypes': _(
+                "Optional list of comma separated mime types associated with this attachment type."
+            ),
+            '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': _("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': _("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 "
-                                      " attachments of this type."),
+            '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': _(
+                "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 "
+                "attachments of this type."
+            ),
         }
         widgets = {
             'limit_uploads_to': forms.CheckboxSelectMultiple,
@@ -78,7 +86,7 @@ class AttachmentTypeForm(forms.ModelForm):
         }
 
     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

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

@@ -17,7 +17,7 @@ class Command(BaseCommand):
         cutoff = timezone.now() - timedelta(minutes=settings.MISAGO_ATTACHMENT_ORPHANED_EXPIRE)
         queryset = Attachment.objects.filter(
             post__isnull=True,
-            uploaded_on__lt=cutoff
+            uploaded_on__lt=cutoff,
         )
 
         attachments_to_sync = queryset.count()

+ 7 - 9
misago/threads/middleware.py

@@ -3,7 +3,6 @@ from django.utils.deprecation import MiddlewareMixin
 from misago.categories.models import Category
 
 from .models import Thread
-from .permissions import exclude_invisible_threads
 from .viewmodels import filter_read_threads_queryset
 
 
@@ -21,10 +20,7 @@ class UnreadThreadsCountMiddleware(MiddlewareMixin):
         participated_threads = request.user.threadparticipant_set.values('thread_id')
 
         category = Category.objects.private_threads()
-        threads = Thread.objects.filter(
-            category=category,
-            id__in=participated_threads
-        )
+        threads = Thread.objects.filter(category=category, id__in=participated_threads)
 
         new_threads = filter_read_threads_queryset(request.user, [category], 'new', threads)
         unread_threads = filter_read_threads_queryset(request.user, [category], 'unread', threads)
@@ -32,7 +28,9 @@ class UnreadThreadsCountMiddleware(MiddlewareMixin):
         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',
-        ])
+        request.user.save(
+            update_fields=[
+                'unread_private_threads',
+                'sync_unread_private_threads',
+            ]
+        )

+ 280 - 58
misago/threads/migrations/0001_initial.py

@@ -9,7 +9,7 @@ from django.contrib.postgres.search import SearchVectorField
 from django.db import migrations, models
 
 import misago.threads.models.attachment
-from misago.core.pgutils import CreatePartialCompositeIndex, CreatePartialIndex
+from misago.core.pgutils import CreatePartialIndex
 
 
 class Migration(migrations.Migration):
@@ -22,7 +22,11 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             name='Post',
             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
+                    )
+                ),
                 ('poster_name', models.CharField(max_length=255)),
                 ('poster_ip', models.GenericIPAddressField()),
                 ('original', models.TextField()),
@@ -34,7 +38,15 @@ class Migration(migrations.Migration):
                 ('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', 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)),
@@ -44,9 +56,28 @@ class Migration(migrations.Migration):
                 ('is_hidden', models.BooleanField(default=False)),
                 ('is_protected', models.BooleanField(default=False)),
                 ('category', models.ForeignKey(to='misago_categories.Category')),
-                ('last_editor', models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, null=True)),
-                ('mentions', models.ManyToManyField(related_name='mention_set', to=settings.AUTH_USER_MODEL)),
-                ('poster', models.ForeignKey(on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, null=True)),
+                (
+                    'last_editor', models.ForeignKey(
+                        related_name='+',
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        blank=True,
+                        to=settings.AUTH_USER_MODEL,
+                        null=True
+                    )
+                ),
+                (
+                    'mentions', models.ManyToManyField(
+                        related_name='mention_set', to=settings.AUTH_USER_MODEL
+                    )
+                ),
+                (
+                    '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)),
@@ -55,9 +86,8 @@ class Migration(migrations.Migration):
                 ('search_document', models.TextField(blank=True, null=True)),
                 ('search_vector', SearchVectorField()),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
         CreatePartialIndex(
             field='Post.has_open_reports',
@@ -72,7 +102,11 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             name='Thread',
             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
+                    )
+                ),
                 ('title', models.CharField(max_length=255)),
                 ('slug', models.CharField(max_length=255)),
                 ('replies', models.PositiveIntegerField(default=0, db_index=True)),
@@ -94,9 +128,8 @@ class Migration(migrations.Migration):
                 ('is_hidden', models.BooleanField(default=False)),
                 ('is_closed', models.BooleanField(default=False)),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
         CreatePartialIndex(
             field='Thread.weight',
@@ -111,19 +144,26 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             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(to='misago_threads.Thread')),
                 ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
                 ('is_owner', models.BooleanField(default=False)),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
         migrations.AddField(
             model_name='thread',
             name='participants',
-            field=models.ManyToManyField(related_name='privatethread_set', through='misago_threads.ThreadParticipant', to=settings.AUTH_USER_MODEL),
+            field=models.ManyToManyField(
+                related_name='privatethread_set',
+                through='misago_threads.ThreadParticipant',
+                to=settings.AUTH_USER_MODEL
+            ),
             preserve_default=True,
         ),
         CreatePartialIndex(
@@ -165,7 +205,13 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='thread',
             name='first_post',
-            field=models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, blank=True, to='misago_threads.Post', null=True),
+            field=models.ForeignKey(
+                related_name='+',
+                on_delete=django.db.models.deletion.SET_NULL,
+                blank=True,
+                to='misago_threads.Post',
+                null=True
+            ),
             preserve_default=True,
         ),
         migrations.AddField(
@@ -177,19 +223,36 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='thread',
             name='last_post',
-            field=models.ForeignKey(related_name='+', on_delete=django.db.models.deletion.SET_NULL, blank=True, to='misago_threads.Post', null=True),
+            field=models.ForeignKey(
+                related_name='+',
+                on_delete=django.db.models.deletion.SET_NULL,
+                blank=True,
+                to='misago_threads.Post',
+                null=True
+            ),
             preserve_default=True,
         ),
         migrations.AddField(
             model_name='thread',
             name='last_poster',
-            field=models.ForeignKey(related_name='last_poster_set', on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, null=True),
+            field=models.ForeignKey(
+                related_name='last_poster_set',
+                on_delete=django.db.models.deletion.SET_NULL,
+                blank=True,
+                to=settings.AUTH_USER_MODEL,
+                null=True
+            ),
             preserve_default=True,
         ),
         migrations.AddField(
             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),
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.SET_NULL,
+                blank=True,
+                to=settings.AUTH_USER_MODEL,
+                null=True
+            ),
             preserve_default=True,
         ),
         migrations.AlterIndexTogether(
@@ -211,16 +274,19 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             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)),
                 ('send_email', models.BooleanField(default=False)),
                 ('category', models.ForeignKey(to='misago_categories.Category')),
                 ('thread', models.ForeignKey(to='misago_threads.Thread')),
                 ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
         migrations.AlterIndexTogether(
             name='subscription',
@@ -231,17 +297,43 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             name='PostEdit',
             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'
+                    )
+                ),
                 ('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')),
-                ('editor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
-                ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='edits_record', to='misago_threads.Post')),
-                ('thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread')),
+                (
+                    'category', models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='misago_categories.Category'
+                    )
+                ),
+                (
+                    'editor', models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to=settings.AUTH_USER_MODEL
+                    )
+                ),
+                (
+                    'post', models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        related_name='edits_record',
+                        to='misago_threads.Post'
+                    )
+                ),
+                (
+                    'thread', models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread'
+                    )
+                ),
             ],
             options={
                 'ordering': ['-id'],
@@ -250,47 +342,115 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             name='Attachment',
             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'
+                    )
+                ),
                 ('secret', models.CharField(max_length=64)),
-                ('uploaded_on', models.DateTimeField(default=django.utils.timezone.now, db_index=True)),
+                (
+                    '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)),
-                ('image', models.ImageField(max_length=255, blank=True, null=True, upload_to=misago.threads.models.attachment.upload_to)),
-                ('file', models.FileField(max_length=255, blank=True, null=True, upload_to=misago.threads.models.attachment.upload_to)),
-                ('post', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='misago_threads.Post')),
+                (
+                    'thumbnail', models.ImageField(
+                        max_length=255,
+                        blank=True,
+                        null=True,
+                        upload_to=misago.threads.models.attachment.upload_to
+                    )
+                ),
+                (
+                    'image', models.ImageField(
+                        max_length=255,
+                        blank=True,
+                        null=True,
+                        upload_to=misago.threads.models.attachment.upload_to
+                    )
+                ),
+                (
+                    'file', models.FileField(
+                        max_length=255,
+                        blank=True,
+                        null=True,
+                        upload_to=misago.threads.models.attachment.upload_to
+                    )
+                ),
+                (
+                    'post', models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to='misago_threads.Post'
+                    )
+                ),
             ],
         ),
         migrations.CreateModel(
             name='AttachmentType',
             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'
+                    )
+                ),
                 ('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')),
-                ('limit_uploads_to', models.ManyToManyField(blank=True, related_name='_attachmenttype_limit_uploads_to_+', to='misago_acl.Role')),
+                (
+                    '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'
+                    )
+                ),
+                (
+                    'limit_uploads_to', models.ManyToManyField(
+                        blank=True,
+                        related_name='_attachmenttype_limit_uploads_to_+',
+                        to='misago_acl.Role'
+                    )
+                ),
             ],
         ),
         migrations.AddField(
             model_name='attachment',
             name='filetype',
-            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.AttachmentType'),
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE, to='misago_threads.AttachmentType'
+            ),
         ),
         migrations.AddField(
             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),
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                to=settings.AUTH_USER_MODEL
+            ),
         ),
         migrations.CreateModel(
             name='Poll',
             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'
+                    )
+                ),
                 ('poster_name', models.CharField(max_length=255)),
                 ('poster_slug', models.CharField(max_length=255)),
                 ('poster_ip', models.GenericIPAddressField()),
@@ -302,24 +462,64 @@ class Migration(migrations.Migration):
                 ('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')),
-                ('poster', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
-                ('thread', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread')),
+                (
+                    'category', models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='misago_categories.Category'
+                    )
+                ),
+                (
+                    'poster', models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to=settings.AUTH_USER_MODEL
+                    )
+                ),
+                (
+                    'thread', models.OneToOneField(
+                        on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread'
+                    )
+                ),
             ],
         ),
         migrations.CreateModel(
             name='PollVote',
             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'
+                    )
+                ),
                 ('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')),
-                ('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')),
-                ('voter', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
+                (
+                    'category', models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='misago_categories.Category'
+                    )
+                ),
+                (
+                    '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'
+                    )
+                ),
+                (
+                    'voter', models.ForeignKey(
+                        blank=True,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        to=settings.AUTH_USER_MODEL
+                    )
+                ),
             ],
         ),
         migrations.AlterIndexTogether(
@@ -331,12 +531,21 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             name='PostLike',
             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'
+                    )
+                ),
                 ('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')),
+                (
+                    'category', models.ForeignKey(
+                        on_delete=django.db.models.deletion.CASCADE,
+                        to='misago_categories.Category'
+                    )
+                ),
             ],
             options={
                 'ordering': ['-id'],
@@ -345,21 +554,34 @@ class Migration(migrations.Migration):
         migrations.AddField(
             model_name='postlike',
             name='post',
-            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Post'),
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Post'
+            ),
         ),
         migrations.AddField(
             model_name='postlike',
             name='thread',
-            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread'),
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE, to='misago_threads.Thread'
+            ),
         ),
         migrations.AddField(
             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),
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                to=settings.AUTH_USER_MODEL
+            ),
         ),
         migrations.AddField(
             model_name='post',
             name='liked_by',
-            field=models.ManyToManyField(related_name='liked_post_set', through='misago_threads.PostLike', to=settings.AUTH_USER_MODEL),
+            field=models.ManyToManyField(
+                related_name='liked_post_set',
+                through='misago_threads.PostLike',
+                to=settings.AUTH_USER_MODEL
+            ),
         ),
     ]

+ 57 - 56
misago/threads/migrations/0002_threads_settings.py

@@ -1,8 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-from django.conf import settings
-from django.db import migrations, models
+from django.db import migrations
 
 from misago.conf.migrationutils import migrate_settings_group
 
@@ -11,64 +10,66 @@ _ = lambda x: x
 
 
 def create_threads_settings_group(apps, schema_editor):
-    migrate_settings_group(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,
+    migrate_settings_group(
+        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,
                 },
-                '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,
+                {
+                    '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,
                 },
-                '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,
+                {
+                    '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,
                 },
-                'is_public': True,
-            },
-            {
-                '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,
+                {
+                    '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,
                 },
-                'is_public': True,
-            },
-        )
-    })
+            ],
+        }
+    )
 
 
 class Migration(migrations.Migration):

+ 27 - 31
misago/threads/migrations/0003_attachment_types.py

@@ -2,89 +2,85 @@
 # Generated by Django 1.9.7 on 2016-10-04 21:41
 from __future__ import unicode_literals
 
-from django.conf import settings
-from django.db import migrations, models
+from django.db import migrations
 
 
-ATTACHMENTS = (
+ATTACHMENTS = [
     {
         'name': 'GIF',
-        'extensions': ('gif',),
-        'mimetypes': ('image/gif',),
+        'extensions': ('gif', ),
+        'mimetypes': ('image/gif', ),
         'size_limit': 5 * 1024
     },
     {
         'name': 'JPG',
-        'extensions': ('jpg', 'jpeg',),
-        'mimetypes': ('image/jpeg',),
+        'extensions': ('jpg', 'jpeg', ),
+        'mimetypes': ('image/jpeg', ),
         'size_limit': 3 * 1024
     },
     {
         'name': 'PNG',
-        'extensions': ('png',),
-        'mimetypes': ('image/png',),
+        'extensions': ('png', ),
+        'mimetypes': ('image/png', ),
         'size_limit': 3 * 1024
     },
     {
         'name': 'PDF',
-        'extensions': ('pdf',),
+        'extensions': ('pdf', ),
         'mimetypes': (
-            'application/pdf',
-            'application/x-pdf',
-            'application/x-bzpdf',
-            'application/x-gzpdf'
+            'application/pdf', 'application/x-pdf', 'application/x-bzpdf', 'application/x-gzpdf',
         ),
         'size_limit': 4 * 1024
     },
     {
         'name': 'Text',
-        'extensions': ('txt',),
-        'mimetypes': ('text/plain',),
+        'extensions': ('txt', ),
+        'mimetypes': ('text/plain', ),
         'size_limit': 4 * 1024
     },
     {
         'name': 'Markdown',
-        'extensions': ('md',),
-        'mimetypes': ('text/markdown',),
+        'extensions': ('md', ),
+        'mimetypes': ('text/markdown', ),
         'size_limit': 4 * 1024
     },
     {
         'name': 'reStructuredText',
-        'extensions': ('rst',),
-        'mimetypes': ('text/x-rst',),
+        'extensions': ('rst', ),
+        'mimetypes': ('text/x-rst', ),
         'size_limit': 4 * 1024
     },
     {
         'name': '7Z',
-        'extensions': ('7z',),
-        'mimetypes': ('application/x-7z-compressed',),
+        'extensions': ('7z', ),
+        'mimetypes': ('application/x-7z-compressed', ),
         'size_limit': 4 * 1024
     },
     {
         'name': 'RAR',
-        'extensions': ('rar',),
-        'mimetypes': ('application/vnd.rar',),
+        'extensions': ('rar', ),
+        'mimetypes': ('application/vnd.rar', ),
         'size_limit': 4 * 1024
     },
     {
         'name': 'TAR',
-        'extensions': ('tar',),
-        'mimetypes': ('application/x-tar',),
+        'extensions': ('tar', ),
+        'mimetypes': ('application/x-tar', ),
         'size_limit': 4 * 1024
     },
     {
         'name': 'GZ',
-        'extensions': ('gz',),
-        'mimetypes': ('application/gzip',),
+        'extensions': ('gz', ),
+        'mimetypes': ('application/gzip', ),
         'size_limit': 4 * 1024
     },
     {
         'name': 'ZIP',
-        'extensions': ('zip', 'zipx',),
-        'mimetypes': ('application/zip',),
+        'extensions': ('zip', 'zipx', ),
+        'mimetypes': ('application/zip', ),
         'size_limit': 4 * 1024
     },
-)
+]
 
 
 def create_attachment_types(apps, schema_editor):

+ 56 - 54
misago/threads/migrations/0004_update_settings.py

@@ -10,64 +10,66 @@ _ = lambda x: x
 
 
 def update_threads_settings(apps, schema_editor):
-    migrate_settings_group(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,
+    migrate_settings_group(
+        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,
                 },
-                '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,
+                {
+                    '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,
                 },
-                '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,
+                {
+                    '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,
                 },
-                'is_public': True,
-            },
-            {
-                '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,
+                {
+                    '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,
                 },
-                'is_public': True,
-            },
-        )
-    })
+            ],
+        }
+    )
 
     delete_settings_cache()
 

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

@@ -25,25 +25,16 @@ 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)
 
 
 @python_2_unicode_compatible
 class Attachment(models.Model):
     secret = models.CharField(max_length=64)
     filetype = models.ForeignKey('AttachmentType')
-    post = models.ForeignKey(
-        'Post',
-        blank=True,
-        null=True,
-        on_delete=models.SET_NULL
-    )
+    post = models.ForeignKey('Post', blank=True, null=True, on_delete=models.SET_NULL)
 
     uploaded_on = models.DateTimeField(default=timezone.now, db_index=True)
 
@@ -51,7 +42,7 @@ class Attachment(models.Model):
         settings.AUTH_USER_MODEL,
         blank=True,
         null=True,
-        on_delete=models.SET_NULL
+        on_delete=models.SET_NULL,
     )
     uploader_name = models.CharField(max_length=255)
     uploader_slug = models.CharField(max_length=255, db_index=True)
@@ -60,24 +51,9 @@ 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
-    )
-    file = models.FileField(
-        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):
         return self.filename
@@ -107,17 +83,21 @@ class Attachment(models.Model):
         return not self.is_image
 
     def get_absolute_url(self):
-        return reverse('misago:attachment', kwargs={
-            'pk': self.pk,
-            'secret': self.secret,
-        })
+        return reverse(
+            '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,
-            })
+            return reverse(
+                'misago:attachment-thumbnail', kwargs={
+                    'pk': self.pk,
+                    'secret': self.secret,
+                }
+            )
         else:
             return None
 
@@ -131,8 +111,8 @@ class Attachment(models.Model):
 
         thumbnail = Image.open(upload)
         downscale_image = (
-            thumbnail.size[0] > settings.MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT[0] or
-            thumbnail.size[1] > settings.MISAGO_ATTACHMENT_IMAGE_SIZE_LIMIT[1]
+            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'
 

+ 2 - 2
misago/threads/models/attachmenttype.py

@@ -17,11 +17,11 @@ class AttachmentType(models.Model):
     size_limit = models.PositiveIntegerField(default=1024)
     status = models.PositiveIntegerField(
         default=ENABLED,
-        choices=(
+        choices=[
             (ENABLED, _("Allow uploads and downloads")),
             (LOCKED, _("Allow downloads only")),
             (DISABLED, _("Disallow both uploading and downloading")),
-        )
+        ],
     )
 
     limit_uploads_to = models.ManyToManyField('misago_acl.Role', related_name='+', blank=True)

+ 2 - 5
misago/threads/models/poll.py

@@ -37,10 +37,7 @@ class Poll(models.Model):
             self.category_id = thread.category_id
             self.save()
 
-            self.pollvote_set.update(
-                thread=self.thread,
-                category_id=self.category_id
-            )
+            self.pollvote_set.update(thread=self.thread, category_id=self.category_id)
 
     @property
     def ends_on(self):
@@ -96,6 +93,6 @@ class Poll(models.Model):
                 'label': choice['label'],
                 'votes': choice['votes'],
                 'selected': choice['selected'],
-                'proc': proc
+                'proc': proc,
             })
         return view_choices

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

@@ -5,15 +5,12 @@ import copy
 from django.contrib.postgres.fields import JSONField
 from django.contrib.postgres.search import SearchVector, SearchVectorField
 from django.db import models
-from django.dispatch import receiver
-from django.urls import reverse
 from django.utils import six, timezone
 from django.utils.encoding import python_2_unicode_compatible
 
 from misago.conf import settings
 from misago.core.utils import parse_iso8601_string
 from misago.markup import finalise_markup
-from misago.threads import threadtypes
 from misago.threads.checksums import is_post_valid, update_post_checksum
 
 
@@ -85,9 +82,9 @@ class Post(models.Model):
 
     class Meta:
         index_together = [
-            ('thread', 'id'), # speed up threadview for team members
+            ('thread', 'id'),  # speed up threadview for team members
             ('is_event', 'is_hidden'),
-            ('poster', 'posted_on')
+            ('poster', 'posted_on'),
         ]
 
     def __str__(self):
@@ -100,6 +97,9 @@ class Post(models.Model):
         super(Post, self).delete(*args, **kwargs)
 
     def merge(self, other_post):
+        if not self.poster_id or self.poster_id != other_post.poster_id:
+            raise ValueError("post can't be merged with other user's post")
+
         if self.thread_id != other_post.thread_id:
             raise ValueError("only posts belonging to same thread can be merged")
 

+ 1 - 3
misago/threads/models/subscription.py

@@ -13,6 +13,4 @@ class Subscription(models.Model):
     send_email = models.BooleanField(default=False)
 
     class Meta:
-        index_together = [
-            ['send_email', 'last_read_on']
-        ]
+        index_together = [['send_email', 'last_read_on']]

+ 10 - 13
misago/threads/models/thread.py

@@ -1,5 +1,5 @@
 from django.core.exceptions import ObjectDoesNotExist
-from django.db import models, transaction
+from django.db import models
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.translation import ugettext_lazy as _
 
@@ -13,11 +13,11 @@ class Thread(models.Model):
     WEIGHT_PINNED = 1
     WEIGHT_GLOBAL = 2
 
-    WEIGHT_CHOICES = (
+    WEIGHT_CHOICES = [
         (WEIGHT_DEFAULT, _("Don't pin thread")),
         (WEIGHT_PINNED, _("Pin thread within category")),
-        (WEIGHT_GLOBAL, _("Pin thread globally"))
-    )
+        (WEIGHT_GLOBAL, _("Pin thread globally")),
+    ]
 
     category = models.ForeignKey('misago_categories.Category')
     title = models.CharField(max_length=255)
@@ -39,13 +39,13 @@ class Thread(models.Model):
         related_name='+',
         null=True,
         blank=True,
-        on_delete=models.SET_NULL
+        on_delete=models.SET_NULL,
     )
     starter = models.ForeignKey(
         settings.AUTH_USER_MODEL,
         null=True,
         blank=True,
-        on_delete=models.SET_NULL
+        on_delete=models.SET_NULL,
     )
     starter_name = models.CharField(max_length=255)
     starter_slug = models.CharField(max_length=255)
@@ -55,7 +55,7 @@ class Thread(models.Model):
         related_name='+',
         null=True,
         blank=True,
-        on_delete=models.SET_NULL
+        on_delete=models.SET_NULL,
     )
     last_post_is_event = models.BooleanField(default=False)
     last_poster = models.ForeignKey(
@@ -63,7 +63,7 @@ class Thread(models.Model):
         related_name='last_poster_set',
         null=True,
         blank=True,
-        on_delete=models.SET_NULL
+        on_delete=models.SET_NULL,
     )
     last_poster_name = models.CharField(max_length=255, null=True, blank=True)
     last_poster_slug = models.CharField(max_length=255, null=True, blank=True)
@@ -78,7 +78,7 @@ class Thread(models.Model):
         settings.AUTH_USER_MODEL,
         related_name='privatethread_set',
         through='ThreadParticipant',
-        through_fields=('thread', 'user')
+        through_fields=('thread', 'user'),
     )
 
     class Meta:
@@ -119,10 +119,7 @@ class Thread(models.Model):
         except ObjectDoesNotExist:
             self.has_poll = False
 
-        self.replies = self.post_set.filter(
-            is_event=False,
-            is_unapproved=False
-        ).count()
+        self.replies = self.post_set.filter(is_event=False, is_unapproved=False).count()
 
         if self.replies > 0:
             self.replies -= 1

+ 4 - 18
misago/threads/models/threadparticipant.py

@@ -5,35 +5,21 @@ 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)
 
-        ThreadParticipant.objects.create(
-            thread=thread,
-            user=user,
-            is_owner=True
-        )
+        ThreadParticipant.objects.create(thread=thread, user=user, is_owner=True)
 
     def add_participants(self, thread, users):
         bulk = []
         for user in users:
-            bulk.append(ThreadParticipant(
-                thread=thread,
-                user=user,
-                is_owner=False
-            ))
+            bulk.append(ThreadParticipant(thread=thread, user=user, is_owner=False))
 
         ThreadParticipant.objects.bulk_create(bulk)
 
     def remove_participant(self, thread, user):
-        ThreadParticipant.objects.filter(
-            thread=thread,
-            user=user
-        ).delete()
+        ThreadParticipant.objects.filter(thread=thread, user=user).delete()
 
 
 class ThreadParticipant(models.Model):

+ 9 - 7
misago/threads/moderation/posts.py

@@ -64,13 +64,15 @@ def hide_post(user, post):
         post.hidden_by_name = user.username
         post.hidden_by_slug = user.slug
         post.hidden_on = timezone.now()
-        post.save(update_fields=[
-            'is_hidden',
-            'hidden_by',
-            'hidden_by_name',
-            'hidden_by_slug',
-            'hidden_on',
-        ])
+        post.save(
+            update_fields=[
+                'is_hidden',
+                'hidden_by',
+                'hidden_by_name',
+                'hidden_by_slug',
+                'hidden_on',
+            ]
+        )
         return True
     else:
         return False

+ 17 - 14
misago/threads/moderation/threads.py

@@ -1,6 +1,5 @@
 from django.db.transaction import atomic
 from django.utils import timezone
-from django.utils.translation import ugettext as _
 
 from misago.threads.events import record_event
 
@@ -35,7 +34,7 @@ def change_thread_title(request, thread, new_title):
         thread.first_post.save(update_fields=['search_vector'])
 
         record_event(request, thread, 'changed_title', {
-            'old_title': old_title
+            'old_title': old_title,
         })
         return True
     else:
@@ -78,12 +77,14 @@ def move_thread(request, thread, new_category):
         from_category = thread.category
         thread.move(new_category)
 
-        record_event(request, thread, 'moved', {
-            'from_category': {
-                'name': from_category.name,
-                'url': from_category.get_absolute_url(),
+        record_event(
+            request, thread, 'moved', {
+                'from_category': {
+                    'name': from_category.name,
+                    'url': from_category.get_absolute_url(),
+                },
             }
-        })
+        )
         return True
     else:
         return False
@@ -154,13 +155,15 @@ def hide_thread(request, thread):
         thread.first_post.hidden_by_name = request.user.username
         thread.first_post.hidden_by_slug = request.user.slug
         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',
-        ])
+        thread.first_post.save(
+            update_fields=[
+                'is_hidden',
+                'hidden_by',
+                'hidden_by_name',
+                'hidden_by_slug',
+                'hidden_on',
+            ]
+        )
         thread.is_hidden = True
 
         record_event(request, thread, 'hid')

+ 6 - 14
misago/threads/paginator.py

@@ -1,26 +1,18 @@
-from math import ceil, floor
-
 from django.core.paginator import Paginator
-from django.utils.functional import cached_property
 
 
 class PostsPaginator(Paginator):
-    """
-    Paginator that returns that makes last item on page
-    repeat as first item on next page.
-    """
-    def __init__(self, object_list, per_page, orphans=0,
-                 allow_empty_first_page=True):
+    """paginator that returns that makes last item on page repeat as first item on next page."""
+
+    def __init__(self, object_list, per_page, orphans=0, allow_empty_first_page=True):
         per_page = int(per_page) - 1
         if orphans:
             orphans += 1
-        super(PostsPaginator, self).__init__(
-            object_list, per_page, orphans, allow_empty_first_page)
+        super(PostsPaginator,
+              self).__init__(object_list, per_page, orphans, allow_empty_first_page)
 
     def page(self, number):
-        """
-        Returns a Page object for the given 1-based page number.
-        """
+        """returns a Page object for the given 1-based page number."""
         number = self.validate_number(number)
         bottom = (number - 1) * self.per_page
         top = bottom + self.per_page

+ 45 - 48
misago/threads/participants.py

@@ -1,7 +1,6 @@
 from django.contrib.auth import get_user_model
 from django.utils.translation import ugettext as _
 
-from misago.core import deprecations
 from misago.core.mail import build_mail, send_messages
 
 from .events import record_event
@@ -30,7 +29,7 @@ def make_threads_participants_aware(user, threads):
 
     participants_qs = ThreadParticipant.objects.filter(
         user=user,
-        thread_id__in=threads_dict.keys()
+        thread_id__in=threads_dict.keys(),
     )
 
     for participant in participants_qs:
@@ -52,8 +51,7 @@ 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]
@@ -65,60 +63,60 @@ def set_users_unread_private_threads_sync(
     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):
-    """
-    Set user as thread's owner
-    """
     ThreadParticipant.objects.set_owner(thread, user)
 
 
 def change_owner(request, thread, user):
-    """
-    Replace thread's owner with other
-    """
     ThreadParticipant.objects.set_owner(thread, user)
     set_users_unread_private_threads_sync(
         participants=thread.participants_list,
-        exclude_user=request.user
+        exclude_user=request.user,
     )
 
     if thread.participant and thread.participant.is_owner:
-        record_event(request, thread, 'changed_owner', {
-            'user': {
-                'username': user.username,
-                'url': user.get_absolute_url(),
-            }
-        })
+        record_event(
+            request,
+            thread,
+            'changed_owner',
+            {
+                'user': {
+                    'username': user.username,
+                    'url': user.get_absolute_url(),
+                },
+            },
+        )
     else:
         record_event(request, thread, 'tookover')
 
 
 def add_participant(request, thread, user):
-    """
-    Adds single participant to thread, registers this on the event
-    """
+    """adds single participant to thread, registers this on the event"""
     add_participants(request, thread, [user])
 
     if request.user == user:
         record_event(request, thread, 'entered_thread')
     else:
-        record_event(request, thread, 'added_participant', {
-            'user': {
-                'username': user.username,
-                'url': user.get_absolute_url(),
-            }
-        })
+        record_event(
+            request,
+            thread,
+            'added_participant',
+            {
+                'user': {
+                    'username': user.username,
+                    'url': user.get_absolute_url(),
+                },
+            },
+        )
 
 
 def add_participants(request, thread, users):
     """
     Add multiple participants to thread, set "recound private threads" flag on them
-    notify them about being added to thread
+    notify them about being added to thread.
     """
     ThreadParticipant.objects.add_participants(thread, users)
 
@@ -130,7 +128,7 @@ def add_participants(request, thread, users):
     set_users_unread_private_threads_sync(
         users=users,
         participants=thread_participants,
-        exclude_user=request.user
+        exclude_user=request.user,
     )
 
     emails = []
@@ -145,24 +143,18 @@ 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
+        'user': request.user.username,
     }
 
     return build_mail(
-        request,
-        user,
-        subject % subject_formats,
-        'misago/emails/privatethread/added',
-        {
-            'thread': thread
+        request, user, subject % subject_formats, 'misago/emails/privatethread/added', {
+            'thread': thread,
         }
     )
 
 
 def remove_participant(request, thread, user):
-    """
-    Remove thread participant, set "recound private threads" flag on user
-    """
+    """remove thread participant, set "recound private threads" flag on user"""
     removed_owner = False
     remaining_participants = []
 
@@ -181,7 +173,7 @@ def remove_participant(request, thread, user):
         thread.subscription_set.filter(user=user).delete()
 
         if removed_owner:
-            thread.is_closed = True # flag thread to close
+            thread.is_closed = True  # flag thread to close
 
             if request.user == user:
                 event_type = 'owner_left'
@@ -193,9 +185,14 @@ def remove_participant(request, thread, user):
             else:
                 event_type = 'removed_participant'
 
-        record_event(request, thread, event_type, {
-            'user': {
-                'username': user.username,
-                'url': user.get_absolute_url(),
-            }
-        })
+        record_event(
+            request,
+            thread,
+            event_type,
+            {
+                'user': {
+                    'username': user.username,
+                    'url': user.get_absolute_url(),
+                },
+            },
+        )

+ 9 - 12
misago/threads/permissions/attachments.py

@@ -7,9 +7,7 @@ from misago.core.forms import YesNoSwitch
 from misago.threads.models import Attachment
 
 
-"""
-Admin Permissions Form
-"""
+# Admin Permissions Forms
 class PermissionsForm(forms.Form):
     legend = _("Attachments")
 
@@ -20,7 +18,9 @@ class PermissionsForm(forms.Form):
         min_value=0
     )
 
-    can_download_other_users_attachments = YesNoSwitch(label=_("Can download other users attachments"))
+    can_download_other_users_attachments = YesNoSwitch(
+        label=_("Can download other users attachments")
+    )
     can_delete_other_users_attachments = YesNoSwitch(label=_("Can delete other users attachments"))
 
 
@@ -40,9 +40,6 @@ def change_permissions_form(role):
         return None
 
 
-"""
-ACL Builder
-"""
 def build_acl(acl, roles, key_name):
     new_acl = {
         'max_attachment_size': 0,
@@ -51,16 +48,16 @@ def build_acl(acl, roles, key_name):
     }
     new_acl.update(acl)
 
-    return algebra.sum_acls(new_acl, roles=roles, key=key_name,
+    return algebra.sum_acls(
+        new_acl,
+        roles=roles,
+        key=key_name,
         max_attachment_size=algebra.greater,
         can_download_other_users_attachments=algebra.greater,
-        can_delete_other_users_attachments=algebra.greater
+        can_delete_other_users_attachments=algebra.greater,
     )
 
 
-"""
-ACL's for targets
-"""
 def add_acl_to_attachment(user, attachment):
     if user.is_authenticated and user.id == attachment.uploader_id:
         attachment.acl.update({

+ 51 - 40
misago/threads/permissions/polls.py

@@ -25,9 +25,6 @@ __all__ = [
 ]
 
 
-"""
-Admin Permissions Forms
-"""
 class RolePermissionsForm(forms.Form):
     legend = _("Polls")
 
@@ -35,41 +32,41 @@ class RolePermissionsForm(forms.Form):
         label=_("Can start polls"),
         coerce=int,
         initial=0,
-        choices=(
+        choices=[
             (0, _("No")),
             (1, _("Own threads")),
-            (2, _("All threads"))
-        )
+            (2, _("All threads")),
+        ],
     )
     can_edit_polls = forms.TypedChoiceField(
         label=_("Can edit polls"),
         coerce=int,
         initial=0,
-        choices=(
+        choices=[
             (0, _("No")),
             (1, _("Own polls")),
-            (2, _("All polls"))
-        )
+            (2, _("All polls")),
+        ],
     )
     can_delete_polls = forms.TypedChoiceField(
         label=_("Can delete polls"),
         coerce=int,
         initial=0,
-        choices=(
+        choices=[
             (0, _("No")),
             (1, _("Own polls")),
-            (2, _("All polls"))
-        )
+            (2, _("All polls")),
+        ],
     )
     poll_edit_time = forms.IntegerField(
         label=_("Time limit for own polls edits, in minutes"),
         help_text=_("Enter 0 to don't limit time for editing own polls."),
         initial=0,
-        min_value=0
+        min_value=0,
     )
     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."),
     )
 
 
@@ -80,19 +77,19 @@ def change_permissions_form(role):
         return None
 
 
-"""
-ACL Builder
-"""
 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
+        'can_always_see_poll_voters': 0,
     })
 
-    return algebra.sum_acls(acl, roles=roles, key=key_name,
+    return algebra.sum_acls(
+        acl,
+        roles=roles,
+        key=key_name,
         can_start_polls=algebra.greater,
         can_edit_polls=algebra.greater,
         can_delete_polls=algebra.greater,
@@ -101,9 +98,6 @@ def build_acl(acl, roles, key_name):
     )
 
 
-"""
-ACL's for targets
-"""
 def add_acl_to_poll(user, poll):
     poll.acl.update({
         'can_vote': can_vote_poll(user, poll),
@@ -115,7 +109,7 @@ def add_acl_to_poll(user, poll):
 
 def add_acl_to_thread(user, thread):
     thread.acl.update({
-        'can_start_poll': can_start_poll(user, thread)
+        'can_start_poll': can_start_poll(user, thread),
     })
 
 
@@ -124,16 +118,15 @@ def register_with(registry):
     registry.acl_annotator(Thread, add_acl_to_thread)
 
 
-"""
-ACL tests
-"""
 def allow_start_poll(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to start polls."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_close_threads': False,
-    })
+    category_acl = user.acl_cache['categories'].get(
+        target.category_id, {
+            'can_close_threads': False,
+        }
+    )
 
     if not user.acl_cache.get('can_start_polls'):
         raise PermissionDenied(_("You can't start polls."))
@@ -145,6 +138,8 @@ def allow_start_poll(user, target):
             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."))
+
+
 can_start_poll = return_boolean(allow_start_poll)
 
 
@@ -152,9 +147,11 @@ def allow_edit_poll(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to edit polls."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_close_threads': False,
-    })
+    category_acl = user.acl_cache['categories'].get(
+        target.category_id, {
+            'can_close_threads': False,
+        }
+    )
 
     if not user.acl_cache.get('can_edit_polls'):
         raise PermissionDenied(_("You can't edit polls."))
@@ -166,7 +163,8 @@ def allow_edit_poll(user, target):
             message = ungettext(
                 "You can't edit polls that are older than %(minutes)s minute.",
                 "You can't edit polls that are older than %(minutes)s minutes.",
-                user.acl_cache['poll_edit_time'])
+                user.acl_cache['poll_edit_time']
+            )
             raise PermissionDenied(message % {'minutes': user.acl_cache['poll_edit_time']})
 
         if target.is_over:
@@ -177,6 +175,8 @@ def allow_edit_poll(user, target):
             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."))
+
+
 can_edit_poll = return_boolean(allow_edit_poll)
 
 
@@ -184,9 +184,11 @@ def allow_delete_poll(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to delete polls."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_close_threads': False,
-    })
+    category_acl = user.acl_cache['categories'].get(
+        target.category_id, {
+            'can_close_threads': False,
+        }
+    )
 
     if not user.acl_cache.get('can_delete_polls'):
         raise PermissionDenied(_("You can't delete polls."))
@@ -198,7 +200,8 @@ def allow_delete_poll(user, target):
             message = ungettext(
                 "You can't delete polls that are older than %(minutes)s minute.",
                 "You can't delete polls that are older than %(minutes)s minutes.",
-                user.acl_cache['poll_edit_time'])
+                user.acl_cache['poll_edit_time']
+            )
             raise PermissionDenied(message % {'minutes': user.acl_cache['poll_edit_time']})
         if target.is_over:
             raise PermissionDenied(_("This poll is over. You can't delete it."))
@@ -208,6 +211,8 @@ def allow_delete_poll(user, target):
             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."))
+
+
 can_delete_poll = return_boolean(allow_delete_poll)
 
 
@@ -220,21 +225,27 @@ def allow_vote_poll(user, target):
     if target.is_over:
         raise PermissionDenied(_("This poll is over. You can't vote in it."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_close_threads': False,
-    })
+    category_acl = user.acl_cache['categories'].get(
+        target.category_id, {
+            'can_close_threads': False,
+        }
+    )
 
     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:
             raise PermissionDenied(_("This thread is closed. You can't vote in it."))
+
+
 can_vote_poll = return_boolean(allow_vote_poll)
 
 
 def allow_see_poll_votes(user, target):
     if not target.is_public and not user.acl_cache['can_always_see_poll_voters']:
         raise PermissionDenied(_("You dont have permission to this poll's voters."))
+
+
 can_see_poll_votes = return_boolean(allow_see_poll_votes)
 
 

+ 41 - 36
misago/threads/permissions/privatethreads.py

@@ -1,11 +1,9 @@
 from django import forms
-from django.contrib.auth import get_user_model
 from django.core.exceptions import PermissionDenied
-from django.db.models import Q
 from django.http import Http404
 from django.utils.translation import ugettext_lazy as _
 
-from misago.acl import add_acl, algebra
+from misago.acl import algebra
 from misago.acl.decorators import return_boolean
 from misago.acl.models import Role
 from misago.categories import PRIVATE_THREADS_ROOT_NAME
@@ -32,9 +30,6 @@ __all__ = [
 ]
 
 
-"""
-Admin Permissions Form
-"""
 class PermissionsForm(forms.Form):
     legend = _("Private threads")
 
@@ -44,21 +39,25 @@ class PermissionsForm(forms.Form):
         label=_("Max number of users invited to private thread"),
         help_text=_("Enter 0 to don't limit number of participants."),
         initial=3,
-        min_value=0
+        min_value=0,
     )
     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"),
-        help_text=_("Allows user to report private threads they are "
-                    "participating in, making them accessible to moderators.")
+        help_text=_(
+            "Allows user to report private threads they are "
+            "participating in, making them accessible to moderators."
+        ),
     )
     can_moderate_private_threads = YesNoSwitch(
         label=_("Can moderate private threads"),
-        help_text=_("Allows user to read, reply, edit and delete content "
-                    "in reported private threads.")
+        help_text=_(
+            "Allows user to read, reply, edit and delete content "
+            "in reported private threads."
+        ),
     )
 
 
@@ -69,9 +68,6 @@ def change_permissions_form(role):
         return None
 
 
-"""
-ACL Builder
-"""
 def build_acl(acl, roles, key_name):
     new_acl = {
         'can_use_private_threads': 0,
@@ -84,7 +80,10 @@ def build_acl(acl, roles, key_name):
 
     new_acl.update(acl)
 
-    algebra.sum_acls(new_acl, roles=roles, key=key_name,
+    algebra.sum_acls(
+        new_acl,
+        roles=roles,
+        key=key_name,
         can_use_private_threads=algebra.greater,
         can_start_private_threads=algebra.greater,
         max_private_thread_participants=algebra.greater_or_zero,
@@ -171,14 +170,13 @@ def register_with(registry):
     registry.acl_annotator(Thread, add_acl_to_thread)
 
 
-"""
-ACL tests
-"""
 def allow_use_private_threads(user):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to use private threads."))
     if not user.acl_cache['can_use_private_threads']:
         raise PermissionDenied(_("You can't use private threads."))
+
+
 can_use_private_threads = return_boolean(allow_use_private_threads)
 
 
@@ -192,6 +190,8 @@ def allow_see_private_thread(user, target):
 
     if not (can_see_participating or can_see_reported):
         raise Http404()
+
+
 can_see_private_thread = return_boolean(allow_see_private_thread)
 
 
@@ -200,12 +200,12 @@ def allow_change_owner(user, target):
     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."))
+        raise PermissionDenied(_("Only moderators can change closed threads owners."))
+
+
 can_change_owner = return_boolean(allow_change_owner)
 
 
@@ -214,19 +214,18 @@ def allow_add_participants(user, target):
 
     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_cache['max_private_thread_participants']
     current_participants = len(target.participants_list) - 1
 
     if current_participants >= max_participants:
-        raise PermissionDenied(
-            _("You can't add any more new users to this thread."))
+        raise PermissionDenied(_("You can't add any more new users to this thread."))
+
+
 can_add_participants = return_boolean(allow_add_participants)
 
 
@@ -235,15 +234,15 @@ def allow_remove_participant(user, thread, target):
         return
 
     if user == target:
-        return # we can always remove ourselves
+        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)
 
 
@@ -252,7 +251,8 @@ def allow_add_participant(user, target):
 
     if not can_use_private_threads(target):
         raise PermissionDenied(
-            _("%(user)s can't participate in private threads.") % message_format)
+            _("%(user)s can't participate in private threads.") % message_format
+        )
 
     if user.acl_cache['can_add_everyone_to_private_threads']:
         return
@@ -262,15 +262,20 @@ def allow_add_participant(user, target):
 
     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):
         message = _("%(user)s limits invitations to private threads to followed users.")
         raise PermissionDenied(message % message_format)
+
+
 can_add_participant = return_boolean(allow_add_participant)
 
 
 def allow_message_user(user, target):
     allow_use_private_threads(user)
     allow_add_participant(user, target)
+
+
 can_message_user = return_boolean(allow_message_user)

+ 171 - 119
misago/threads/permissions/threads.py

@@ -9,7 +9,7 @@ from django.utils.translation import ungettext
 from misago.acl import add_acl, algebra
 from misago.acl.decorators import return_boolean
 from misago.acl.models import Role
-from misago.categories.models import Category, CategoryRole, RoleCategoryACL
+from misago.categories.models import Category, CategoryRole
 from misago.categories.permissions import get_categories_roles
 from misago.core.forms import YesNoSwitch
 from misago.threads.models import Post, Thread
@@ -47,33 +47,34 @@ __all__ = [
 ]
 
 
-"""
-Admin Permissions Forms
-"""
 class RolePermissionsForm(forms.Form):
     legend = _("Threads")
 
     can_see_unapproved_content_lists = YesNoSwitch(
         label=_("Can see unapproved content list"),
-        help_text=_('Allows access to "unapproved" tab on threads lists for '
-                    "easy listing of threads that are unapproved or contain "
-                    "unapproved posts. Despite the tab being available on all "
-                    "threads lists, it will only display threads belonging to "
-                    "categories in which the user has permission to approve "
-                    "content.")
+        help_text=_(
+            'Allows access to "unapproved" tab on threads lists for '
+            "easy listing of threads that are unapproved or contain "
+            "unapproved posts. Despite the tab being available on all "
+            "threads lists, it will only display threads belonging to "
+            "categories in which the user has permission to approve "
+            "content."
+        ),
     )
     can_see_reported_content_lists = YesNoSwitch(
         label=_("Can see reported content list"),
-        help_text=_('Allows access to "reported" tab on threads lists for '
-                    "easy listing of threads that contain reported posts. "
-                    "Despite the tab being available on all categories "
-                    "threads lists, it will only display threads belonging to "
-                    "categories in which the user has permission to see posts "
-                    "reports.")
+        help_text=_(
+            'Allows access to "reported" tab on threads lists for '
+            "easy listing of threads that contain reported posts. "
+            "Despite the tab being available on all categories "
+            "threads lists, it will only display threads belonging to "
+            "categories in which the user has permission to see posts "
+            "reports."
+        ),
     )
     can_omit_flood_protection = YesNoSwitch(
         label=_("Can omit flood protection"),
-        help_text=_("Allows posting more frequently than flood protection would.")
+        help_text=_("Allows posting more frequently than flood protection would."),
     )
 
 
@@ -84,7 +85,10 @@ 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"))
@@ -94,46 +98,52 @@ 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"),
-        help_text=_("Only threads started within time limit and "
-                    "with no replies can be hidden."),
+        help_text=_(
+            "Only threads started within time limit and "
+            "with no replies can be hidden."
+        ),
         coerce=int,
         initial=0,
-        choices=(
+        choices=[
             (0, _("No")),
             (1, _("Hide threads")),
-            (2, _("Delete threads"))
-        )
+            (2, _("Delete threads")),
+        ],
     )
     thread_edit_time = forms.IntegerField(
         label=_("Time limit for own threads edits, in minutes"),
         help_text=_("Enter 0 to don't limit time for editing own threads."),
         initial=0,
-        min_value=0
+        min_value=0,
     )
     can_hide_threads = forms.TypedChoiceField(
         label=_("Can hide all threads"),
         coerce=int,
         initial=0,
-        choices=(
+        choices=[
             (0, _("No")),
             (1, _("Hide threads")),
-            (2, _("Delete threads"))
-        )
+            (2, _("Delete threads")),
+        ],
     )
 
     can_pin_threads = forms.TypedChoiceField(
         label=_("Can pin threads"),
         coerce=int,
         initial=0,
-        choices=(
+        choices=[
             (0, _("No")),
             (1, _("Locally")),
-            (2, _("Globally"))
-        )
+            (2, _("Globally")),
+        ],
     )
     can_close_threads = YesNoSwitch(label=_("Can close threads"))
     can_move_threads = YesNoSwitch(label=_("Can move threads"))
@@ -143,63 +153,66 @@ 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."),
         coerce=int,
         initial=0,
-        choices=(
+        choices=[
             (0, _("No")),
             (1, _("Hide posts")),
-            (2, _("Delete posts"))
-        )
+            (2, _("Delete posts")),
+        ],
     )
     post_edit_time = forms.IntegerField(
         label=_("Time limit for own post edits, in minutes"),
         help_text=_("Enter 0 to don't limit time for editing own posts."),
         initial=0,
-        min_value=0
+        min_value=0,
     )
     can_hide_posts = forms.TypedChoiceField(
         label=_("Can hide all posts"),
         coerce=int,
         initial=0,
-        choices=(
+        choices=[
             (0, _("No")),
             (1, _("Hide posts")),
-            (2, _("Delete posts"))
-        )
+            (2, _("Delete posts")),
+        ],
     )
 
     can_see_posts_likes = forms.TypedChoiceField(
         label=_("Can see posts likes"),
         coerce=int,
         initial=0,
-        choices=(
+        choices=[
             (0, _("No")),
             (1, _("Number only")),
-            (2, _("Number and list of likers"))
-        )
+            (2, _("Number and list of likers")),
+        ],
     )
     can_like_posts = YesNoSwitch(
         label=_("Can like posts"),
-        help_text=_("Only users with this permission to see likes can like posts.")
+        help_text=_("Only users with this permission to see likes can like posts."),
     )
 
     can_protect_posts = YesNoSwitch(
         label=_("Can protect posts"),
-        help_text=_("Only users with this permission can edit protected posts.")
+        help_text=_("Only users with this permission can edit protected posts."),
     )
     can_move_posts = YesNoSwitch(
-        label=_("Can move posts"),
-        help_text=_("Will be able to move posts to other threads.")
+        label=_("Can move posts"), help_text=_("Will be able to move posts to other threads.")
     )
     can_merge_posts = YesNoSwitch(label=_("Can merge posts"))
     can_approve_content = YesNoSwitch(
         label=_("Can approve content"),
-        help_text=_("Will be able to see and approve unapproved content.")
+        help_text=_("Will be able to see and approve unapproved content."),
     )
     can_report_content = YesNoSwitch(label=_("Can report posts"))
     can_see_reports = YesNoSwitch(label=_("Can see reports"))
@@ -208,11 +221,11 @@ class CategoryPermissionsForm(forms.Form):
         label=_("Can hide events"),
         coerce=int,
         initial=0,
-        choices=(
+        choices=[
             (0, _("No")),
             (1, _("Hide events")),
-            (2, _("Delete events"))
-        )
+            (2, _("Delete events")),
+        ],
     )
 
 
@@ -225,9 +238,6 @@ def change_permissions_form(role):
         return None
 
 
-"""
-ACL Builder
-"""
 def build_acl(acl, roles, key_name):
     acl.update({
         'can_see_unapproved_content_lists': False,
@@ -237,7 +247,10 @@ def build_acl(acl, roles, key_name):
         'can_see_reports': [],
     })
 
-    acl = algebra.sum_acls(acl, roles=roles, key=key_name,
+    acl = algebra.sum_acls(
+        acl,
+        roles=roles,
+        key=key_name,
         can_see_unapproved_content_lists=algebra.greater,
         can_see_reported_content_lists=algebra.greater,
         can_omit_flood_protection=algebra.greater
@@ -250,7 +263,8 @@ def build_acl(acl, roles, key_name):
         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)
+                category_acl, category, categories_roles, key_name
+            )
 
             if category_acl.get('can_approve_content'):
                 acl['can_approve_content'].append(category.pk)
@@ -291,7 +305,10 @@ def build_category_acl(acl, category, categories_roles, key_name):
     }
     final_acl.update(acl)
 
-    algebra.sum_acls(final_acl, roles=category_roles, key=key_name,
+    algebra.sum_acls(
+        final_acl,
+        roles=category_roles,
+        key=key_name,
         can_see_all_threads=algebra.greater,
         can_start_threads=algebra.greater,
         can_reply_threads=algebra.greater,
@@ -321,9 +338,6 @@ def build_category_acl(acl, category, categories_roles, key_name):
     return final_acl
 
 
-"""
-ACL's for targets
-"""
 def add_acl_to_category(user, category):
     category_acl = user.acl_cache['categories'].get(category.pk, {})
 
@@ -355,13 +369,17 @@ def add_acl_to_category(user, category):
         'can_hide_events': 0,
     })
 
-    algebra.sum_acls(category.acl, acls=[category_acl],
+    algebra.sum_acls(
+        category.acl,
+        acls=[category_acl],
         can_see_all_threads=algebra.greater,
         can_see_posts_likes=algebra.greater,
     )
 
     if user.is_authenticated:
-        algebra.sum_acls(category.acl, acls=[category_acl],
+        algebra.sum_acls(
+            category.acl,
+            acls=[category_acl],
             can_start_threads=algebra.greater,
             can_reply_threads=algebra.greater,
             can_edit_threads=algebra.greater,
@@ -477,14 +495,13 @@ def register_with(registry):
     registry.acl_annotator(Post, add_acl_to_post)
 
 
-"""
-ACL tests
-"""
 def allow_see_thread(user, target):
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_see': False,
-        'can_browse': False
-    })
+    category_acl = user.acl_cache['categories'].get(
+        target.category_id, {
+            'can_see': False,
+            'can_browse': False,
+        }
+    )
 
     if not (category_acl['can_see'] and category_acl['can_browse']):
         raise Http404()
@@ -498,6 +515,8 @@ def allow_see_thread(user, target):
 
         if target.is_unapproved and not category_acl['can_approve_content']:
             raise Http404()
+
+
 can_see_thread = return_boolean(allow_see_thread)
 
 
@@ -505,16 +524,22 @@ def allow_start_thread(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to start threads."))
 
-    category_acl = user.acl_cache['categories'].get(target.pk, {
-        'can_close_threads': False,
-        'can_start_threads': False
-    })
+    category_acl = user.acl_cache['categories'].get(
+        target.pk, {
+            'can_close_threads': False,
+            'can_start_threads': False,
+        }
+    )
 
     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 not category_acl['can_start_threads']:
-        raise PermissionDenied(_("You don't have permission to start new threads in this category."))
+        raise PermissionDenied(
+            _("You don't have permission to start new threads in this category.")
+        )
+
+
 can_start_thread = return_boolean(allow_start_thread)
 
 
@@ -522,10 +547,12 @@ def allow_reply_thread(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to reply threads."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_close_threads': False,
-        'can_reply_threads': False
-    })
+    category_acl = user.acl_cache['categories'].get(
+        target.category_id, {
+            'can_close_threads': False,
+            'can_reply_threads': False,
+        }
+    )
 
     if not category_acl['can_close_threads']:
         if target.category.is_closed:
@@ -535,6 +562,8 @@ def allow_reply_thread(user, target):
 
     if not category_acl['can_reply_threads']:
         raise PermissionDenied(_("You can't reply to threads in this category."))
+
+
 can_reply_thread = return_boolean(allow_reply_thread)
 
 
@@ -542,9 +571,9 @@ def allow_edit_thread(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to edit threads."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_edit_threads': False
-    })
+    category_acl = user.acl_cache['categories'].get(
+        target.category_id, {'can_edit_threads': False}
+    )
 
     if not category_acl['can_edit_threads']:
         raise PermissionDenied(_("You can't edit threads in this category."))
@@ -563,16 +592,21 @@ def allow_edit_thread(user, target):
             message = ungettext(
                 "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']})
+
+
 can_edit_thread = return_boolean(allow_edit_thread)
 
 
 def allow_see_post(user, target):
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_approve_content': False,
-        'can_hide_events': False
-    })
+    category_acl = user.acl_cache['categories'].get(
+        target.category_id, {
+            'can_approve_content': False,
+            'can_hide_events': False,
+        }
+    )
 
     if not target.is_event and target.is_unapproved:
         if user.is_anonymous:
@@ -583,6 +617,8 @@ def allow_see_post(user, target):
 
     if target.is_event and target.is_hidden and not category_acl['can_hide_events']:
         raise Http404()
+
+
 can_see_post = return_boolean(allow_see_post)
 
 
@@ -593,9 +629,7 @@ def allow_edit_post(user, target):
     if target.is_event:
         raise PermissionDenied(_("Events can't be edited."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_edit_posts': False
-    })
+    category_acl = user.acl_cache['categories'].get(target.category_id, {'can_edit_posts': False})
 
     if not category_acl['can_edit_posts']:
         raise PermissionDenied(_("You can't edit posts in this category."))
@@ -620,8 +654,11 @@ def allow_edit_post(user, target):
             message = ungettext(
                 "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']})
+
+
 can_edit_post = return_boolean(allow_edit_post)
 
 
@@ -629,10 +666,12 @@ def allow_unhide_post(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to reveal posts."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_hide_posts': 0,
-        'can_hide_own_posts': 0
-    })
+    category_acl = user.acl_cache['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']:
@@ -654,11 +693,14 @@ def allow_unhide_post(user, target):
             message = ungettext(
                 "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']})
 
     if target.is_first_post:
         raise PermissionDenied(_("You can't reveal thread's first post."))
+
+
 can_unhide_post = return_boolean(allow_unhide_post)
 
 
@@ -666,10 +708,12 @@ def allow_hide_post(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to hide posts."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_hide_posts': 0,
-        'can_hide_own_posts': 0
-    })
+    category_acl = user.acl_cache['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']:
@@ -691,11 +735,14 @@ def allow_hide_post(user, target):
             message = ungettext(
                 "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']})
 
     if target.is_first_post:
         raise PermissionDenied(_("You can't hide thread's first post."))
+
+
 can_hide_post = return_boolean(allow_hide_post)
 
 
@@ -703,10 +750,12 @@ def allow_delete_post(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to delete posts."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_hide_posts': 0,
-        'can_hide_own_posts': 0
-    })
+    category_acl = user.acl_cache['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:
@@ -728,11 +777,14 @@ def allow_delete_post(user, target):
             message = ungettext(
                 "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']})
 
     if target.is_first_post:
         raise PermissionDenied(_("You can't delete thread's first post."))
+
+
 can_delete_post = return_boolean(allow_delete_post)
 
 
@@ -740,14 +792,16 @@ def allow_protect_post(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to protect posts."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_protect_posts': False
-    })
+    category_acl = user.acl_cache['categories'].get(
+        target.category_id, {'can_protect_posts': False}
+    )
 
     if not category_acl['can_protect_posts']:
         raise PermissionDenied(_("You can't protect posts in this category."))
     if not can_edit_post(user, target):
         raise PermissionDenied(_("You can't protect posts you can't edit."))
+
+
 can_protect_post = return_boolean(allow_protect_post)
 
 
@@ -755,9 +809,9 @@ def allow_approve_post(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to approve posts."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_approve_content': False
-    })
+    category_acl = user.acl_cache['categories'].get(
+        target.category_id, {'can_approve_content': False}
+    )
 
     if not category_acl['can_approve_content']:
         raise PermissionDenied(_("You can't approve posts in this category."))
@@ -765,6 +819,8 @@ def allow_approve_post(user, target):
         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:
         raise PermissionDenied(_("You can't approve posts the content you can't see."))
+
+
 can_approve_post = return_boolean(allow_approve_post)
 
 
@@ -772,9 +828,7 @@ def allow_move_post(user, target):
     if user.is_anonymous:
         raise PermissionDenied(_("You have to sign in to move posts."))
 
-    category_acl = user.acl_cache['categories'].get(target.category_id, {
-        'can_move_posts': False
-    })
+    category_acl = user.acl_cache['categories'].get(target.category_id, {'can_move_posts': False})
 
     if not category_acl['can_move_posts']:
         raise PermissionDenied(_("You can't move posts in this category."))
@@ -784,6 +838,8 @@ def allow_move_post(user, target):
         raise PermissionDenied(_("You can't move thread's first post."))
     if not category_acl['can_hide_posts'] and target.is_hidden:
         raise PermissionDenied(_("You can't move posts the content you can't see."))
+
+
 can_move_post = return_boolean(allow_move_post)
 
 
@@ -795,12 +851,11 @@ def allow_delete_event(user, target):
 
     if not category_acl or category_acl['can_hide_events'] != 2:
         raise PermissionDenied(_("You can't delete events in this category."))
+
+
 can_delete_event = return_boolean(allow_delete_event)
 
 
-"""
-Permission check helpers
-"""
 def can_change_owned_thread(user, target):
     if user.is_anonymous or user.pk != target.starter_id:
         return False
@@ -833,9 +888,6 @@ def has_time_to_edit_post(user, target):
         return True
 
 
-"""
-Queryset helpers
-"""
 def exclude_invisible_threads(user, categories, queryset):
     show_all = []
     show_accepted_visible = []

+ 13 - 8
misago/threads/search.py

@@ -22,14 +22,19 @@ class SearchThreads(SearchProvider):
 
         if len(query) > 2:
             visible_threads = exclude_invisible_threads(
-                self.request.user, threads_categories, Thread.objects)
+                self.request.user, threads_categories, Thread.objects
+            )
             results = search_threads(self.request, query, visible_threads)
         else:
             results = []
 
         list_page = paginate(
-            results, page, settings.MISAGO_POSTS_PER_PAGE, settings.MISAGO_POSTS_TAIL,
-            allow_explicit_first_page=True)
+            results,
+            page,
+            settings.MISAGO_POSTS_PER_PAGE,
+            settings.MISAGO_POSTS_TAIL,
+            allow_explicit_first_page=True,
+        )
         paginator = pagination_dict(list_page)
 
         posts = list(list_page.object_list)
@@ -38,12 +43,12 @@ class SearchThreads(SearchProvider):
         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)
 
@@ -59,5 +64,5 @@ def search_threads(request, query, visible_threads):
         is_hidden=False,
         is_unapproved=False,
         thread_id__in=visible_threads.values('id'),
-        search_vector=search_query
+        search_vector=search_query,
     ).annotate(rank=SearchRank(search_vector, search_query)).order_by('-rank', '-id')

+ 8 - 9
misago/threads/serializers/attachment.py

@@ -2,7 +2,6 @@ from rest_framework import serializers
 
 from django.urls import reverse
 
-from misago.core.utils import format_plaintext_for_html
 from misago.threads.models import Attachment
 
 
@@ -21,7 +20,7 @@ class AttachmentSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Attachment
-        fields = (
+        fields = [
             'id',
             'filetype',
             'post',
@@ -30,12 +29,10 @@ class AttachmentSerializer(serializers.ModelSerializer):
             'uploader_ip',
             'filename',
             'size',
-
             'acl',
             'is_image',
-
             'url',
-        )
+        ]
 
     def get_acl(self, obj):
         try:
@@ -64,9 +61,11 @@ class AttachmentSerializer(serializers.ModelSerializer):
 
     def get_uploader_url(self, obj):
         if obj.uploader_id:
-            return reverse('misago:user', kwargs={
-                'slug': obj.uploader_slug,
-                'pk': obj.uploader_id,
-            })
+            return reverse(
+                'misago:user', kwargs={
+                    'slug': obj.uploader_slug,
+                    'pk': obj.uploader_id,
+                }
+            )
         else:
             return None

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

@@ -1,7 +1,5 @@
 from rest_framework import serializers
 
-from django.urls import reverse
-
 from misago.categories.serializers import CategorySerializer
 from misago.core.serializers import MutableFields
 from misago.threads.models import Post
@@ -14,14 +12,9 @@ __all__ = [
     'FeedSerializer',
 ]
 
+FeedUserSerializer = UserSerializer.subset_fields('id', 'username', 'avatars', 'absolute_url')
 
-
-FeedUserSerializer = UserSerializer.subset_fields(
-    'id', 'username', 'avatars', 'absolute_url')
-
-
-FeedCategorySerializer = CategorySerializer.subset_fields(
-    'name', 'css_class', 'absolute_url')
+FeedCategorySerializer = CategorySerializer.subset_fields('name', 'css_class', 'absolute_url')
 
 
 class FeedSerializer(PostSerializer, MutableFields):
@@ -33,17 +26,12 @@ class FeedSerializer(PostSerializer, MutableFields):
 
     class Meta:
         model = Post
-        fields = PostSerializer.Meta.fields + [
-            'category',
-
-            'thread',
-            'top_category'
-        ]
+        fields = PostSerializer.Meta.fields + ['category', 'thread', 'top_category']
 
     def get_thread(self, obj):
         return {
             'title': obj.thread.title,
-            'url': obj.thread.get_absolute_url()
+            'url': obj.thread.get_absolute_url(),
         }
 
     def get_top_category(self, obj):

+ 12 - 6
misago/threads/serializers/moderation.py

@@ -43,20 +43,24 @@ class NewThreadSerializer(serializers.Serializer):
         try:
             add_acl(self.context, self.category)
         except AttributeError:
-            return weight # don't validate weight further if category failed
+            return weight  # don't validate weight further if category failed
 
         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."))
+                raise ValidationError(
+                    _("You don't have permission to pin threads globally in this category.")
+                )
             else:
-                raise ValidationError(_("You don't have permission to pin threads in this category."))
+                raise ValidationError(
+                    _("You don't have permission to pin threads in this category.")
+                )
         return weight
 
     def validate_is_hidden(self, is_hidden):
         try:
             add_acl(self.context, self.category)
         except AttributeError:
-            return is_hidden # don't validate hidden further if category failed
+            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."))
@@ -66,8 +70,10 @@ class NewThreadSerializer(serializers.Serializer):
         try:
             add_acl(self.context, self.category)
         except AttributeError:
-            return is_closed # don't validate closed further if category failed
+            return is_closed  # don't validate closed further if category failed
 
         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."))
+            raise ValidationError(
+                _("You don't have permission to close threads in this category.")
+            )
         return is_closed

+ 27 - 25
misago/threads/serializers/poll.py

@@ -27,7 +27,7 @@ class PollSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Poll
-        fields = (
+        fields = [
             'id',
             'poster_name',
             'posted_on',
@@ -37,13 +37,11 @@ class PollSerializer(serializers.ModelSerializer):
             'allow_revotes',
             'votes',
             'is_public',
-
             'acl',
             'choices',
-
             'api',
             'url',
-        )
+        ]
 
     def get_api(self, obj):
         return {
@@ -58,10 +56,12 @@ class PollSerializer(serializers.ModelSerializer):
 
     def get_poster_url(self, obj):
         if obj.poster_id:
-            return reverse('misago:user', kwargs={
-                'slug': obj.poster_slug,
-                'pk': obj.poster_id,
-            })
+            return reverse(
+                'misago:user', kwargs={
+                    'slug': obj.poster_slug,
+                    'pk': obj.poster_id,
+                }
+            )
         else:
             return None
 
@@ -80,19 +80,19 @@ class EditPollSerializer(serializers.ModelSerializer):
     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(),
+        allow_empty=False,
+        child=serializers.DictField(),
     )
 
     class Meta:
         model = Poll
-        fields = (
+        fields = [
             'length',
             'question',
             'allowed_choices',
             'allow_revotes',
             'choices',
-        )
+        ]
 
     def validate_choices(self, choices):
         clean_choices = list(map(self.clean_choice, choices))
@@ -105,14 +105,12 @@ class EditPollSerializer(serializers.ModelSerializer):
         final_choices = []
         for choice in clean_choices:
             if choice['hash'] in choices_map:
-                choices_map[choice['hash']].update({
-                    'label': choice['label']
-                })
+                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
+                    'votes': 0,
                 })
                 final_choices.append(choice)
 
@@ -142,16 +140,20 @@ class EditPollSerializer(serializers.ModelSerializer):
             message = ungettext(
                 "You can't add more than %(limit_value)s option to a single poll (added %(show_value)s).",
                 "You can't add more than %(limit_value)s options to a single poll (added %(show_value)s).",
-                MAX_POLL_OPTIONS)
-            raise serializers.ValidationError(message % {
-                'limit_value': MAX_POLL_OPTIONS,
-                'show_value': total_choices
-            })
+                MAX_POLL_OPTIONS,
+            )
+            raise serializers.ValidationError(
+                message % {
+                    'limit_value': MAX_POLL_OPTIONS,
+                    'show_value': total_choices,
+                }
+            )
 
     def validate(self, data):
         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):
@@ -176,14 +178,14 @@ class EditPollSerializer(serializers.ModelSerializer):
 class NewPollSerializer(EditPollSerializer):
     class Meta:
         model = Poll
-        fields = (
+        fields = [
             'length',
             'question',
             'allowed_choices',
             'allow_revotes',
             'is_public',
             'choices',
-        )
+        ]
 
     def validate_choices(self, choices):
         clean_choices = list(map(self.clean_choice, choices))
@@ -193,7 +195,7 @@ class NewPollSerializer(EditPollSerializer):
         for choice in clean_choices:
             choice.update({
                 'hash': get_random_string(12),
-                'votes': 0
+                'votes': 0,
             })
 
         return clean_choices

+ 8 - 8
misago/threads/serializers/pollvote.py

@@ -13,20 +13,20 @@ class PollVoteSerializer(serializers.Serializer):
     url = serializers.SerializerMethodField()
 
     class Meta:
-        fields = (
+        fields = [
             'voted_on',
-
             'username',
-
             'url',
-        )
+        ]
 
     def get_username(self, obj):
         return obj['voter_name']
 
     def get_url(self, obj):
         if obj['voter_id']:
-            return reverse('misago:user', kwargs={
-                'pk': obj['voter_id'],
-                'slug': obj['voter_slug'],
-            })
+            return reverse(
+                'misago:user', kwargs={
+                    'pk': obj['voter_id'],
+                    'slug': obj['voter_slug'],
+                }
+            )

+ 14 - 13
misago/threads/serializers/post.py

@@ -2,7 +2,6 @@ from rest_framework import serializers
 
 from django.urls import reverse
 
-from misago.categories.serializers import CategorySerializer
 from misago.core.serializers import MutableFields
 from misago.threads.models import Post
 from misago.users.serializers import UserSerializer as BaseUserSerializer
@@ -10,9 +9,9 @@ from misago.users.serializers import UserSerializer as BaseUserSerializer
 
 __all__ = ['PostSerializer']
 
-
 UserSerializer = BaseUserSerializer.subset_fields(
-    'id', 'username', 'rank', 'avatars', 'signature', 'title', 'status', 'absolute_url')
+    'id', 'username', 'rank', 'avatars', 'signature', 'title', 'status', 'absolute_url'
+)
 
 
 class PostSerializer(serializers.ModelSerializer, MutableFields):
@@ -58,14 +57,12 @@ class PostSerializer(serializers.ModelSerializer, MutableFields):
             'is_event',
             'event_type',
             'event_context',
-
             'acl',
             'is_liked',
             'is_new',
             'is_read',
             'last_likes',
             'likes',
-
             'api',
             'url',
         ]
@@ -152,18 +149,22 @@ class PostSerializer(serializers.ModelSerializer, MutableFields):
 
     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
-            })
+            return reverse(
+                'misago:user', kwargs={
+                    'pk': obj.last_editor_id,
+                    'slug': obj.last_editor_slug,
+                }
+            )
         else:
             return None
 
     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
-            })
+            return reverse(
+                'misago:user', kwargs={
+                    'pk': obj.hidden_by_id,
+                    'slug': obj.hidden_by_slug,
+                }
+            )
         else:
             return None

+ 8 - 8
misago/threads/serializers/postedit.py

@@ -17,16 +17,14 @@ class PostEditSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = PostEdit
-        fields = (
+        fields = [
             'id',
             'edited_on',
             'editor_name',
             'editor_slug',
-
             'diff',
-
             'url',
-        )
+        ]
 
     def get_diff(self, obj):
         return obj.get_diff()
@@ -38,9 +36,11 @@ class PostEditSerializer(serializers.ModelSerializer):
 
     def get_editor_url(self, obj):
         if obj.editor_id:
-            return reverse('misago:user', kwargs={
-                'slug': obj.editor_slug,
-                'pk': obj.editor_id,
-            })
+            return reverse(
+                'misago:user', kwargs={
+                    'slug': obj.editor_slug,
+                    'pk': obj.editor_id,
+                }
+            )
         else:
             return None

+ 8 - 8
misago/threads/serializers/postlike.py

@@ -18,15 +18,13 @@ class PostLikeSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = PostLike
-        fields = (
+        fields = [
             'id',
             'liked_on',
-
             'liker_id',
             'username',
-
             'url',
-        )
+        ]
 
     def get_liker_id(self, obj):
         return obj['liker_id']
@@ -36,9 +34,11 @@ class PostLikeSerializer(serializers.ModelSerializer):
 
     def get_url(self, obj):
         if obj['liker_id']:
-            return reverse('misago:user', kwargs={
-                'slug': obj['liker_slug'],
-                'pk': obj['liker_id'],
-            })
+            return reverse(
+                'misago:user', kwargs={
+                    'slug': obj['liker_slug'],
+                    'pk': obj['liker_id'],
+                }
+            )
         else:
             return None

+ 18 - 18
misago/threads/serializers/thread.py

@@ -16,10 +16,10 @@ __all__ = [
     'ThreadsListSerializer',
 ]
 
-
 BasicCategorySerializer = CategorySerializer.subset_fields(
-    'id', 'parent', 'name', 'description', 'is_closed', 'css_class',
-    'absolute_url', 'api_url', 'level', 'lft', 'rght', 'is_read')
+    'id', 'parent', 'name', 'description', 'is_closed', 'css_class', 'absolute_url', 'api_url',
+    'level', 'lft', 'rght', 'is_read'
+)
 
 
 class ThreadSerializer(serializers.ModelSerializer, MutableFields):
@@ -37,7 +37,7 @@ class ThreadSerializer(serializers.ModelSerializer, MutableFields):
 
     class Meta:
         model = Thread
-        fields = (
+        fields = [
             'id',
             'category',
             'title',
@@ -52,17 +52,15 @@ class ThreadSerializer(serializers.ModelSerializer, MutableFields):
             'is_hidden',
             'is_closed',
             'weight',
-
             'acl',
             'is_new',
             'is_read',
             'path',
             'poll',
             'subscription',
-
             'api',
             'url',
-        )
+        ]
 
     def get_acl(self, obj):
         try:
@@ -107,8 +105,8 @@ class ThreadSerializer(serializers.ModelSerializer, MutableFields):
                 '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()
-            }
+                'split': obj.get_post_split_api_url(),
+            },
         }
 
     def get_url(self, obj):
@@ -122,10 +120,12 @@ class ThreadSerializer(serializers.ModelSerializer, MutableFields):
 
     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,
-            })
+            return reverse(
+                'misago:user', kwargs={
+                    'slug': obj.last_poster_slug,
+                    'pk': obj.last_poster_id,
+                }
+            )
         else:
             return None
 
@@ -135,9 +135,9 @@ class PrivateThreadSerializer(ThreadSerializer):
 
     class Meta:
         model = Thread
-        fields = ThreadSerializer.Meta.fields + (
+        fields = ThreadSerializer.Meta.fields + [
             'participants',
-        )
+        ]
 
 
 class ThreadsListSerializer(ThreadSerializer):
@@ -148,7 +148,7 @@ class ThreadsListSerializer(ThreadSerializer):
 
     class Meta:
         model = Thread
-        fields = ThreadSerializer.Meta.fields + (
-            'has_poll', 'top_category'
-        )
+        fields = ThreadSerializer.Meta.fields + ['has_poll', 'top_category']
+
+
 ThreadsListSerializer = ThreadsListSerializer.exclude_fields('path', 'poll')

+ 1 - 7
misago/threads/serializers/threadparticipant.py

@@ -15,13 +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

+ 15 - 13
misago/threads/signals.py

@@ -19,13 +19,13 @@ move_post = Signal()
 move_thread = Signal()
 
 
-"""
-Signal handlers
-"""
 @receiver(merge_thread)
 def merge_threads_posts(sender, **kwargs):
     other_thread = kwargs['other_thread']
-    other_thread.post_set.update(category=sender.category, thread=sender)
+    other_thread.post_set.update(
+        category=sender.category,
+        thread=sender,
+    )
 
 
 @receiver(merge_post)
@@ -102,44 +102,46 @@ def delete_user_threads(sender, **kwargs):
 def update_usernames(sender, **kwargs):
     Thread.objects.filter(starter=sender).update(
         starter_name=sender.username,
-        starter_slug=sender.slug
+        starter_slug=sender.slug,
     )
 
     Thread.objects.filter(last_poster=sender).update(
         last_poster_name=sender.username,
-        last_poster_slug=sender.slug
+        last_poster_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_slug=sender.slug,
     )
 
     PostEdit.objects.filter(editor=sender).update(
         editor_name=sender.username,
-        editor_slug=sender.slug
+        editor_slug=sender.slug,
     )
 
     PostLike.objects.filter(liker=sender).update(
         liker_name=sender.username,
-        liker_slug=sender.slug
+        liker_slug=sender.slug,
     )
 
     Attachment.objects.filter(uploader=sender).update(
         uploader_name=sender.username,
-        uploader_slug=sender.slug
+        uploader_slug=sender.slug,
     )
 
     Poll.objects.filter(poster=sender).update(
         poster_name=sender.username,
-        poster_slug=sender.slug
+        poster_slug=sender.slug,
     )
 
     PollVote.objects.filter(voter=sender).update(
         voter_name=sender.username,
-        voter_slug=sender.slug
+        voter_slug=sender.slug,
     )
 
 

+ 1 - 1
misago/threads/subscriptions.py

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

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

@@ -4,8 +4,6 @@ from django import template
 from django.utils.translation import gettext as _
 from django.utils.translation import ngettext
 
-from misago.conf import settings
-
 
 register = template.Library()
 
@@ -30,22 +28,16 @@ def likes_label(post):
     if not hidden_likes:
         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
+        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

+ 48 - 19
misago/threads/tests/test_attachmentadmin_views.py

@@ -11,9 +11,7 @@ class AttachmentAdminViewsTests(AdminTestCase):
         super(AttachmentAdminViewsTests, self).setUp()
 
         self.category = Category.objects.get(slug='first-category')
-        self.post = testutils.post_thread(
-            category=self.category
-        ).first_post
+        self.post = testutils.post_thread(category=self.category).first_post
 
         self.filetype = AttachmentType.objects.order_by('id').first()
 
@@ -32,7 +30,7 @@ class AttachmentAdminViewsTests(AdminTestCase):
             filename='testfile_{}.zip'.format(Attachment.objects.count() + 1),
             file=None,
             image=None,
-            thumbnail=None
+            thumbnail=None,
         )
 
     def test_link_registered(self):
@@ -50,14 +48,22 @@ class AttachmentAdminViewsTests(AdminTestCase):
         attachments = [
             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.mock_attachment(
+                self.post,
+                image='somelargeimage.png',
+                thumbnail='somethumb.png',
+            ),
         ]
 
         response = self.client.get(final_link)
         self.assertEqual(response.status_code, 200)
 
         for attachment in attachments:
-            delete_link = reverse('misago:admin:system:attachments:delete', kwargs={'pk': attachment.pk})
+            delete_link = reverse(
+                'misago:admin:system:attachments:delete', kwargs={
+                    'pk': attachment.pk,
+                }
+            )
             self.assertContains(response, attachment.filename)
             self.assertContains(response, delete_link)
             self.assertContains(response, attachment.get_absolute_url())
@@ -72,16 +78,23 @@ class AttachmentAdminViewsTests(AdminTestCase):
         attachments = [
             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.mock_attachment(
+                self.post,
+                image='somelargeimage.png',
+                thumbnail='somethumb.png',
+            ),
         ]
 
         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]
-        })
+        response = self.client.post(
+            self.admin_link,
+            data={
+                'action': 'delete',
+                'selected_items': [a.pk for a in attachments],
+            }
+        )
         self.assertEqual(response.status_code, 302)
 
         self.assertEqual(Attachment.objects.count(), 0)
@@ -94,13 +107,23 @@ class AttachmentAdminViewsTests(AdminTestCase):
         """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})
+        action_link = reverse(
+            'misago:admin:system:attachments:delete', kwargs={
+                'pk': attachment.pk,
+            }
+        )
 
         response = self.client.post(action_link)
         self.assertEqual(response.status_code, 302)
@@ -114,7 +137,13 @@ class AttachmentAdminViewsTests(AdminTestCase):
 
         # assert it was removed from post's 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}
-        ])
+        self.assertEqual(
+            attachments_cache, [
+                {
+                    'id': attachment.pk + 1,
+                },
+                {
+                    'id': attachment.pk + 2,
+                },
+            ]
+        )

+ 90 - 72
misago/threads/tests/test_attachments_api.py

@@ -1,11 +1,9 @@
-import json
 import os
 
 from PIL import Image
 
 from django.urls import reverse
 from django.utils import six
-from django.utils.encoding import smart_str
 
 from misago.acl.models import Role
 from misago.acl.testutils import override_acl
@@ -45,9 +43,7 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
 
     def test_no_permission(self):
         """user needs permission to upload files"""
-        self.override_acl({
-            'max_attachment_size': 0
-        })
+        self.override_acl({'max_attachment_size': 0})
 
         response = self.client.post(self.api_link)
         self.assertContains(response, "don't have permission to upload new files", status_code=403)
@@ -62,13 +58,15 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         AttachmentType.objects.create(
             name="Test extension",
             extensions='jpg,jpeg',
-            mimetypes=None
+            mimetypes=None,
         )
 
         with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertContains(response, "You can't upload files of this type.", status_code=400)
 
     def test_invalid_mime(self):
@@ -76,13 +74,15 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         AttachmentType.objects.create(
             name="Test extension",
             extensions='png',
-            mimetypes='loremipsum'
+            mimetypes='loremipsum',
         )
 
         with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertContains(response, "You can't upload files of this type.", status_code=400)
 
     def test_no_perm_to_type(self):
@@ -90,46 +90,52 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
         attachment_type = AttachmentType.objects.create(
             name="Test extension",
             extensions='png',
-            mimetypes='application/pdf'
+            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
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertContains(response, "You can't upload files of this type.", status_code=400)
 
     def test_type_is_locked(self):
         """new uploads for this filetype are locked"""
-        attachment_type = AttachmentType.objects.create(
+        AttachmentType.objects.create(
             name="Test extension",
             extensions='png',
             mimetypes='application/pdf',
-            status=AttachmentType.LOCKED
+            status=AttachmentType.LOCKED,
         )
 
         with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertContains(response, "You can't upload files of this type.", status_code=400)
 
     def test_type_is_disabled(self):
         """new uploads for this filetype are disabled"""
-        attachment_type = AttachmentType.objects.create(
+        AttachmentType.objects.create(
             name="Test extension",
             extensions='png',
             mimetypes='application/pdf',
-            status=AttachmentType.DISABLED
+            status=AttachmentType.DISABLED,
         )
 
         with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertContains(response, "You can't upload files of this type.", status_code=400)
 
     def test_upload_too_big_for_type(self):
@@ -138,62 +144,70 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
             name="Test extension",
             extensions='png',
             mimetypes='image/png',
-            size_limit=100
+            size_limit=100,
         )
 
         with open(TEST_LARGEPNG_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
-
-        self.assertContains(response, "can't upload files of this type larger than", status_code=400)
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
+
+        self.assertContains(
+            response, "can't upload files of this type larger than", status_code=400
+        )
 
     def test_upload_too_big_for_user(self):
         """too big uploads are rejected"""
-        self.override_acl({
-            'max_attachment_size': 100
-        })
+        self.override_acl({'max_attachment_size': 100})
 
         AttachmentType.objects.create(
             name="Test extension",
             extensions='png',
-            mimetypes='image/png'
+            mimetypes='image/png',
         )
 
         with open(TEST_LARGEPNG_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertContains(response, "can't upload files larger than", status_code=400)
 
     def test_corrupted_image_upload(self):
         """corrupted image upload is handled"""
-        attachment_type = AttachmentType.objects.create(
+        AttachmentType.objects.create(
             name="Test extension",
-            extensions='gif'
+            extensions='gif',
         )
 
         with open(TEST_CORRUPTEDIMG_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertContains(response, "Uploaded image was corrupted or invalid.", status_code=400)
 
     def test_document_upload(self):
         """successful upload creates orphan attachment"""
-        attachment_type = AttachmentType.objects.create(
+        AttachmentType.objects.create(
             name="Test extension",
             extensions='pdf',
-            mimetypes='application/pdf'
+            mimetypes='application/pdf',
         )
 
         with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         attachment = Attachment.objects.get(id=response_json['id'])
 
         self.assertEqual(attachment.filename, 'document.pdf')
@@ -220,19 +234,21 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
 
     def test_small_image_upload(self):
         """successful small image upload creates orphan attachment without thumbnail"""
-        attachment_type = AttachmentType.objects.create(
+        AttachmentType.objects.create(
             name="Test extension",
             extensions='jpeg,jpg',
-            mimetypes='image/jpeg'
+            mimetypes='image/jpeg',
         )
 
         with open(TEST_SMALLJPG_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         attachment = Attachment.objects.get(id=response_json['id'])
 
         self.assertEqual(attachment.filename, 'small.jpg')
@@ -253,23 +269,23 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
 
     def test_large_image_upload(self):
         """successful large image upload creates orphan attachment with thumbnail"""
-        self.override_acl({
-            'max_attachment_size': 10 * 1024
-        })
+        self.override_acl({'max_attachment_size': 10 * 1024})
 
-        attachment_type = AttachmentType.objects.create(
+        AttachmentType.objects.create(
             name="Test extension",
             extensions='png',
-            mimetypes='image/png'
+            mimetypes='image/png',
         )
 
         with open(TEST_LARGEPNG_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         attachment = Attachment.objects.get(id=response_json['id'])
 
         self.assertEqual(attachment.filename, 'large.png')
@@ -308,19 +324,21 @@ class AttachmentsApiTestCase(AuthenticatedUserTestCase):
 
     def test_animated_image_upload(self):
         """successful gif upload creates orphan attachment with thumbnail"""
-        attachment_type = AttachmentType.objects.create(
+        AttachmentType.objects.create(
             name="Test extension",
             extensions='gif',
-            mimetypes='image/gif'
+            mimetypes='image/gif',
         )
 
         with open(TEST_ANIMATEDGIF_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         attachment = Attachment.objects.get(id=response_json['id'])
 
         self.assertEqual(attachment.filename, 'animated.gif')

+ 28 - 34
misago/threads/tests/test_attachments_middleware.py

@@ -30,9 +30,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         self.filetype = AttachmentType.objects.order_by('id').last()
 
     def override_acl(self, new_acl=None):
-        override_acl(self.user, new_acl or {
-            'max_attachment_size': 1024
-        })
+        override_acl(self.user, new_acl or {'max_attachment_size': 1024})
 
     def mock_attachment(self, user=True, post=None):
         return Attachment.objects.create(
@@ -51,31 +49,24 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         """use_this_middleware returns False if we can't upload attachments"""
         middleware = AttachmentsMiddleware(user=self.user)
 
-        self.override_acl({
-            'max_attachment_size': 0
-        })
+        self.override_acl({'max_attachment_size': 0})
 
         self.assertFalse(middleware.use_this_middleware())
 
-        self.override_acl({
-            'max_attachment_size': 1024
-        })
+        self.override_acl({'max_attachment_size': 1024})
 
         self.assertTrue(middleware.use_this_middleware())
 
     def test_middleware_is_optional(self):
         """middleware is optional"""
-        INPUTS = (
-            {},
-            {'attachments': []}
-        )
+        INPUTS = [{}, {'attachments': []}]
 
         for test_input in INPUTS:
             middleware = AttachmentsMiddleware(
                 request=RequestMock(test_input),
                 mode=PostingEndpoint.START,
                 user=self.user,
-                post=self.post
+                post=self.post,
             )
 
             serializer = middleware.get_serializer()
@@ -83,11 +74,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
 
     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)]
 
         for test_input in INPUTS:
             middleware = AttachmentsMiddleware(
@@ -96,7 +83,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
                 }),
                 mode=PostingEndpoint.START,
                 user=self.user,
-                post=self.post
+                post=self.post,
             )
 
             serializer = middleware.get_serializer()
@@ -108,18 +95,20 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             request=RequestMock(),
             mode=PostingEndpoint.EDIT,
             user=self.user,
-            post=self.post
+            post=self.post,
         )
 
         serializer = middleware.get_serializer()
 
         attachments = serializer.get_initial_attachments(
-            middleware.mode, middleware.user, middleware.post)
+            middleware.mode, middleware.user, middleware.post
+        )
         self.assertEqual(attachments, [])
 
         attachment = self.mock_attachment(post=self.post)
         attachments = serializer.get_initial_attachments(
-            middleware.mode, middleware.user, middleware.post)
+            middleware.mode, middleware.user, middleware.post
+        )
         self.assertEqual(attachments, [attachment])
 
     def test_get_new_attachments(self):
@@ -128,7 +117,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             request=RequestMock(),
             mode=PostingEndpoint.EDIT,
             user=self.user,
-            post=self.post
+            post=self.post,
         )
 
         serializer = middleware.get_serializer()
@@ -149,17 +138,19 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         """middleware validates if we have permission to delete other users attachments"""
         self.override_acl({
             'max_attachment_size': 1024,
-            'can_delete_other_users_attachments': False
+            'can_delete_other_users_attachments': False,
         })
 
         attachment = self.mock_attachment(user=False, post=self.post)
         self.assertIsNone(attachment.uploader)
 
         serializer = AttachmentsMiddleware(
-            request=RequestMock({'attachments': []}),
+            request=RequestMock({
+                'attachments': []
+            }),
             mode=PostingEndpoint.EDIT,
             user=self.user,
-            post=self.post
+            post=self.post,
         ).get_serializer()
 
         self.assertFalse(serializer.is_valid())
@@ -177,7 +168,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             }),
             mode=PostingEndpoint.EDIT,
             user=self.user,
-            post=self.post
+            post=self.post,
         )
 
         serializer = middleware.get_serializer()
@@ -189,7 +180,8 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         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)
 
     def test_remove_attachments(self):
         """middleware removes attachment from post and db"""
@@ -204,7 +196,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             }),
             mode=PostingEndpoint.EDIT,
             user=self.user,
-            post=self.post
+            post=self.post,
         )
 
         serializer = middleware.get_serializer()
@@ -218,7 +210,8 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         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)
 
     def test_steal_attachments(self):
         """middleware validates if attachments are already assigned to other posts"""
@@ -235,7 +228,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             }),
             mode=PostingEndpoint.EDIT,
             user=self.user,
-            post=self.post
+            post=self.post,
         )
 
         serializer = middleware.get_serializer()
@@ -263,7 +256,7 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
             }),
             mode=PostingEndpoint.EDIT,
             user=self.user,
-            post=self.post
+            post=self.post,
         )
 
         serializer = middleware.get_serializer()
@@ -275,7 +268,8 @@ class AttachmentsMiddlewareTests(AuthenticatedUserTestCase):
         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):

+ 86 - 53
misago/threads/tests/test_attachmenttypeadmin_views.py

@@ -37,12 +37,15 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
         response = self.client.post(form_link, data={})
         self.assertEqual(response.status_code, 200)
 
-        response = self.client.post(form_link, data={
-            'name': 'Test type',
-            'extensions': '.test',
-            'size_limit': 0,
-            'status': AttachmentType.ENABLED,
-        })
+        response = self.client.post(
+            form_link,
+            data={
+                'name': 'Test type',
+                'extensions': '.test',
+                'size_limit': 0,
+                'status': AttachmentType.ENABLED,
+            }
+        )
         self.assertEqual(response.status_code, 302)
 
         # clean alert about new item created
@@ -55,17 +58,24 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
 
     def test_edit_view(self):
         """edit attachment type view has no showstoppers"""
-        self.client.post(reverse('misago:admin:system:attachment-types:new'), data={
-            'name': 'Test type',
-            'extensions': '.test',
-            'size_limit': 0,
-            'status': AttachmentType.ENABLED,
-        })
+        self.client.post(
+            reverse('misago:admin:system:attachment-types:new'),
+            data={
+                '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')
 
-        form_link = reverse('misago:admin:system:attachment-types:edit', kwargs={'pk': test_type.pk})
+        form_link = reverse(
+            'misago:admin:system:attachment-types:edit', kwargs={
+                'pk': test_type.pk,
+            }
+        )
 
         response = self.client.get(form_link)
         self.assertEqual(response.status_code, 200)
@@ -73,15 +83,18 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
         response = self.client.post(form_link, data={})
         self.assertEqual(response.status_code, 200)
 
-        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()],
-        })
+        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()],
+            }
+        )
         self.assertEqual(response.status_code, 302)
 
         test_type = AttachmentType.objects.order_by('id').last()
@@ -100,15 +113,18 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
         self.assertEqual(test_type.limit_downloads_to.count(), Role.objects.count())
 
         # remove limits from type
-        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': [],
-        })
+        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': [],
+            }
+        )
         self.assertEqual(response.status_code, 302)
 
         self.assertEqual(test_type.limit_uploads_to.count(), 0)
@@ -116,7 +132,7 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
 
     def test_clean_params_view(self):
         """admin form nicely cleans lists of extensions/mimetypes"""
-        TEST_CASES = (
+        TEST_CASES = [
             ('test', ['test']),
             ('.test', ['test']),
             ('.tar.gz', ['tar.gz']),
@@ -125,15 +141,18 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
             ('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'), data={
-                'name': 'Test type',
-                'extensions': raw,
-                'size_limit': 0,
-                'status': AttachmentType.ENABLED,
-            })
+            response = self.client.post(
+                reverse('misago:admin:system:attachment-types:new'),
+                data={
+                    '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()
@@ -141,17 +160,24 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
 
     def test_delete_view(self):
         """delete attachment type view has no showstoppers"""
-        self.client.post(reverse('misago:admin:system:attachment-types:new'), data={
-            'name': 'Test type',
-            'extensions': '.test',
-            'size_limit': 0,
-            'status': AttachmentType.ENABLED,
-        })
+        self.client.post(
+            reverse('misago:admin:system:attachment-types:new'),
+            data={
+                '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')
 
-        action_link = reverse('misago:admin:system:attachment-types:delete', kwargs={'pk': test_type.pk})
+        action_link = reverse(
+            'misago:admin:system:attachment-types:delete', kwargs={
+                'pk': test_type.pk,
+            }
+        )
 
         response = self.client.post(action_link)
         self.assertEqual(response.status_code, 302)
@@ -165,12 +191,15 @@ 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'), data={
-            'name': 'Test type',
-            'extensions': '.test',
-            'size_limit': 0,
-            'status': AttachmentType.ENABLED,
-        })
+        self.client.post(
+            reverse('misago:admin:system:attachment-types:new'),
+            data={
+                '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')
@@ -185,7 +214,11 @@ class AttachmentTypeAdminViewsTests(AdminTestCase):
             file='sad76asd678as687sa.zip'
         )
 
-        action_link = reverse('misago:admin:system:attachment-types:delete', kwargs={'pk': test_type.pk})
+        action_link = reverse(
+            'misago:admin:system:attachment-types:delete', kwargs={
+                'pk': test_type.pk,
+            }
+        )
 
         response = self.client.post(action_link)
         self.assertEqual(response.status_code, 302)

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

@@ -29,11 +29,11 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
 
         self.attachment_type_jpg = AttachmentType.objects.create(
             name="JPG",
-            extensions='jpeg,jpg'
+            extensions='jpeg,jpg',
         )
         self.attachment_type_pdf = AttachmentType.objects.create(
             name="PDF",
-            extensions='pdf'
+            extensions='pdf',
         )
 
         self.override_acl()
@@ -42,15 +42,17 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         acl = self.user.acl_cache.copy()
         acl.update({
             'max_attachment_size': 1000,
-            'can_download_other_users_attachments': allow_download
+            'can_download_other_users_attachments': allow_download,
         })
         override_acl(self.user, acl)
 
     def upload_document(self, is_orphaned=False, by_other_user=False):
         with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertEqual(response.status_code, 200)
 
         attachment = Attachment.objects.order_by('id').last()
@@ -68,9 +70,11 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
 
     def upload_image(self):
         with open(TEST_SMALLJPG_PATH, 'rb') as upload:
-            response = self.client.post(self.api_link, data={
-                'upload': upload
-            })
+            response = self.client.post(
+                self.api_link, data={
+                    'upload': upload,
+                }
+            )
         self.assertEqual(response.status_code, 200)
 
         attachment = Attachment.objects.order_by('id').last()
@@ -94,10 +98,12 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
 
     def test_nonexistant_file(self):
         """user tries to retrieve nonexistant file"""
-        response = self.client.get(reverse('misago:attachment', kwargs={
-            'pk': 123,
-            'secret': 'qwertyuiop'
-        }))
+        response = self.client.get(
+            reverse('misago:attachment', kwargs={
+                'pk': 123,
+                'secret': 'qwertyuiop',
+            })
+        )
 
         self.assertIs404(response)
 
@@ -105,10 +111,12 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         """user tries to retrieve existing file using invalid secret"""
         attachment = self.upload_document()
 
-        response = self.client.get(reverse('misago:attachment', kwargs={
-            'pk': attachment.pk,
-            'secret': 'qwertyuiop'
-        }))
+        response = self.client.get(
+            reverse('misago:attachment', kwargs={
+                'pk': attachment.pk,
+                'secret': 'qwertyuiop',
+            })
+        )
 
         self.assertIs404(response)
 
@@ -135,10 +143,15 @@ class AttachmentViewTestCase(AuthenticatedUserTestCase):
         """user tries to retrieve thumbnail from non-image attachment"""
         attachment = self.upload_document()
 
-        response = self.client.get(reverse('misago:attachment-thumbnail', kwargs={
-            'pk': attachment.pk,
-            'secret': attachment.secret
-        }))
+        response = self.client.get(
+            reverse(
+                'misago:attachment-thumbnail',
+                kwargs={
+                    'pk': attachment.pk,
+                    'secret': attachment.secret,
+                }
+            )
+        )
         self.assertIs404(response)
 
     def test_no_role(self):

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

@@ -31,7 +31,7 @@ class ClearAttachmentsTests(TestCase):
         cutoff = timezone.now() - timedelta(minutes=settings.MISAGO_ATTACHMENT_ORPHANED_EXPIRE)
         cutoff -= timedelta(minutes=5)
 
-        for i in range(5):
+        for _ in range(5):
             Attachment.objects.create(
                 secret=Attachment.generate_new_secret(),
                 filetype=filetype,
@@ -47,7 +47,7 @@ class ClearAttachmentsTests(TestCase):
         category = Category.objects.get(slug='first-category')
         post = testutils.post_thread(category).first_post
 
-        for i in range(5):
+        for _ in range(5):
             Attachment.objects.create(
                 secret=Attachment.generate_new_secret(),
                 filetype=filetype,
@@ -61,7 +61,7 @@ class ClearAttachmentsTests(TestCase):
             )
 
         # create 5 fresh orphaned attachments
-        for i in range(5):
+        for _ in range(5):
             Attachment.objects.create(
                 secret=Attachment.generate_new_secret(),
                 filetype=filetype,

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

@@ -26,16 +26,17 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
         self.category = Category.objects.get(slug='first-category')
         self.thread = testutils.post_thread(
             category=self.category,
-            started_on=timezone.now() - timedelta(seconds=5)
+            started_on=timezone.now() - timedelta(seconds=5),
         )
         self.override_acl()
 
-        self.api_link = reverse('misago:api:thread-post-list', kwargs={
-            'thread_pk': self.thread.pk
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-list', kwargs={
+                'thread_pk': self.thread.pk,
+            }
+        )
 
-        self.other_user = UserModel.objects.create_user(
-            'Bob', 'bob@boberson.com', 'pass123')
+        self.other_user = UserModel.objects.create_user('Bob', 'bob@boberson.com', 'pass123')
 
     def override_acl(self):
         new_acl = deepcopy(self.user.acl_cache)
@@ -44,7 +45,7 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             'can_browse': 1,
             'can_start_threads': 1,
             'can_reply_threads': 1,
-            'can_edit_posts': 1
+            'can_edit_posts': 1,
         })
 
         override_acl(self.user, new_acl)
@@ -56,21 +57,23 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             'can_browse': 1,
             'can_start_threads': 1,
             'can_reply_threads': 1,
-            'can_edit_posts': 1
+            'can_edit_posts': 1,
         })
 
         if hide:
             new_acl['categories'][self.category.pk].update({
-                'can_browse': False
+                'can_browse': False,
             })
 
         override_acl(self.other_user, new_acl)
 
     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!'
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': 'This is test response!',
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(len(mail.outbox), 0)
@@ -81,12 +84,14 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             thread=self.thread,
             category=self.category,
             last_read_on=timezone.now(),
-            send_email=True
+            send_email=True,
         )
 
-        response = self.client.post(self.api_link, data={
-            'post': 'This is test response!'
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': 'This is test response!',
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(len(mail.outbox), 0)
@@ -97,12 +102,14 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             thread=self.thread,
             category=self.category,
             last_read_on=timezone.now(),
-            send_email=False
+            send_email=False,
         )
 
-        response = self.client.post(self.api_link, data={
-            'post': 'This is test response!'
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': 'This is test response!',
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(len(mail.outbox), 0)
@@ -113,13 +120,15 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             thread=self.thread,
             category=self.category,
             last_read_on=timezone.now(),
-            send_email=True
+            send_email=True,
         )
         self.override_other_user_acl(hide=True)
 
-        response = self.client.post(self.api_link, data={
-            'post': 'This is test response!'
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': 'This is test response!',
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(len(mail.outbox), 0)
@@ -130,15 +139,17 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             thread=self.thread,
             category=self.category,
             last_read_on=timezone.now(),
-            send_email=True
+            send_email=True,
         )
         self.override_other_user_acl()
 
         testutils.reply_thread(self.thread, posted_on=timezone.now())
 
-        response = self.client.post(self.api_link, data={
-            'post': 'This is test response!'
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': 'This is test response!',
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(len(mail.outbox), 0)
@@ -149,13 +160,15 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             thread=self.thread,
             category=self.category,
             last_read_on=timezone.now(),
-            send_email=True
+            send_email=True,
         )
         self.override_other_user_acl()
 
-        response = self.client.post(self.api_link, data={
-            'post': 'This is test response!'
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': 'This is test response!',
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(len(mail.outbox), 1)
@@ -179,13 +192,15 @@ class EmailNotificationTests(AuthenticatedUserTestCase):
             thread=self.thread,
             category=self.category,
             last_read_on=self.thread.last_post_on,
-            send_email=True
+            send_email=True,
         )
         self.override_other_user_acl()
 
-        response = self.client.post(self.api_link, data={
-            'post': 'This is test response!'
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': 'This is test response!',
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(len(mail.outbox), 1)

+ 3 - 7
misago/threads/tests/test_events.py

@@ -1,6 +1,4 @@
 #-*- coding: utf-8 -*-
-import random
-
 from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.utils import timezone
@@ -8,8 +6,7 @@ from django.utils import timezone
 from misago.acl import add_acl
 from misago.categories.models import Category
 from misago.threads.events import record_event
-from misago.threads.models import Post, Thread
-from misago.threads.testutils import reply_thread
+from misago.threads.models import Thread
 
 
 UserModel = get_user_model()
@@ -23,8 +20,7 @@ class MockRequest(object):
 
 class EventsAPITests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user(
-            "Bob", "bob@bob.com", "Pass.123")
+        self.user = UserModel.objects.create_user("Bob", "bob@bob.com", "Pass.123")
 
         datetime = timezone.now()
 
@@ -36,7 +32,7 @@ class EventsAPITests(TestCase):
             starter_slug='tester',
             last_post_on=datetime,
             last_poster_name='Tester',
-            last_poster_slug='tester'
+            last_poster_slug='tester',
         )
 
         self.thread.set_title("Test thread")

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

@@ -17,9 +17,11 @@ class PostMentionsTests(AuthenticatedUserTestCase):
         self.thread = testutils.post_thread(category=self.category)
         self.override_acl()
 
-        self.post_link = reverse('misago:api:thread-post-list', kwargs={
-            'thread_pk': self.thread.pk
-        })
+        self.post_link = reverse(
+            'misago:api:thread-post-list', kwargs={
+                'thread_pk': self.thread.pk,
+            }
+        )
 
     def override_acl(self):
         new_acl = self.user.acl_cache
@@ -27,19 +29,25 @@ class PostMentionsTests(AuthenticatedUserTestCase):
             'can_see': 1,
             'can_browse': 1,
             'can_start_threads': 1,
-            'can_reply_threads': 1
+            'can_reply_threads': 1,
         })
 
         override_acl(self.user, new_acl)
 
     def test_flood_has_no_showstoppers(self):
         """endpoint handles posting interruption"""
-        response = self.client.post(self.post_link, data={
-            'post': "This is test response!"
-        })
+        response = self.client.post(
+            self.post_link, data={
+                'post': "This is test response!",
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
-        response = self.client.post(self.post_link, data={
-            'post': "This is test response!"
-        })
-        self.assertContains(response, "You can't post message so quickly after previous one.", status_code=403)
+        response = self.client.post(
+            self.post_link, data={
+                'post': "This is test response!",
+            }
+        )
+        self.assertContains(
+            response, "You can't post message so quickly after previous one.", status_code=403
+        )

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

@@ -4,8 +4,7 @@ from django.utils import timezone
 
 from misago.acl.testutils import override_acl
 from misago.threads.api.postingendpoint import PostingInterrupt
-from misago.threads.api.postingendpoint.floodprotection import (
-    MIN_POSTING_PAUSE, FloodProtectionMiddleware)
+from misago.threads.api.postingendpoint.floodprotection import FloodProtectionMiddleware
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -42,9 +41,7 @@ class FloodProtectionMiddlewareTests(AuthenticatedUserTestCase):
 
     def test_flood_permission(self):
         """middleware is respects permission to flood for team members"""
-        override_acl(self.user, {
-            'can_omit_flood_protection': True
-        })
+        override_acl(self.user, {'can_omit_flood_protection': True})
 
         middleware = FloodProtectionMiddleware(user=self.user)
         self.assertFalse(middleware.use_this_middleware())

+ 58 - 26
misago/threads/tests/test_gotoviews.py

@@ -25,31 +25,38 @@ class GotoPostTests(GotoViewTestCase):
         """first post redirect url is valid"""
         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))
+        self.assertEqual(
+            response['location'],
+            GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id)
+        )
 
         response = self.client.get(response['location'])
         self.assertContains(response, self.thread.first_post.get_absolute_url())
 
     def test_goto_last_post_on_page(self):
         """last post on page redirect url is valid"""
-        for i in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
             post = testutils.reply_thread(self.thread)
 
         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))
+        self.assertEqual(
+            response['location'], GOTO_URL % (self.thread.get_absolute_url(), post.pk)
+        )
 
         response = self.client.get(response['location'])
         self.assertContains(response, post.get_absolute_url())
 
     def test_goto_first_post_on_next_page(self):
         """first post on next page redirect url is valid"""
-        for i in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
             post = testutils.reply_thread(self.thread)
 
         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))
+        self.assertEqual(
+            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk)
+        )
 
         response = self.client.get(response['location'])
         self.assertContains(response, post.get_absolute_url())
@@ -57,7 +64,7 @@ class GotoPostTests(GotoViewTestCase):
     def test_goto_first_post_on_page_three_out_of_five(self):
         """first post on next page redirect url is valid"""
         posts = []
-        for i in range(settings.MISAGO_POSTS_PER_PAGE * 4 - 1):
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE * 4 - 1):
             post = testutils.reply_thread(self.thread)
             posts.append(post)
 
@@ -65,7 +72,9 @@ 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))
+        self.assertEqual(
+            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 3, post.pk)
+        )
 
         response = self.client.get(response['location'])
         self.assertContains(response, post.get_absolute_url())
@@ -73,7 +82,7 @@ class GotoPostTests(GotoViewTestCase):
     def test_goto_first_event_on_page_three_out_of_five(self):
         """event redirect url is valid"""
         posts = []
-        for i in range(settings.MISAGO_POSTS_PER_PAGE * 4 - 1):
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE * 4 - 1):
             post = testutils.reply_thread(self.thread)
             posts.append(post)
 
@@ -87,7 +96,9 @@ 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))
+        self.assertEqual(
+            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 3, post.pk)
+        )
 
         response = self.client.get(response['location'])
         self.assertContains(response, post.get_absolute_url())
@@ -98,19 +109,24 @@ class GotoLastTests(GotoViewTestCase):
         """first post redirect url is valid"""
         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))
+        self.assertEqual(
+            response['location'],
+            GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id)
+        )
 
         response = self.client.get(response['location'])
         self.assertContains(response, self.thread.last_post.get_absolute_url())
 
     def test_goto_last_post_on_page(self):
         """last post on page redirect url is valid"""
-        for i in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
             post = testutils.reply_thread(self.thread)
 
         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))
+        self.assertEqual(
+            response['location'], GOTO_URL % (self.thread.get_absolute_url(), post.pk)
+        )
 
         response = self.client.get(response['location'])
         self.assertContains(response, post.get_absolute_url())
@@ -121,7 +137,10 @@ class GotoNewTests(GotoViewTestCase):
         """first unread post redirect url is valid"""
         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))
+        self.assertEqual(
+            response['location'],
+            GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id)
+        )
 
     def test_goto_first_new_post(self):
         """first unread post redirect url in already read thread is valid"""
@@ -129,32 +148,36 @@ class GotoNewTests(GotoViewTestCase):
         read_thread(self.user, self.thread, self.thread.last_post)
 
         post = testutils.reply_thread(self.thread, posted_on=timezone.now())
-        for i in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
             testutils.reply_thread(self.thread, posted_on=timezone.now())
 
         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))
+        self.assertEqual(
+            response['location'], GOTO_URL % (self.thread.get_absolute_url(), post.pk)
+        )
 
     def test_goto_first_new_post_on_next_page(self):
         """first unread post redirect url in already read multipage thread is valid"""
-        for i in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
             testutils.reply_thread(self.thread, posted_on=timezone.now())
 
         make_thread_read_aware(self.user, self.thread)
         read_thread(self.user, self.thread, self.thread.last_post)
 
         post = testutils.reply_thread(self.thread, posted_on=timezone.now())
-        for i in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
             testutils.reply_thread(self.thread, posted_on=timezone.now())
 
         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))
+        self.assertEqual(
+            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk)
+        )
 
     def test_goto_first_new_post_in_read_thread(self):
         """goto new in read thread points to last post"""
-        for i in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
             post = testutils.reply_thread(self.thread, posted_on=timezone.now())
 
         make_thread_read_aware(self.user, self.thread)
@@ -162,18 +185,22 @@ 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))
+        self.assertEqual(
+            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk)
+        )
 
     def test_guest_goto_first_new_post_in_thread(self):
         """guest goto new in read thread points to last post"""
-        for i in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
             post = testutils.reply_thread(self.thread, posted_on=timezone.now())
 
         self.logout_user()
 
         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))
+        self.assertEqual(
+            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk)
+        )
 
 
 class GotoUnapprovedTests(GotoViewTestCase):
@@ -197,22 +224,27 @@ 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))
+        self.assertEqual(
+            response['location'],
+            GOTO_URL % (self.thread.get_absolute_url(), self.thread.first_post_id)
+        )
 
     def test_vie_handles_unapproved_posts(self):
         """if thread has unapproved posts, redirect to first of them"""
-        for i in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL):
             testutils.reply_thread(self.thread, posted_on=timezone.now())
 
         make_thread_read_aware(self.user, self.thread)
         read_thread(self.user, self.thread, self.thread.last_post)
 
         post = testutils.reply_thread(self.thread, is_unapproved=True, posted_on=timezone.now())
-        for i in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
+        for _ in range(settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL - 1):
             testutils.reply_thread(self.thread, posted_on=timezone.now())
 
         self.grant_permission()
 
         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))
+        self.assertEqual(
+            response['location'], GOTO_PAGE_URL % (self.thread.get_absolute_url(), 2, post.pk)
+        )

+ 57 - 42
misago/threads/tests/test_paginator.py

@@ -11,61 +11,75 @@ class PostsPaginatorTests(TestCase):
         items = [i + 1 for i in range(30)]
 
         paginator = PostsPaginator(items, 5)
-        self.assertEqual(self.get_paginator_items_list(paginator), [
-            [1, 2, 3, 4, 5],
-            [5, 6, 7, 8, 9],
-            [9, 10, 11, 12, 13],
-            [13, 14, 15, 16, 17],
-            [17, 18, 19, 20, 21],
-            [21, 22, 23, 24, 25],
-            [25, 26, 27, 28, 29],
-            [29, 30],
-        ])
+        self.assertEqual(
+            self.get_paginator_items_list(paginator), [
+                [1, 2, 3, 4, 5],
+                [5, 6, 7, 8, 9],
+                [9, 10, 11, 12, 13],
+                [13, 14, 15, 16, 17],
+                [17, 18, 19, 20, 21],
+                [21, 22, 23, 24, 25],
+                [25, 26, 27, 28, 29],
+                [29, 30],
+            ]
+        )
 
     def test_paginator_orphans(self):
         """paginator handles orphans"""
         items = [i + 1 for i in range(16)]
 
         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.assertEqual(
+            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.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],
+            ]
+        )
 
         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.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],
+            ]
+        )
 
         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.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],
+            ]
+        )
 
         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.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],
+            ]
+        )
 
         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.assertEqual(
+            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)
         self.assertEqual(self.get_paginator_items_list(paginator), [items])
@@ -84,9 +98,10 @@ class PostsPaginatorTests(TestCase):
                         continue
 
                     common_part = set(page) & set(compared)
-                    self.assertTrue(len(common_part) < 2, "invalid page %s: %s" % (
-                        max(p, c) + 1, sorted(list(common_part))
-                    ))
+                    self.assertTrue(
+                        len(common_part) < 2,
+                        "invalid page %s: %s" % (max(p, c) + 1, sorted(list(common_part)))
+                    )
 
     def get_paginator_items_list(self, paginator):
         items_list = []

+ 26 - 8
misago/threads/tests/test_participants.py

@@ -23,7 +23,7 @@ class ParticipantsTests(TestCase):
             starter_slug='tester',
             last_post_on=datetime,
             last_poster_name='Tester',
-            last_poster_slug='tester'
+            last_poster_slug='tester',
         )
 
         self.thread.set_title("Test thread")
@@ -38,7 +38,7 @@ class ParticipantsTests(TestCase):
             parsed="<p>Hello! I am test message!</p>",
             checksum="nope",
             posted_on=datetime,
-            updated_on=datetime
+            updated_on=datetime,
         )
 
         self.thread.first_post = post
@@ -137,7 +137,10 @@ 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):
         """
@@ -153,7 +156,10 @@ 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):
         """
@@ -168,9 +174,15 @@ class ParticipantsTests(TestCase):
 
         users.append(UserModel.objects.create_user("Bob2", "bob2@boberson.com", "Pass.123"))
 
-        set_users_unread_private_threads_sync(users=users, participants=participants)
+        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"""
@@ -179,7 +191,10 @@ class ParticipantsTests(TestCase):
             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)
@@ -189,6 +204,9 @@ class ParticipantsTests(TestCase):
         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)

+ 74 - 44
misago/threads/tests/test_post_mentions.py

@@ -23,9 +23,11 @@ class PostMentionsTests(AuthenticatedUserTestCase):
         self.thread = testutils.post_thread(category=self.category)
         self.override_acl()
 
-        self.post_link = reverse('misago:api:thread-post-list', kwargs={
-            'thread_pk': self.thread.pk
-        })
+        self.post_link = reverse(
+            'misago:api:thread-post-list', kwargs={
+                'thread_pk': self.thread.pk,
+            }
+        )
 
     def override_acl(self):
         new_acl = self.user.acl_cache
@@ -34,20 +36,26 @@ class PostMentionsTests(AuthenticatedUserTestCase):
             'can_browse': 1,
             'can_start_threads': 1,
             'can_reply_threads': 1,
-            'can_edit_posts': 1
+            'can_edit_posts': 1,
         })
 
         override_acl(self.user, new_acl)
 
-    def put(self, url, data=None):
+    def put(
+            self,
+            url,
+            data=None,
+    ):
         content = encode_multipart(BOUNDARY, data or {})
         return self.client.put(url, content, content_type=MULTIPART_CONTENT)
 
     def test_mention_noone(self):
         """endpoint handles no mentions in post"""
-        response = self.client.post(self.post_link, data={
-            'post': "This is test response!"
-        })
+        response = self.client.post(
+            self.post_link, data={
+                'post': "This is test response!",
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         post = self.user.post_set.order_by('id').last()
@@ -55,9 +63,11 @@ class PostMentionsTests(AuthenticatedUserTestCase):
 
     def test_mention_nonexistant(self):
         """endpoint handles nonexistant mention"""
-        response = self.client.post(self.post_link, data={
-            'post': "This is test response, @InvalidUser!"
-        })
+        response = self.client.post(
+            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()
@@ -65,9 +75,11 @@ class PostMentionsTests(AuthenticatedUserTestCase):
 
     def test_mention_self(self):
         """endpoint mentions author"""
-        response = self.client.post(self.post_link, data={
-            'post': "This is test response, @{}!".format(self.user)
-        })
+        response = self.client.post(
+            self.post_link, data={
+                'post': "This is test response, @{}!".format(self.user),
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         post = self.user.post_set.order_by('id').last()
@@ -80,16 +92,18 @@ class PostMentionsTests(AuthenticatedUserTestCase):
         users = []
 
         for i in range(MENTIONS_LIMIT + 5):
-            users.append(UserModel.objects.create_user(
-                'Mention{}'.format(i),
-                'mention{}@bob.com'.format(i),
-                'pass123'
-            ))
+            users.append(
+                UserModel.objects.
+                create_user('Mention{}'.format(i), 'mention{}@bob.com'.format(i), 'pass123')
+            )
 
         mentions = ['@{}'.format(u) for u in users]
-        response = self.client.post(self.post_link, data={
-            'post': "This is test response, {}!".format(', '.join(mentions))
-        })
+        response = self.client.post(
+            self.post_link,
+            data={
+                'post': "This is test response, {}!".format(', '.join(mentions)),
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         post = self.user.post_set.order_by('id').last()
@@ -102,9 +116,11 @@ class PostMentionsTests(AuthenticatedUserTestCase):
         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, @{}!".format(user_a)
-        })
+        response = self.client.post(
+            self.post_link, data={
+                'post': "This is test response, @{}!".format(user_a),
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         post = self.user.post_set.order_by('id').last()
@@ -113,15 +129,20 @@ class PostMentionsTests(AuthenticatedUserTestCase):
         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
-        })
+        edit_link = reverse(
+            'misago:api:thread-post-detail', kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': post.pk,
+            }
+        )
 
         self.override_acl()
-        response = self.put(edit_link, data={
-            'post': "This is test response, @{} and @{}!".format(user_a, user_b)
-        })
+        response = self.put(
+            edit_link,
+            data={
+                'post': "This is test response, @{} and @{}!".format(user_a, user_b),
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(post.mentions.count(), 2)
@@ -129,9 +150,11 @@ class PostMentionsTests(AuthenticatedUserTestCase):
 
         # remove first mention from post - should preserve mentions
         self.override_acl()
-        response = self.put(edit_link, data={
-            'post': "This is test response, @{}!".format(user_b)
-        })
+        response = self.put(
+            edit_link, data={
+                'post': "This is test response, @{}!".format(user_b),
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         self.assertEqual(post.mentions.count(), 2)
@@ -139,9 +162,11 @@ class PostMentionsTests(AuthenticatedUserTestCase):
 
         # remove mentions from post - should preserve mentions
         self.override_acl()
-        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)
@@ -152,9 +177,11 @@ class PostMentionsTests(AuthenticatedUserTestCase):
         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, @{}!".format(user_a)
-        })
+        response = self.client.post(
+            self.post_link, data={
+                'post': "This is test response, @{}!".format(user_a),
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         post_a = self.user.post_set.order_by('id').last()
@@ -166,9 +193,12 @@ class PostMentionsTests(AuthenticatedUserTestCase):
         self.user.last_post_on = None
         self.user.save()
 
-        response = self.client.post(self.post_link, data={
-            'post': "This is test response, @{} and @{}!".format(user_a, user_b)
-        })
+        response = self.client.post(
+            self.post_link,
+            data={
+                'post': "This is test response, @{} and @{}!".format(user_a, user_b),
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         post_b = self.user.post_set.order_by('id').last()

+ 51 - 30
misago/threads/tests/test_post_model.py

@@ -26,7 +26,7 @@ class PostModelTests(TestCase):
             starter_slug='tester',
             last_post_on=datetime,
             last_poster_name='Tester',
-            last_poster_slug='tester'
+            last_poster_slug='tester',
         )
 
         self.thread.set_title("Test thread")
@@ -42,7 +42,7 @@ class PostModelTests(TestCase):
             parsed="<p>Hello! I am test message!</p>",
             checksum="nope",
             posted_on=datetime,
-            updated_on=datetime
+            updated_on=datetime,
         )
 
         update_post_checksum(self.post)
@@ -67,39 +67,60 @@ class PostModelTests(TestCase):
             starter_slug='tester',
             last_post_on=timezone.now(),
             last_poster_name='Tester',
-            last_poster_slug='tester'
+            last_poster_slug='tester',
         )
 
+        # can't merge with other users posts
+        with self.assertRaises(ValueError):
+            self.post.merge(
+                Post.objects.create(
+                    category=self.category,
+                    thread=self.thread,
+                    poster=other_user,
+                    poster_name=other_user.username,
+                    poster_ip='127.0.0.1',
+                    original="Hello! I am test message!",
+                    parsed="<p>Hello! I am test message!</p>",
+                    checksum="nope",
+                    posted_on=timezone.now() + timedelta(minutes=5),
+                    updated_on=timezone.now() + timedelta(minutes=5),
+                )
+            )
+
         # can't merge across threads
         with self.assertRaises(ValueError):
-            self.post.merge(Post.objects.create(
-                category=self.category,
-                thread=other_thread,
-                poster=self.user,
-                poster_name=self.user.username,
-                poster_ip='127.0.0.1',
-                original="Hello! I am test message!",
-                parsed="<p>Hello! I am test message!</p>",
-                checksum="nope",
-                posted_on=timezone.now() + timedelta(minutes=5),
-                updated_on=timezone.now() + timedelta(minutes=5)
-            ))
+            self.post.merge(
+                Post.objects.create(
+                    category=self.category,
+                    thread=other_thread,
+                    poster=self.user,
+                    poster_name=self.user.username,
+                    poster_ip='127.0.0.1',
+                    original="Hello! I am test message!",
+                    parsed="<p>Hello! I am test message!</p>",
+                    checksum="nope",
+                    posted_on=timezone.now() + timedelta(minutes=5),
+                    updated_on=timezone.now() + timedelta(minutes=5),
+                )
+            )
 
         # can't merge with events
         with self.assertRaises(ValueError):
-            self.post.merge(Post.objects.create(
-                category=self.category,
-                thread=self.thread,
-                poster=self.user,
-                poster_name=self.user.username,
-                poster_ip='127.0.0.1',
-                original="Hello! I am test message!",
-                parsed="<p>Hello! I am test message!</p>",
-                checksum="nope",
-                posted_on=timezone.now() + timedelta(minutes=5),
-                updated_on=timezone.now() + timedelta(minutes=5),
-                is_event=True
-            ))
+            self.post.merge(
+                Post.objects.create(
+                    category=self.category,
+                    thread=self.thread,
+                    poster=self.user,
+                    poster_name=self.user.username,
+                    poster_ip='127.0.0.1',
+                    original="Hello! I am test message!",
+                    parsed="<p>Hello! I am test message!</p>",
+                    checksum="nope",
+                    posted_on=timezone.now() + timedelta(minutes=5),
+                    updated_on=timezone.now() + timedelta(minutes=5),
+                    is_event=True,
+                )
+            )
 
     def test_merge(self):
         """merge method merges two posts into one"""
@@ -113,7 +134,7 @@ class PostModelTests(TestCase):
             parsed="<p>I am other message!</p>",
             checksum="nope",
             posted_on=timezone.now() + timedelta(minutes=5),
-            updated_on=timezone.now() + timedelta(minutes=5)
+            updated_on=timezone.now() + timedelta(minutes=5),
         )
 
         other_post.merge(self.post)
@@ -131,7 +152,7 @@ class PostModelTests(TestCase):
             starter_slug='tester',
             last_post_on=timezone.now(),
             last_poster_name='Tester',
-            last_poster_slug='tester'
+            last_poster_slug='tester',
         )
 
         self.post.move(new_thread)

+ 319 - 135
misago/threads/tests/test_privatethread_patch_api.py

@@ -21,11 +21,11 @@ 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):
@@ -33,31 +33,51 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         """non-owner can't add participant"""
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
 
-        response = self.patch(self.api_link, [
-            {'op': 'add', 'path': 'participants', 'value': self.user.username}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'add',
+                    'path': 'participants',
+                    'value': self.user.username,
+                },
+            ]
+        )
 
         self.assertContains(
-            response, "be thread owner to add new participants to it", status_code=400)
+            response, "be thread owner to add new participants to it", status_code=400
+        )
 
     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': ''}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'add',
+                    'path': 'participants',
+                    'value': '',
+                },
+            ]
+        )
 
         self.assertContains(
-            response, "You have to enter new participant's username.", status_code=400)
+            response, "You have to enter new participant's username.", status_code=400
+        )
 
     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'}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'add',
+                    'path': 'participants',
+                    'value': 'InvalidUser',
+                },
+            ]
+        )
 
         self.assertContains(response, "No user with such name exists.", status_code=400)
 
@@ -65,21 +85,32 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         """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}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'add',
+                    'path': 'participants',
+                    'value': self.user.username,
+                },
+            ]
+        )
 
-        self.assertContains(
-            response, "This user is already thread participant", status_code=400)
+        self.assertContains(response, "This user is already thread participant", status_code=400)
 
     def test_add_blocking_user(self):
         """can't add user that is already participant"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
         self.other_user.blocks.add(self.user)
 
-        response = self.patch(self.api_link, [
-            {'op': 'add', 'path': 'participants', 'value': self.other_user.username}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'add',
+                    'path': 'participants',
+                    'value': self.other_user.username,
+                },
+            ]
+        )
 
         self.assertContains(response, "BobBoberson is blocking you.", status_code=400)
 
@@ -87,13 +118,17 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         """can't add user that has no permission to use private threads"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
-        override_acl(self.other_user, {
-            'can_use_private_threads': 0
-        })
+        override_acl(self.other_user, {'can_use_private_threads': 0})
 
-        response = self.patch(self.api_link, [
-            {'op': 'add', 'path': 'participants', 'value': self.other_user.username}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'add',
+                    'path': 'participants',
+                    'value': self.other_user.username,
+                },
+            ]
+        )
 
         self.assertContains(response, "BobBoberson can't participate", status_code=400)
 
@@ -103,15 +138,23 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
 
         for i in range(self.user.acl_cache['max_private_thread_participants']):
             user = UserModel.objects.create_user(
-                'User{}'.format(i), 'user{}@example.com'.format(i), 'Pass.123')
+                'User{}'.format(i), 'user{}@example.com'.format(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}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'add',
+                    'path': 'participants',
+                    'value': self.other_user.username,
+                },
+            ]
+        )
 
         self.assertContains(
-            response, "You can't add any more new users to this thread.", status_code=400)
+            response, "You can't add any more new users to this thread.", status_code=400
+        )
 
     def test_add_user_closed_thread(self):
         """adding user to closed thread fails for non-moderator"""
@@ -120,20 +163,33 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        response = self.patch(self.api_link, [
-            {'op': 'add', 'path': 'participants', 'value': self.other_user.username}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'add',
+                    'path': 'participants',
+                    'value': self.other_user.username,
+                },
+            ]
+        )
 
         self.assertContains(
-            response, "Only moderators can add participants to closed threads.", status_code=400)
+            response, "Only moderators can add participants to closed threads.", status_code=400
+        )
 
     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)
 
-        response = self.patch(self.api_link, [
-            {'op': 'add', 'path': 'participants', 'value': self.other_user.username}
-        ])
+        self.patch(
+            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()
@@ -154,13 +210,17 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.has_reported_posts = True
         self.thread.save()
 
-        override_acl(self.user, {
-            'can_moderate_private_threads': 1
-        })
+        override_acl(self.user, {'can_moderate_private_threads': 1})
 
-        response = self.patch(self.api_link, [
-            {'op': 'add', 'path': 'participants', 'value': self.user.username}
-        ])
+        self.patch(
+            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()
@@ -177,13 +237,17 @@ class PrivateThreadAddParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        override_acl(self.user, {
-            'can_moderate_private_threads': 1
-        })
+        override_acl(self.user, {'can_moderate_private_threads': 1})
 
-        response = self.patch(self.api_link, [
-            {'op': 'add', 'path': 'participants', 'value': self.other_user.username}
-        ])
+        self.patch(
+            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()
@@ -203,9 +267,15 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         """api handles empty user id"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
-        response = self.patch(self.api_link, [
-            {'op': 'remove', 'path': 'participants', 'value': 'string'}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'participants',
+                    'value': 'string',
+                },
+            ]
+        )
 
         self.assertContains(response, "Participant doesn't exist.", status_code=400)
 
@@ -213,9 +283,15 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         """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'}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'participants',
+                    'value': 'string',
+                },
+            ]
+        )
 
         self.assertContains(response, "Participant doesn't exist.", status_code=400)
 
@@ -223,9 +299,15 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         """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}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'participants',
+                    'value': self.other_user.pk,
+                },
+            ]
+        )
 
         self.assertContains(response, "Participant doesn't exist.", status_code=400)
 
@@ -234,12 +316,19 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
 
-        response = self.patch(self.api_link, [
-            {'op': 'remove', 'path': 'participants', 'value': self.other_user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'participants',
+                    'value': self.other_user.pk,
+                },
+            ]
+        )
 
         self.assertContains(
-            response, "be thread owner to remove participants from it", status_code=400)
+            response, "be thread owner to remove participants from it", status_code=400
+        )
 
     def test_owner_remove_user_closed_thread(self):
         """api disallows owner to remove other user from closed thread"""
@@ -249,12 +338,19 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        response = self.patch(self.api_link, [
-            {'op': 'remove', 'path': 'participants', 'value': self.other_user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'participants',
+                    'value': self.other_user.pk,
+                },
+            ]
+        )
 
         self.assertContains(
-            response, "moderators can remove participants from closed threads", status_code=400)
+            response, "moderators can remove participants from closed threads", status_code=400
+        )
 
     def test_user_leave_thread(self):
         """api allows user to remove himself from thread"""
@@ -266,9 +362,15 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
             thread=self.thread,
         )
 
-        response = self.patch(self.api_link, [
-            {'op': 'remove', 'path': 'participants', 'value': self.user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'participants',
+                    'value': self.user.pk,
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
         self.assertFalse(response.json()['deleted'])
@@ -300,9 +402,15 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        response = self.patch(self.api_link, [
-            {'op': 'remove', 'path': 'participants', 'value': self.user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'participants',
+                    'value': self.user.pk,
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
         self.assertFalse(response.json()['deleted'])
@@ -325,19 +433,22 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
 
     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])
 
-        override_acl(self.user, {
-            'can_moderate_private_threads': True
-        })
+        override_acl(self.user, {'can_moderate_private_threads': True})
 
-        response = self.patch(self.api_link, [
-            {'op': 'remove', 'path': 'participants', 'value': removed_user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'participants',
+                    'value': removed_user.pk,
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
         self.assertFalse(response.json()['deleted'])
@@ -364,9 +475,15 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.set_owner(self.thread, self.user)
         ThreadParticipant.objects.add_participants(self.thread, [self.other_user])
 
-        response = self.patch(self.api_link, [
-            {'op': 'remove', 'path': 'participants', 'value': self.other_user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'participants',
+                    'value': self.other_user.pk,
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
         self.assertFalse(response.json()['deleted'])
@@ -392,9 +509,15 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.set_owner(self.thread, self.user)
         ThreadParticipant.objects.add_participants(self.thread, [self.other_user])
 
-        response = self.patch(self.api_link, [
-            {'op': 'remove', 'path': 'participants', 'value': self.user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'participants',
+                    'value': self.user.pk,
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
         self.assertFalse(response.json()['deleted'])
@@ -419,9 +542,15 @@ class PrivateThreadRemoveParticipantApiTests(PrivateThreadPatchApiTestCase):
         """api allows last user leave thread, causing thread to delete"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
-        response = self.patch(self.api_link, [
-            {'op': 'remove', 'path': 'participants', 'value': self.user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'remove',
+                    'path': 'participants',
+                    'value': self.user.pk,
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
         self.assertTrue(response.json()['deleted'])
@@ -439,9 +568,15 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         """api handles empty user id"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
 
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'owner', 'value': ''}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'owner',
+                    'value': '',
+                },
+            ]
+        )
 
         self.assertContains(response, "Participant doesn't exist.", status_code=400)
 
@@ -449,9 +584,15 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         """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'}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'owner',
+                    'value': 'dsadsa',
+                },
+            ]
+        )
 
         self.assertContains(response, "Participant doesn't exist.", status_code=400)
 
@@ -459,9 +600,15 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         """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}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'owner',
+                    'value': self.other_user.pk,
+                },
+            ]
+        )
 
         self.assertContains(response, "Participant doesn't exist.", status_code=400)
 
@@ -470,21 +617,34 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         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}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'owner',
+                    'value': self.user.pk,
+                },
+            ]
+        )
 
         self.assertContains(
-            response, "thread owner and moderators can change threads owners", status_code=400)
+            response, "thread owner and moderators can change threads owners", status_code=400
+        )
 
     def test_no_change(self):
         """api validates that new owner id is same as current owner"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
         ThreadParticipant.objects.add_participants(self.thread, [self.other_user])
 
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'owner', 'value': self.user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'owner',
+                    'value': self.user.pk,
+                },
+            ]
+        )
 
         self.assertContains(response, "This user already is thread owner.", status_code=400)
 
@@ -496,21 +656,34 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'owner', 'value': self.other_user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'owner',
+                    'value': self.other_user.pk,
+                },
+            ]
+        )
 
         self.assertContains(
-            response, "Only moderators can change closed threads owners.", status_code=400)
+            response, "Only moderators can change closed threads owners.", status_code=400
+        )
 
     def test_owner_change_thread_owner(self):
         """owner can pass thread ownership to other participant"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
         ThreadParticipant.objects.add_participants(self.thread, [self.other_user])
 
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'owner', 'value': self.other_user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'owner',
+                    'value': self.other_user.pk,
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
 
@@ -530,19 +703,22 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
 
     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])
 
-        override_acl(self.user, {
-            'can_moderate_private_threads': 1
-        })
+        override_acl(self.user, {'can_moderate_private_threads': 1})
 
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'owner', 'value': new_owner.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'owner',
+                    'value': new_owner.pk,
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
 
@@ -567,13 +743,17 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         ThreadParticipant.objects.set_owner(self.thread, self.other_user)
         ThreadParticipant.objects.add_participants(self.thread, [self.user])
 
-        override_acl(self.user, {
-            'can_moderate_private_threads': 1
-        })
+        override_acl(self.user, {'can_moderate_private_threads': 1})
 
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'owner', 'value': self.user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'owner',
+                    'value': self.user.pk,
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
 
@@ -599,13 +779,17 @@ class PrivateThreadTakeOverApiTests(PrivateThreadPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        override_acl(self.user, {
-            'can_moderate_private_threads': 1
-        })
+        override_acl(self.user, {'can_moderate_private_threads': 1})
 
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'owner', 'value': self.user.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'owner',
+                    'value': self.user.pk,
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
 

+ 7 - 5
misago/threads/tests/test_privatethread_reply_api.py

@@ -1,6 +1,5 @@
 from django.contrib.auth import get_user_model
 
-from misago.acl.testutils import override_acl
 from misago.threads import testutils
 from misago.threads.models import ThreadParticipant
 
@@ -18,16 +17,19 @@ 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):
         """api sets other private thread participants sync thread flag"""
         ThreadParticipant.objects.set_owner(self.thread, self.user)
         ThreadParticipant.objects.add_participants(self.thread, [self.other_user])
 
-        response = self.client.post(self.api_link, data={
-            'post': "This is test response!"
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': "This is test response!",
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         # don't count private thread replies

+ 195 - 167
misago/threads/tests/test_privatethread_start_api.py

@@ -1,8 +1,6 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-import json
-
 from django.contrib.auth import get_user_model
 from django.core import mail
 from django.urls import reverse
@@ -10,7 +8,7 @@ from django.utils.encoding import smart_str
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
-from misago.threads.models import Thread, ThreadParticipant
+from misago.threads.models import ThreadParticipant
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -25,7 +23,8 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         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):
         """user has to be authenticated to be able to post private thread"""
@@ -53,144 +52,160 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         response = self.client.post(self.api_link, data={})
 
         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."
-            ]
-        })
+        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."],
+            }
+        )
 
     def test_title_is_validated(self):
         """title is validated"""
-        response = self.client.post(self.api_link, data={
-            'to': [self.other_user.username],
-            'title': "------",
-            'post': "Lorem ipsum dolor met, sit amet elit!",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'title': ["Thread title should contain alpha-numeric characters."],
+            }
+        )
 
     def test_post_is_validated(self):
         """post is validated"""
-        response = self.client.post(self.api_link, data={
-            'to': [self.other_user.username],
-            'title': "Lorem ipsum dolor met",
-            'post': "a",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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)."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'post': ["Posted message should be at least 5 characters long (it has 1)."],
+            }
+        )
 
     def test_cant_invite_self(self):
         """api validates that you cant invite yourself to private thread"""
-        response = self.client.post(self.api_link, data={
-            'to': [self.user.username],
-            'title': "Lorem ipsum dolor met",
-            'post': "Lorem ipsum dolor.",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'to': ["You can't include yourself on the list of users to invite to new thread."],
+            }
+        )
 
     def test_cant_invite_nonexisting(self):
         """api validates that you cant invite nonexisting user to thread"""
-        response = self.client.post(self.api_link, data={
-            'to': ['Ab', 'Cd'],
-            'title': "Lorem ipsum dolor met",
-            'post': "Lorem ipsum dolor.",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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"
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'to': ["One or more users could not be found: ab, cd"],
+            }
+        )
 
     def test_cant_invite_too_many(self):
         """api validates that you cant invite too many users to thread"""
-        response = self.client.post(self.api_link, data={
-            'to': ['Username{}'.format(i) for i in range(50)],
-            'title': "Lorem ipsum dolor met",
-            'post': "Lorem ipsum dolor.",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                'to': ['Username{}'.format(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)."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'to': ["You can't add more than 3 users to private thread (you've added 50)."],
+            }
+        )
 
     def test_cant_invite_no_permission(self):
         """api validates invited user permission to private thread"""
-        override_acl(self.other_user, {
-            'can_use_private_threads': 0
-        })
-
-        response = self.client.post(self.api_link, data={
-            'to': [self.other_user.username],
-            'title': "Lorem ipsum dolor met",
-            'post': "Lorem ipsum dolor.",
-        })
+        override_acl(self.other_user, {'can_use_private_threads': 0})
+
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'to': ["BobBoberson can't participate in private threads."],
+            }
+        )
 
     def test_cant_invite_blocking(self):
         """api validates that you cant invite blocking user to thread"""
         self.other_user.blocks.add(self.user)
 
-        response = self.client.post(self.api_link, data={
-            'to': [self.other_user.username],
-            'title': "Lorem ipsum dolor met",
-            'post': "Lorem ipsum dolor.",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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."
-            ]
+            'to': ["BobBoberson is blocking you."],
         })
 
         # allow us to bypass blocked check
         override_acl(self.user, {'can_add_everyone_to_private_threads': 1})
 
-        response = self.client.post(self.api_link, data={
-            'to': [self.other_user.username],
-            'title': "-----",
-            'post': "Lorem ipsum dolor.",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                'to': [self.other_user.username],
+                'title': "-----",
+                'post': "Lorem ipsum dolor.",
+            }
+        )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'title': [
-                "Thread title should contain alpha-numeric characters."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'title': ["Thread title should contain alpha-numeric characters."],
+            }
+        )
 
     def test_cant_invite_followers_only(self):
         """api validates that you cant invite followers-only user to thread"""
@@ -198,51 +213,60 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         self.other_user.limits_private_thread_invites_to = user_constant
         self.other_user.save()
 
-        response = self.client.post(self.api_link, data={
-            'to': [self.other_user.username],
-            'title': "Lorem ipsum dolor met",
-            'post': "Lorem ipsum dolor.",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'to': ["BobBoberson limits invitations to private threads to followed users."],
+            }
+        )
 
         # allow us to bypass following check
         override_acl(self.user, {'can_add_everyone_to_private_threads': 1})
 
-        response = self.client.post(self.api_link, data={
-            'to': [self.other_user.username],
-            'title': "-----",
-            'post': "Lorem ipsum dolor.",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                'to': [self.other_user.username],
+                'title': "-----",
+                'post': "Lorem ipsum dolor.",
+            }
+        )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'title': [
-                "Thread title should contain alpha-numeric characters."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'title': ["Thread title should contain alpha-numeric characters."],
+            }
+        )
 
         # make user follow us
         override_acl(self.user, {'can_add_everyone_to_private_threads': 0})
         self.other_user.follows.add(self.user)
 
-        response = self.client.post(self.api_link, data={
-            'to': [self.other_user.username],
-            'title': "-----",
-            'post': "Lorem ipsum dolor.",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                'to': [self.other_user.username],
+                'title': "-----",
+                'post': "Lorem ipsum dolor.",
+            }
+        )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'title': [
-                "Thread title should contain alpha-numeric characters."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'title': ["Thread title should contain alpha-numeric characters."],
+            }
+        )
 
     def test_cant_invite_anyone(self):
         """api validates that you cant invite nobody user to thread"""
@@ -250,42 +274,51 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         self.other_user.limits_private_thread_invites_to = user_constant
         self.other_user.save()
 
-        response = self.client.post(self.api_link, data={
-            'to': [self.other_user.username],
-            'title': "Lorem ipsum dolor met",
-            'post': "Lorem ipsum dolor.",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'to': ["BobBoberson is not allowing invitations to private threads."],
+            }
+        )
 
         # allow us to bypass user preference check
         override_acl(self.user, {'can_add_everyone_to_private_threads': 1})
 
-        response = self.client.post(self.api_link, data={
-            'to': [self.other_user.username],
-            'title': "-----",
-            'post': "Lorem ipsum dolor.",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                'to': [self.other_user.username],
+                'title': "-----",
+                'post': "Lorem ipsum dolor.",
+            }
+        )
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'title': [
-                "Thread title should contain alpha-numeric characters."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'title': ["Thread title should contain alpha-numeric characters."],
+            }
+        )
 
     def test_can_start_thread(self):
         """endpoint creates new thread"""
-        response = self.client.post(self.api_link, data={
-            'to': [self.other_user.username],
-            'title': "Hello, I am test thread!",
-            'post': "Lorem ipsum dolor met!"
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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]
@@ -322,18 +355,10 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
         self.assertEqual(thread.participants.count(), 2)
 
         # we are thread owner
-        ThreadParticipant.objects.get(
-            thread=thread,
-            user=self.user,
-            is_owner=True
-        )
+        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)
@@ -354,9 +379,12 @@ class StartPrivateThreadTests(AuthenticatedUserTestCase):
 
     def test_post_unicode(self):
         """unicode characters can be posted"""
-        response = self.client.post(self.api_link, data={
-            'to': [self.other_user.username],
-            'title': "Brzęczyżczykiewicz",
-            'post': "Chrzążczyżewoszyce, powiat Łękółody."
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                'to': [self.other_user.username],
+                'title': "Brzęczyżczykiewicz",
+                'post': "Chrzążczyżewoszyce, powiat Łękółody.",
+            }
+        )
         self.assertEqual(response.status_code, 200)

+ 3 - 9
misago/threads/tests/test_privatethread_view.py

@@ -21,9 +21,7 @@ class PrivateThreadViewTests(PrivateThreadsTestCase):
 
     def test_no_permission(self):
         """user needs to have permission to see private thread"""
-        override_acl(self.user, {
-            'can_use_private_threads': 0
-        })
+        override_acl(self.user, {'can_use_private_threads': 0})
 
         response = self.client.get(self.test_link)
         self.assertContains(response, "t use private threads", status_code=403)
@@ -35,9 +33,7 @@ class PrivateThreadViewTests(PrivateThreadsTestCase):
 
     def test_mod_not_reported(self):
         """moderator can't see private thread that has no reports"""
-        override_acl(self.user, {
-            'can_moderate_private_threads': 1
-        })
+        override_acl(self.user, {'can_moderate_private_threads': 1})
 
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 404)
@@ -66,9 +62,7 @@ class PrivateThreadViewTests(PrivateThreadsTestCase):
 
     def test_mod_can_see_reported(self):
         """moderator can see private thread that has reports"""
-        override_acl(self.user, {
-            'can_moderate_private_threads': 1
-        })
+        override_acl(self.user, {'can_moderate_private_threads': 1})
 
         self.thread.has_reported_posts = True
         self.thread.save()

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

@@ -10,8 +10,9 @@ class PrivateThreadsTestCase(AuthenticatedUserTestCase):
 
         override_acl(self.user, {
             'can_use_private_threads': 1,
-            'can_start_private_threads': 1
+            'can_start_private_threads': 1,
         })
+
         self.override_acl()
 
     def override_acl(self, acl=None):
@@ -26,7 +27,7 @@ class PrivateThreadsTestCase(AuthenticatedUserTestCase):
             'can_edit_posts': 0,
             'can_hide_posts': 0,
             'can_hide_own_posts': 0,
-            'can_merge_threads': 0
+            'can_merge_threads': 0,
         })
 
         if acl:
@@ -34,6 +35,6 @@ class PrivateThreadsTestCase(AuthenticatedUserTestCase):
 
         override_acl(self.user, {
             'categories': {
-                self.category.pk: final_acl
-            }
+                self.category.pk: final_acl,
+            },
         })

+ 37 - 48
misago/threads/tests/test_privatethreads_api.py

@@ -22,9 +22,7 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
 
     def test_no_permission(self):
         """api requires user to have permission to be able to access it"""
-        override_acl(self.user, {
-            'can_use_private_threads': 0
-        })
+        override_acl(self.user, {'can_use_private_threads': 0})
 
         response = self.client.get(self.api_link)
         self.assertContains(response, "can't use private threads", status_code=403)
@@ -40,9 +38,11 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
     def test_thread_visibility(self):
         """only participated threads are returned by private threads api"""
         visible = testutils.post_thread(category=self.category, poster=self.user)
-        hidden = testutils.post_thread(category=self.category, poster=self.user)
         reported = testutils.post_thread(category=self.category, poster=self.user)
 
+        # hidden thread
+        testutils.post_thread(category=self.category, poster=self.user)
+
         ThreadParticipant.objects.add_participants(visible, [self.user])
 
         reported.has_reported_posts = True
@@ -56,9 +56,7 @@ class PrivateThreadsListApiTests(PrivateThreadsTestCase):
         self.assertEqual(response_json['results'][0]['id'], visible.id)
 
         # threads with reported posts will also show to moderators
-        override_acl(self.user, {
-            'can_moderate_private_threads': 1
-        })
+        override_acl(self.user, {'can_moderate_private_threads': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
@@ -85,9 +83,7 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
 
     def test_no_permission(self):
         """user needs to have permission to see private thread"""
-        override_acl(self.user, {
-            'can_use_private_threads': 0
-        })
+        override_acl(self.user, {'can_use_private_threads': 0})
 
         response = self.client.get(self.api_link)
         self.assertContains(response, "t use private threads", status_code=403)
@@ -99,9 +95,7 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
 
     def test_mod_not_reported(self):
         """moderator can't see private thread that has no reports"""
-        override_acl(self.user, {
-            'can_moderate_private_threads': 1
-        })
+        override_acl(self.user, {'can_moderate_private_threads': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
@@ -123,15 +117,17 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
 
         response_json = response.json()
         self.assertEqual(response_json['title'], self.thread.title)
-        self.assertEqual(response_json['participants'], [
-            {
-                'id': self.user.id,
-                'username': self.user.username,
-                'avatars': self.user.avatars,
-                'url': self.user.get_absolute_url(),
-                'is_owner': True
-            }
-        ])
+        self.assertEqual(
+            response_json['participants'], [
+                {
+                    'id': self.user.id,
+                    'username': self.user.username,
+                    'avatars': self.user.avatars,
+                    'url': self.user.get_absolute_url(),
+                    'is_owner': True,
+                },
+            ]
+        )
 
     def test_can_see_participant(self):
         """user can see thread he is participant of"""
@@ -142,21 +138,21 @@ class PrivateThreadRetrieveApiTests(PrivateThreadsTestCase):
 
         response_json = response.json()
         self.assertEqual(response_json['title'], self.thread.title)
-        self.assertEqual(response_json['participants'], [
-            {
-                'id': self.user.id,
-                'username': self.user.username,
-                'avatars': self.user.avatars,
-                'url': self.user.get_absolute_url(),
-                'is_owner': False
-            }
-        ])
+        self.assertEqual(
+            response_json['participants'], [
+                {
+                    'id': self.user.id,
+                    'username': self.user.username,
+                    'avatars': self.user.avatars,
+                    'url': self.user.get_absolute_url(),
+                    'is_owner': False,
+                },
+            ]
+        )
 
     def test_mod_can_see_reported(self):
         """moderator can see private thread that has reports"""
-        override_acl(self.user, {
-            'can_moderate_private_threads': 1
-        })
+        override_acl(self.user, {'can_moderate_private_threads': 1})
 
         self.thread.has_reported_posts = True
         self.thread.save()
@@ -176,9 +172,7 @@ class PrivateThreadsReadApiTests(PrivateThreadsTestCase):
 
     def test_read_threads_no_permission(self):
         """api validates permission to use private threads"""
-        override_acl(self.user, {
-            'can_use_private_threads': 0
-        })
+        override_acl(self.user, {'can_use_private_threads': 0})
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
@@ -210,29 +204,24 @@ class PrivateThreadDeleteApiTests(PrivateThreadsTestCase):
 
     def test_delete_thread_no_permission(self):
         """DELETE to API link with no permission to delete fails"""
-        self.override_acl({
-            'can_hide_threads': 1
-        })
+        self.override_acl({'can_hide_threads': 1})
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
 
-        self.override_acl({
-            'can_hide_threads': 0
-        })
+        self.override_acl({'can_hide_threads': 0})
 
         response_json = response.json()
-        self.assertEqual(response_json['detail'],
-            "You don't have permission to delete this thread.")
+        self.assertEqual(
+            response_json['detail'], "You don't have permission to delete this thread."
+        )
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
 
     def test_delete_thread(self):
         """DELETE to API link with permission deletes thread"""
-        self.override_acl({
-            'can_hide_threads': 2
-        })
+        self.override_acl({'can_hide_threads': 2})
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)

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

@@ -22,9 +22,7 @@ class PrivateThreadsListTests(PrivateThreadsTestCase):
 
     def test_no_permission(self):
         """view requires user to have permission to be able to access it"""
-        override_acl(self.user, {
-            'can_use_private_threads': 0
-        })
+        override_acl(self.user, {'can_use_private_threads': 0})
 
         response = self.client.get(self.test_link)
         self.assertContains(response, "use private threads", status_code=403)
@@ -38,9 +36,11 @@ class PrivateThreadsListTests(PrivateThreadsTestCase):
     def test_thread_visibility(self):
         """only participated threads are returned by private threads view"""
         visible = testutils.post_thread(category=self.category, poster=self.user)
-        hidden = testutils.post_thread(category=self.category, poster=self.user)
         reported = testutils.post_thread(category=self.category, poster=self.user)
 
+        # post hidden thread
+        testutils.post_thread(category=self.category, poster=self.user)
+
         ThreadParticipant.objects.add_participants(visible, [self.user])
 
         reported.has_reported_posts = True
@@ -51,9 +51,7 @@ class PrivateThreadsListTests(PrivateThreadsTestCase):
         self.assertContains(response, visible.get_absolute_url())
 
         # threads with reported posts will also show to moderators
-        override_acl(self.user, {
-            'can_moderate_private_threads': 1
-        })
+        override_acl(self.user, {'can_moderate_private_threads': 1})
 
         response = self.client.get(self.test_link)
         self.assertEqual(response.status_code, 200)

+ 13 - 5
misago/threads/tests/test_search.py

@@ -83,7 +83,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         """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)
@@ -100,7 +103,10 @@ class SearchApiTests(AuthenticatedUserTestCase):
         """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)
@@ -174,6 +180,8 @@ class SearchProviderApiTests(SearchApiTests):
     def setUp(self):
         super(SearchProviderApiTests, self).setUp()
 
-        self.api_link = reverse('misago:api:search', kwargs={
-            'search_provider': 'threads'
-        })
+        self.api_link = reverse(
+            'misago:api:search', kwargs={
+                'search_provider': 'threads',
+            }
+        )

+ 55 - 34
misago/threads/tests/test_subscription_middleware.py

@@ -26,7 +26,7 @@ class SubscriptionMiddlewareTestCase(AuthenticatedUserTestCase):
             'can_see': 1,
             'can_browse': 1,
             'can_start_threads': 1,
-            'can_reply_threads': 1
+            'can_reply_threads': 1,
         })
 
         override_acl(self.user, new_acl)
@@ -43,11 +43,14 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NOTIFY
         self.user.save()
 
-        response = self.client.post(self.api_link, data={
-            'category': self.category.id,
-            'title': "This is an test thread!",
-            'post': "This is test response!"
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                'category': self.category.id,
+                'title': "This is an test thread!",
+                'post': "This is test response!",
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         # user has no subscriptions
@@ -58,11 +61,14 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_NOTIFY
         self.user.save()
 
-        response = self.client.post(self.api_link, data={
-            'category': self.category.id,
-            'title': "This is an test thread!",
-            'post': "This is test response!"
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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
@@ -77,11 +83,14 @@ class SubscribeStartedThreadTests(SubscriptionMiddlewareTestCase):
         self.user.subscribe_to_started_threads = UserModel.SUBSCRIBE_ALL
         self.user.save()
 
-        response = self.client.post(self.api_link, data={
-            'category': self.category.id,
-            'title': "This is an test thread!",
-            'post': "This is test response!"
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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
@@ -96,9 +105,11 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
     def setUp(self):
         super(SubscribeRepliedThreadTests, self).setUp()
         self.thread = testutils.post_thread(self.category)
-        self.api_link = reverse('misago:api:thread-post-list', kwargs={
-            'thread_pk': self.thread.pk
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-list', kwargs={
+                'thread_pk': self.thread.pk,
+            }
+        )
 
     def test_dont_subscribe(self):
         """middleware makes no subscription to thread"""
@@ -106,9 +117,11 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NONE
         self.user.save()
 
-        response = self.client.post(self.api_link, data={
-            'post': "This is test response!"
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': "This is test response!",
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         # user has no subscriptions
@@ -119,9 +132,11 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_NOTIFY
         self.user.save()
 
-        response = self.client.post(self.api_link, data={
-            'post': "This is test response!"
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': "This is test response!",
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         # user has subscribed to thread
@@ -135,9 +150,11 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
         self.user.save()
 
-        response = self.client.post(self.api_link, data={
-            'post': "This is test response!"
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': "This is test response!",
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         # user has subscribed to thread
@@ -151,17 +168,21 @@ class SubscribeRepliedThreadTests(SubscriptionMiddlewareTestCase):
         self.user.subscribe_to_replied_threads = UserModel.SUBSCRIBE_ALL
         self.user.save()
 
-        response = self.client.post(self.api_link, data={
-            'post': "This is test response!"
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': "This is test response!",
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         # clear subscription
         self.user.subscription_set.all().delete()
         # reply again
-        response = self.client.post(self.api_link, data={
-            'post': "This is test response!"
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': "This is test response!",
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         # user has no subscriptions

+ 5 - 24
misago/threads/tests/test_subscriptions.py

@@ -3,7 +3,6 @@ from datetime import timedelta
 from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.utils import timezone
-from django.utils.six.moves import range
 
 from misago.categories.models import Category
 from misago.threads import testutils
@@ -19,14 +18,13 @@ class SubscriptionsTests(TestCase):
         self.category = list(Category.objects.all_categories()[:1])[0]
         self.thread = self.post_thread(timezone.now() - timedelta(days=10))
 
-        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.anon = AnonymousUser()
 
     def post_thread(self, datetime):
         return testutils.post_thread(
             category=self.category,
-            started_on=datetime
+            started_on=datetime,
         )
 
     def test_anon_subscription(self):
@@ -37,9 +35,8 @@ class SubscriptionsTests(TestCase):
     def test_anon_threads_subscription(self):
         """make multiple threads list sub aware for anon"""
         threads = []
-        for i in range(10):
-            threads.append(
-                self.post_thread(timezone.now() - timedelta(days=10)))
+        for _ in range(10):
+            threads.append(self.post_thread(timezone.now() - timedelta(days=10)))
 
         make_subscription_aware(self.anon, threads)
 
@@ -51,24 +48,11 @@ class SubscriptionsTests(TestCase):
         make_subscription_aware(self.user, self.thread)
         self.assertIsNone(self.thread.subscription)
 
-    def test_threads_no_subscription(self):
-        """make mulitple threads sub aware for authenticated"""
-        threads = []
-        for i in range(10):
-            threads.append(
-                self.post_thread(timezone.now() - timedelta(days=10)))
-
-        make_subscription_aware(self.user, threads)
-
-        for thread in threads:
-            self.assertIsNone(thread.subscription)
-
     def test_subscribed_thread(self):
         """make thread sub aware for authenticated"""
         self.user.subscription_set.create(
             thread=self.thread,
             category=self.category,
-
             last_read_on=timezone.now(),
             send_email=True,
         )
@@ -80,14 +64,12 @@ class SubscriptionsTests(TestCase):
         """make mulitple threads sub aware for authenticated"""
         threads = []
         for i in range(10):
-            threads.append(
-                self.post_thread(timezone.now() - timedelta(days=10)))
+            threads.append(self.post_thread(timezone.now() - timedelta(days=10)))
 
             if i % 3 == 0:
                 self.user.subscription_set.create(
                     thread=threads[-1],
                     category=self.category,
-
                     last_read_on=timezone.now(),
                     send_email=False,
                 )
@@ -95,7 +77,6 @@ class SubscriptionsTests(TestCase):
                 self.user.subscription_set.create(
                     thread=threads[-1],
                     category=self.category,
-
                     last_read_on=timezone.now(),
                     send_email=True,
                 )

+ 2 - 1
misago/threads/tests/test_sync_unread_private_threads.py

@@ -14,7 +14,8 @@ class SyncUnreadPrivateThreadsTestCase(PrivateThreadsTestCase):
         super(SyncUnreadPrivateThreadsTestCase, self).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)
 

+ 2 - 3
misago/threads/tests/test_synchronizethreads.py

@@ -1,7 +1,6 @@
 from django.core.management import call_command
 from django.test import TestCase
 from django.utils.six import StringIO
-from django.utils.six.moves import range
 
 from misago.categories.models import Category
 from misago.threads import testutils
@@ -23,9 +22,9 @@ class SynchronizeThreadsTests(TestCase):
         """command synchronizes threads"""
         category = Category.objects.all_categories()[:1][0]
 
-        threads = [testutils.post_thread(category) for t in range(10)]
+        threads = [testutils.post_thread(category) for _ in range(10)]
         for i, thread in enumerate(threads):
-            [testutils.reply_thread(thread) for r in range(i)]
+            [testutils.reply_thread(thread) for _ in range(i)]
             thread.replies = 0
             thread.save()
 

+ 72 - 89
misago/threads/tests/test_thread_editreply_api.py

@@ -1,16 +1,12 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-import json
-
 from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
 from django.urls import reverse
-from django.utils.encoding import smart_str
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.threads import testutils
-from misago.threads.models import Thread
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -22,10 +18,13 @@ class EditReplyTests(AuthenticatedUserTestCase):
         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
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-detail',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.post.pk,
+            }
+        )
 
     def override_acl(self, extra_acl=None):
         new_acl = self.user.acl_cache
@@ -34,7 +33,7 @@ class EditReplyTests(AuthenticatedUserTestCase):
             'can_browse': 1,
             'can_start_threads': 0,
             'can_reply_threads': 0,
-            'can_edit_posts': 1
+            'can_edit_posts': 1,
         })
 
         if extra_acl:
@@ -69,81 +68,73 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
     def test_cant_edit_reply(self):
         """permission to edit reply is validated"""
-        self.override_acl({
-            'can_edit_posts': 0
-        })
+        self.override_acl({'can_edit_posts': 0})
 
         response = self.put(self.api_link)
         self.assertContains(response, "You can't edit posts in this category.", status_code=403)
 
     def test_cant_edit_other_user_reply(self):
         """permission to edit reply by other users is validated"""
-        self.override_acl({
-            'can_edit_posts': 1
-        })
+        self.override_acl({'can_edit_posts': 1})
 
         self.post.poster = None
         self.post.save()
 
         response = self.put(self.api_link)
-        self.assertContains(response, "You can't edit other users posts in this category.", status_code=403)
+        self.assertContains(
+            response, "You can't edit other users posts in this category.", status_code=403
+        )
 
     def test_closed_category(self):
         """permssion to edit reply in closed category is validated"""
-        self.override_acl({
-            'can_close_threads': 0
-        })
+        self.override_acl({'can_close_threads': 0})
 
         self.category.is_closed = True
         self.category.save()
 
         response = self.put(self.api_link)
-        self.assertContains(response, "This category is closed. You can't edit posts in it.", status_code=403)
+        self.assertContains(
+            response, "This category is closed. You can't edit posts in it.", status_code=403
+        )
 
         # allow to post in closed category
-        self.override_acl({
-            'can_close_threads': 1
-        })
+        self.override_acl({'can_close_threads': 1})
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
 
     def test_closed_thread(self):
         """permssion to edit reply in closed thread is validated"""
-        self.override_acl({
-            'can_close_threads': 0
-        })
+        self.override_acl({'can_close_threads': 0})
 
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.put(self.api_link)
-        self.assertContains(response, "This thread is closed. You can't edit posts in it.", status_code=403)
+        self.assertContains(
+            response, "This thread is closed. You can't edit posts in it.", status_code=403
+        )
 
         # allow to post in closed thread
-        self.override_acl({
-            'can_close_threads': 1
-        })
+        self.override_acl({'can_close_threads': 1})
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
 
     def test_protected_post(self):
         """permssion to edit protected post is validated"""
-        self.override_acl({
-            'can_protect_posts': 0
-        })
+        self.override_acl({'can_protect_posts': 0})
 
         self.post.is_protected = True
         self.post.save()
 
         response = self.put(self.api_link)
-        self.assertContains(response, "This post is protected. You can't edit it.", status_code=403)
+        self.assertContains(
+            response, "This post is protected. You can't edit it.", status_code=403
+        )
 
         # allow to post in closed thread
-        self.override_acl({
-            'can_protect_posts': 1
-        })
+        self.override_acl({'can_protect_posts': 1})
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
@@ -155,10 +146,8 @@ class EditReplyTests(AuthenticatedUserTestCase):
         response = self.put(self.api_link, data={})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(json.loads(smart_str(response.content)), {
-            'post': [
-                "You have to enter a message."
-            ]
+        self.assertEqual(response.json(), {
+            'post': ["You have to enter a message."],
         })
 
     def test_edit_event(self):
@@ -176,29 +165,27 @@ class EditReplyTests(AuthenticatedUserTestCase):
         """post is validated"""
         self.override_acl()
 
-        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(json.loads(smart_str(response.content)), {
-            'post': [
-                "Posted message should be at least 5 characters long (it has 1)."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'post': ["Posted message should be at least 5 characters long (it has 1)."],
+            }
+        )
 
     def test_edit_reply_no_change(self):
         """endpoint isn't bumping edits count if no change was made to post's body"""
         self.override_acl()
         self.assertEqual(self.post.edits_record.count(), 0)
 
-        response = self.put(self.api_link, data={
-            'post': self.post.original
-        })
+        response = self.put(self.api_link, data={'post': self.post.original})
         self.assertEqual(response.status_code, 200)
 
-        thread = Thread.objects.get(pk=self.thread.pk)
-
         self.override_acl()
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, self.post.parsed)
@@ -217,13 +204,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
         self.override_acl()
         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)
 
-        thread = Thread.objects.get(pk=self.thread.pk)
-
         self.override_acl()
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, "<p>This is test edit!</p>")
@@ -247,36 +230,34 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
     def test_edit_first_post_hidden(self):
         """endpoint updates hidden thread's first post"""
-        self.override_acl({
-            'can_hide_threads': 1,
-            'can_edit_posts': 2
-        })
+        self.override_acl({'can_hide_threads': 1, 'can_edit_posts': 2})
 
         self.thread.is_hidden = True
         self.thread.save()
         self.thread.first_post.is_hidden = True
         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
-        })
+        api_link = reverse(
+            'misago:api:thread-post-detail',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.thread.first_post.pk,
+            }
+        )
 
-        response = self.put(self.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)
 
     def test_protect_post(self):
         """can protect post"""
-        self.override_acl({
-            'can_protect_posts': 1
-        })
-
-        response = self.put(self.api_link, data={
-            'post': "Lorem ipsum dolor met!",
-            'protect': 1
-        })
+        self.override_acl({'can_protect_posts': 1})
+
+        response = self.put(
+            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()
@@ -284,14 +265,14 @@ class EditReplyTests(AuthenticatedUserTestCase):
 
     def test_protect_post_no_permission(self):
         """cant protect post without permission"""
-        self.override_acl({
-            'can_protect_posts': 0
-        })
-
-        response = self.put(self.api_link, data={
-            'post': "Lorem ipsum dolor met!",
-            'protect': 1
-        })
+        self.override_acl({'can_protect_posts': 0})
+
+        response = self.put(
+            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()
@@ -301,7 +282,9 @@ class EditReplyTests(AuthenticatedUserTestCase):
         """unicode characters can be posted"""
         self.override_acl()
 
-        response = self.put(self.api_link, data={
-            'post': "Chrzążczyżewoszyce, powiat Łękółody."
-        })
+        response = self.put(
+            self.api_link, data={
+                'post': "Chrzążczyżewoszyce, powiat Łękółody.",
+            }
+        )
         self.assertEqual(response.status_code, 200)

+ 143 - 153
misago/threads/tests/test_thread_merge_api.py

@@ -1,7 +1,4 @@
-import json
-
 from django.urls import reverse
-from django.utils.encoding import smart_str
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
@@ -18,10 +15,18 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         Category(
             name='Category B',
             slug='category-b',
-        ).insert_at(self.category, position='last-child', save=True)
+        ).insert_at(
+            self.category,
+            position='last-child',
+            save=True,
+        )
         self.category_b = Category.objects.get(slug='category-b')
 
-        self.api_link = reverse('misago:api:thread-merge', kwargs={'pk': self.thread.pk})
+        self.api_link = reverse(
+            'misago:api:thread-merge', kwargs={
+                'pk': self.thread.pk,
+            }
+        )
 
     def override_other_acl(self, acl=None):
         other_category_acl = self.user.acl_cache['categories'][self.category.pk].copy()
@@ -35,7 +40,7 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
             'can_edit_posts': 0,
             'can_hide_posts': 0,
             'can_hide_own_posts': 0,
-            'can_merge_threads': 0
+            'can_merge_threads': 0,
         })
 
         if acl:
@@ -48,56 +53,54 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         if other_category_acl['can_see']:
             visible_categories.append(self.category_b.pk)
 
-        override_acl(self.user, {
-            'visible_categories': visible_categories,
-            'categories': categories_acl,
-        })
+        override_acl(
+            self.user, {
+                'visible_categories': visible_categories,
+                'categories': categories_acl,
+            }
+        )
 
     def test_merge_no_permission(self):
         """api validates if thread can be merged with other one"""
-        self.override_acl({
-            'can_merge_threads': 0
-        })
+        self.override_acl({'can_merge_threads': 0})
 
         response = self.client.post(self.api_link)
-        self.assertContains(response, "You don't have permission to merge this thread with others.", status_code=403)
+        self.assertContains(
+            response,
+            "You don't have permission to merge this thread with others.",
+            status_code=403
+        )
 
     def test_merge_no_url(self):
         """api validates if thread url was given"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
         response = self.client.post(self.api_link)
         self.assertContains(response, "This is not a valid thread link.", status_code=400)
 
     def test_invalid_url(self):
         """api validates thread url"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
         response = self.client.post(self.api_link, {
-            'thread_url': self.user.get_absolute_url()
+            'thread_url': self.user.get_absolute_url(),
         })
         self.assertContains(response, "This is not a valid thread link.", status_code=400)
 
     def test_current_thread_url(self):
         """api validates if thread url given is to current thread"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
-        response = self.client.post(self.api_link, {
-            'thread_url': self.thread.get_absolute_url()
-        })
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': self.thread.get_absolute_url(),
+            }
+        )
         self.assertContains(response, "You can't merge thread with itself.", status_code=400)
 
     def test_other_thread_exists(self):
         """api validates if other thread exists"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
         self.override_other_acl()
 
@@ -106,76 +109,76 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
         other_thread.delete()
 
         response = self.client.post(self.api_link, {
-            'thread_url': other_thread_url
+            'thread_url': other_thread_url,
         })
-        self.assertContains(response, "The thread you have entered link to doesn't exist", status_code=400)
+        self.assertContains(
+            response, "The thread you have entered link to doesn't exist", status_code=400
+        )
 
     def test_other_thread_is_invisible(self):
         """api validates if other thread is visible"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
-        self.override_other_acl({
-            'can_see': 0
-        })
+        self.override_other_acl({'can_see': 0})
 
         other_thread = testutils.post_thread(self.category_b)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url()
-        })
-        self.assertContains(response, "The thread you have entered link to doesn't exist", status_code=400)
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+            }
+        )
+        self.assertContains(
+            response, "The thread you have entered link to doesn't exist", status_code=400
+        )
 
     def test_other_thread_isnt_mergeable(self):
         """api validates if other thread can be merged"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
-        self.override_other_acl({
-            'can_merge_threads': 0
-        })
+        self.override_other_acl({'can_merge_threads': 0})
 
         other_thread = testutils.post_thread(self.category_b)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url()
-        })
-        self.assertContains(response, "You don't have permission to merge this thread", status_code=400)
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+            }
+        )
+        self.assertContains(
+            response, "You don't have permission to merge this thread", status_code=400
+        )
 
     def test_other_thread_isnt_replyable(self):
         """api validates if other thread can be replied, which is condition for merg"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
-        self.override_other_acl({
-            'can_reply_threads': 0
-        })
+        self.override_other_acl({'can_reply_threads': 0})
 
         other_thread = testutils.post_thread(self.category_b)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url()
-        })
-        self.assertContains(response, "You can't merge this thread into thread you can't reply.", status_code=400)
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+            }
+        )
+        self.assertContains(
+            response, "You can't merge this thread into thread you can't reply.", status_code=400
+        )
 
     def test_merge_threads(self):
         """api merges two threads successfully"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
-        self.override_other_acl({
-            'can_merge_threads': 1
-        })
+        self.override_other_acl({'can_merge_threads': 1})
 
         other_thread = testutils.post_thread(self.category_b)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url()
-        })
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+            }
+        )
         self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
 
         # other thread has two posts now
@@ -187,20 +190,18 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
 
     def test_merge_threads_kept_poll(self):
         """api merges two threads successfully, keeping poll from old thread"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
-        self.override_other_acl({
-            'can_merge_threads': 1
-        })
+        self.override_other_acl({'can_merge_threads': 1})
 
         other_thread = testutils.post_thread(self.category_b)
         poll = testutils.post_poll(other_thread, self.user)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url()
-        })
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+            }
+        )
         self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
 
         # other thread has two posts now
@@ -216,20 +217,18 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
 
     def test_merge_threads_moved_poll(self):
         """api merges two threads successfully, moving poll from other thread"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
-        self.override_other_acl({
-            'can_merge_threads': 1
-        })
+        self.override_other_acl({'can_merge_threads': 1})
 
         other_thread = testutils.post_thread(self.category_b)
         poll = testutils.post_poll(self.thread, self.user)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url()
-        })
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+            }
+        )
         self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
 
         # other thread has two posts now
@@ -245,29 +244,29 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
 
     def test_threads_merge_conflict(self):
         """api errors on merge conflict, returning list of available polls"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
-        self.override_other_acl({
-            'can_merge_threads': 1
-        })
+        self.override_other_acl({'can_merge_threads': 1})
 
         other_thread = testutils.post_thread(self.category_b)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url()
-        })
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+            }
+        )
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'polls': [
-                [0, "Delete all polls"],
-                [poll.pk, poll.question],
-                [other_poll.pk, other_poll.question]
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'polls': [
+                    [0, "Delete all polls"],
+                    [poll.pk, poll.question],
+                    [other_poll.pk, other_poll.question],
+                ]
+            }
+        )
 
         # polls and votes were untouched
         self.assertEqual(Poll.objects.count(), 2)
@@ -275,26 +274,23 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
 
     def test_threads_merge_conflict_invalid_resolution(self):
         """api errors on invalid merge conflict resolution"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
-        self.override_other_acl({
-            'can_merge_threads': 1
-        })
+        self.override_other_acl({'can_merge_threads': 1})
 
         other_thread = testutils.post_thread(self.category_b)
-        poll = testutils.post_poll(self.thread, self.user)
-        other_poll = testutils.post_poll(other_thread, self.user)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url(),
-            'poll': 'jhdkajshdsak'
-        })
+        testutils.post_poll(self.thread, self.user)
+        testutils.post_poll(other_thread, self.user)
+
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+                'poll': 'jhdkajshdsak',
+            }
+        )
         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)
@@ -302,22 +298,20 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
 
     def test_threads_merge_conflict_delete_all(self):
         """api deletes all polls when delete all choice is selected"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
-        self.override_other_acl({
-            'can_merge_threads': 1
-        })
+        self.override_other_acl({'can_merge_threads': 1})
 
         other_thread = testutils.post_thread(self.category_b)
-        poll = testutils.post_poll(self.thread, self.user)
-        other_poll = testutils.post_poll(other_thread, self.user)
-
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url(),
-            'poll': 0
-        })
+        testutils.post_poll(self.thread, self.user)
+        testutils.post_poll(other_thread, self.user)
+
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+                'poll': 0,
+            }
+        )
         self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
 
         # other thread has two posts now
@@ -333,22 +327,20 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
 
     def test_threads_merge_conflict_keep_first_poll(self):
         """api deletes other poll on merge"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
-        self.override_other_acl({
-            'can_merge_threads': 1
-        })
+        self.override_other_acl({'can_merge_threads': 1})
 
         other_thread = testutils.post_thread(self.category_b)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url(),
-            'poll': poll.pk
-        })
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+                'poll': poll.pk,
+            }
+        )
         self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
 
         # other thread has two posts now
@@ -371,22 +363,20 @@ class ThreadMergeApiTests(ThreadsApiTestCase):
 
     def test_threads_merge_conflict_keep_other_poll(self):
         """api deletes first poll on merge"""
-        self.override_acl({
-            'can_merge_threads': 1
-        })
+        self.override_acl({'can_merge_threads': 1})
 
-        self.override_other_acl({
-            'can_merge_threads': 1
-        })
+        self.override_other_acl({'can_merge_threads': 1})
 
         other_thread = testutils.post_thread(self.category_b)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url(),
-            'poll': other_poll.pk
-        })
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+                'poll': other_poll.pk,
+            }
+        )
         self.assertContains(response, other_thread.get_absolute_url(), status_code=200)
 
         # other thread has two posts now

+ 23 - 24
misago/threads/tests/test_thread_model.py

@@ -23,7 +23,7 @@ class ThreadModelTests(TestCase):
             starter_slug='tester',
             last_post_on=datetime,
             last_poster_name='Tester',
-            last_poster_slug='tester'
+            last_poster_slug='tester',
         )
 
         self.thread.set_title("Test thread")
@@ -38,7 +38,7 @@ class ThreadModelTests(TestCase):
             parsed="<p>Hello! I am test message!</p>",
             checksum="nope",
             posted_on=datetime,
-            updated_on=datetime
+            updated_on=datetime,
         )
 
         self.thread.first_post = post
@@ -47,8 +47,7 @@ class ThreadModelTests(TestCase):
 
     def test_synchronize(self):
         """synchronize method updates thread data to reflect its contents"""
-        user = UserModel.objects.create_user(
-            "Bob", "bob@boberson.com", "Pass.123")
+        user = UserModel.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
 
         self.assertEqual(self.thread.replies, 0)
 
@@ -63,7 +62,7 @@ class ThreadModelTests(TestCase):
             parsed="<p>Hello! I am test message!</p>",
             checksum="nope",
             posted_on=datetime,
-            updated_on=datetime
+            updated_on=datetime,
         )
 
         # first sync call, updates last thread
@@ -91,7 +90,7 @@ class ThreadModelTests(TestCase):
             checksum="nope",
             posted_on=datetime + timedelta(5),
             updated_on=datetime + timedelta(5),
-            is_unapproved=True
+            is_unapproved=True,
         )
 
         self.thread.synchronize()
@@ -117,7 +116,7 @@ class ThreadModelTests(TestCase):
             checksum="nope",
             posted_on=datetime + timedelta(10),
             updated_on=datetime + timedelta(10),
-            is_hidden=True
+            is_hidden=True,
         )
 
         self.thread.synchronize()
@@ -163,7 +162,7 @@ class ThreadModelTests(TestCase):
         self.assertFalse(self.thread.has_hidden_posts)
         self.assertEqual(self.thread.replies, 3)
 
-         # add event post
+        # add event post
         event = Post.objects.create(
             category=self.category,
             thread=self.thread,
@@ -175,7 +174,7 @@ class ThreadModelTests(TestCase):
             checksum="nope",
             posted_on=datetime + timedelta(10),
             updated_on=datetime + timedelta(10),
-            is_event=True
+            is_event=True,
         )
 
         self.thread.synchronize()
@@ -203,7 +202,7 @@ class ThreadModelTests(TestCase):
             parsed="<p>Hello! I am test message!</p>",
             checksum="nope",
             posted_on=datetime,
-            updated_on=datetime
+            updated_on=datetime,
         )
 
         self.thread.synchronize()
@@ -226,7 +225,7 @@ class ThreadModelTests(TestCase):
             poster_name='test',
             poster_slug='test',
             poster_ip='127.0.0.1',
-            choices=[]
+            choices=[],
         )
 
         self.thread.synchronize()
@@ -234,8 +233,7 @@ class ThreadModelTests(TestCase):
 
     def test_set_first_post(self):
         """set_first_post sets first post and poster data on thread"""
-        user = UserModel.objects.create_user(
-            "Bob", "bob@boberson.com", "Pass.123")
+        user = UserModel.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
 
         datetime = timezone.now() + timedelta(5)
 
@@ -249,7 +247,7 @@ class ThreadModelTests(TestCase):
             parsed="<p>Hello! I am test message!</p>",
             checksum="nope",
             posted_on=datetime,
-            updated_on=datetime
+            updated_on=datetime,
         )
 
         self.thread.set_first_post(post)
@@ -261,8 +259,7 @@ class ThreadModelTests(TestCase):
 
     def test_set_last_post(self):
         """set_last_post sets first post and poster data on thread"""
-        user = UserModel.objects.create_user(
-            "Bob", "bob@boberson.com", "Pass.123")
+        user = UserModel.objects.create_user("Bob", "bob@boberson.com", "Pass.123")
 
         datetime = timezone.now() + timedelta(5)
 
@@ -276,7 +273,7 @@ class ThreadModelTests(TestCase):
             parsed="<p>Hello! I am test message!</p>",
             checksum="nope",
             posted_on=datetime,
-            updated_on=datetime
+            updated_on=datetime,
         )
 
         self.thread.set_last_post(post)
@@ -293,7 +290,11 @@ class ThreadModelTests(TestCase):
         Category(
             name='New Category',
             slug='new-category',
-        ).insert_at(root_category, position='last-child', save=True)
+        ).insert_at(
+            root_category,
+            position='last-child',
+            save=True,
+        )
         new_category = Category.objects.get(slug='new-category')
 
         self.thread.move(new_category)
@@ -316,7 +317,7 @@ class ThreadModelTests(TestCase):
             starter_slug='tester',
             last_post_on=datetime,
             last_poster_name='Tester',
-            last_poster_slug='tester'
+            last_poster_slug='tester',
         )
 
         other_thread.set_title("Other thread")
@@ -331,7 +332,7 @@ class ThreadModelTests(TestCase):
             parsed="<p>Hello! I am other message!</p>",
             checksum="nope",
             posted_on=datetime,
-            updated_on=datetime
+            updated_on=datetime,
         )
 
         other_thread.first_post = post
@@ -352,10 +353,8 @@ class ThreadModelTests(TestCase):
         private thread gets deleted automatically
         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_a = UserModel.objects.create_user("Bob", "bob@boberson.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)

+ 468 - 295
misago/threads/tests/test_thread_patch_api.py

@@ -11,15 +11,18 @@ 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}
+            {
+                'op': 'add',
+                'path': 'acl',
+                'value': True,
+            },
         ])
         self.assertEqual(response.status_code, 200)
 
@@ -29,7 +32,11 @@ class ThreadAddAclApiTests(ThreadPatchApiTestCase):
     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}
+            {
+                'op': 'add',
+                'path': 'acl',
+                'value': False,
+            },
         ])
         self.assertEqual(response.status_code, 200)
 
@@ -40,13 +47,17 @@ class ThreadAddAclApiTests(ThreadPatchApiTestCase):
 class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
     def test_change_thread_title(self):
         """api makes it possible to change thread title"""
-        self.override_acl({
-            'can_edit_threads': 2
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'title', 'value': "Lorem ipsum change!"}
-        ])
+        self.override_acl({'can_edit_threads': 2})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'title',
+                    'value': "Lorem ipsum change!",
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         thread_json = self.get_thread_json()
@@ -54,65 +65,82 @@ class ThreadChangeTitleApiTests(ThreadPatchApiTestCase):
 
     def test_change_thread_title_no_permission(self):
         """api validates permission to change title"""
-        self.override_acl({
-            'can_edit_threads': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'title', 'value': "Lorem ipsum change!"}
-        ])
+        self.override_acl({'can_edit_threads': 0})
+
+        response = self.patch(
+            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.")
 
     def test_change_thread_title_after_edit_time(self):
         """api cleans, validates and rejects too short title"""
-        self.override_acl({
-            'thread_edit_time': 1,
-            'can_edit_threads': 1
-        })
+        self.override_acl({'thread_edit_time': 1, 'can_edit_threads': 1})
 
         self.thread.starter = self.user
         self.thread.started_on = timezone.now() - timedelta(minutes=10)
         self.thread.save()
 
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'title', 'value': "Lorem ipsum change!"}
-        ])
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            response_json['detail'][0], "You can't edit threads that are older than 1 minute."
+        )
 
     def test_change_thread_title_invalid(self):
         """api cleans, validates and rejects too short title"""
-        self.override_acl({
-            'can_edit_threads': 2
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'title', 'value': 12}
-        ])
+        self.override_acl({'can_edit_threads': 2})
+
+        response = self.patch(
+            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).")
+        self.assertEqual(
+            response_json['detail'][0],
+            "Thread title should be at least 5 characters long (it has 2)."
+        )
 
 
 class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
     def test_pin_thread(self):
         """api makes it possible to pin globally thread"""
-        self.override_acl({
-            'can_pin_threads': 2
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'weight', 'value': 2}
-        ])
+        self.override_acl({'can_pin_threads': 2})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'weight',
+                    'value': 2,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         thread_json = self.get_thread_json()
@@ -126,13 +154,17 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 2)
 
-        self.override_acl({
-            'can_pin_threads': 2
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'weight', 'value': 0}
-        ])
+        self.override_acl({'can_pin_threads': 2})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'weight',
+                    'value': 0,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         thread_json = self.get_thread_json()
@@ -140,18 +172,23 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
 
     def test_pin_thread_no_permission(self):
         """api pin thread globally with no permission fails"""
-        self.override_acl({
-            'can_pin_threads': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'weight', 'value': 2}
-        ])
+        self.override_acl({'can_pin_threads': 1})
+
+        response = self.patch(
+            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 don't have permission to pin this thread globally.")
+        self.assertEqual(
+            response_json['detail'][0], "You don't have permission to pin this thread globally."
+        )
 
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 0)
@@ -164,18 +201,23 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 2)
 
-        self.override_acl({
-            'can_pin_threads': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'weight', 'value': 1}
-        ])
+        self.override_acl({'can_pin_threads': 1})
+
+        response = self.patch(
+            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 don't have permission to change this thread's weight.")
+        self.assertEqual(
+            response_json['detail'][0], "You don't have permission to change this thread's weight."
+        )
 
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 2)
@@ -184,13 +226,17 @@ class ThreadPinGloballyApiTests(ThreadPatchApiTestCase):
 class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
     def test_pin_thread(self):
         """api makes it possible to pin locally thread"""
-        self.override_acl({
-            'can_pin_threads': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'weight', 'value': 1}
-        ])
+        self.override_acl({'can_pin_threads': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'weight',
+                    'value': 1,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         thread_json = self.get_thread_json()
@@ -204,13 +250,17 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 1)
 
-        self.override_acl({
-            'can_pin_threads': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'weight', 'value': 0}
-        ])
+        self.override_acl({'can_pin_threads': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'weight',
+                    'value': 0,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         thread_json = self.get_thread_json()
@@ -218,18 +268,23 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
 
     def test_pin_thread_no_permission(self):
         """api pin thread locally with no permission fails"""
-        self.override_acl({
-            'can_pin_threads': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'weight', 'value': 1}
-        ])
+        self.override_acl({'can_pin_threads': 0})
+
+        response = self.patch(
+            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 don't have permission to change this thread's weight.")
+        self.assertEqual(
+            response_json['detail'][0], "You don't have permission to change this thread's weight."
+        )
 
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 0)
@@ -242,18 +297,23 @@ class ThreadPinLocallyApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 1)
 
-        self.override_acl({
-            'can_pin_threads': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'weight', 'value': 0}
-        ])
+        self.override_acl({'can_pin_threads': 0})
+
+        response = self.patch(
+            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 don't have permission to change this thread's weight.")
+        self.assertEqual(
+            response_json['detail'][0], "You don't have permission to change this thread's weight."
+        )
 
         thread_json = self.get_thread_json()
         self.assertEqual(thread_json['weight'], 1)
@@ -266,7 +326,11 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         Category(
             name='Category B',
             slug='category-b',
-        ).insert_at(self.category, position='last-child', save=True)
+        ).insert_at(
+            self.category,
+            position='last-child',
+            save=True,
+        )
         self.category_b = Category.objects.get(slug='category-b')
 
     def override_other_acl(self, acl):
@@ -288,25 +352,37 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
         if other_category_acl['can_see']:
             visible_categories.append(self.category_b.pk)
 
-        override_acl(self.user, {
-            'visible_categories': visible_categories,
-            'categories': categories_acl,
-        })
+        override_acl(
+            self.user, {
+                'visible_categories': visible_categories,
+                'categories': categories_acl,
+            }
+        )
 
     def test_move_thread_no_top(self):
         """api moves thread to other category, sets no top category"""
-        self.override_acl({
-            'can_move_threads': True
-        })
-        self.override_other_acl({
-            'can_start_threads': 2
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'category', 'value': self.category_b.pk},
-            {'op': 'add', 'path': 'top-category', 'value': self.category_b.pk},
-            {'op': 'replace', 'path': 'flatten-categories', 'value': None},
-        ])
+        self.override_acl({'can_move_threads': True})
+        self.override_other_acl({'can_start_threads': 2})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'category',
+                    'value': self.category_b.pk,
+                },
+                {
+                    'op': 'add',
+                    'path': 'top-category',
+                    'value': self.category_b.pk,
+                },
+                {
+                    'op': 'replace',
+                    'path': 'flatten-categories',
+                    'value': None,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         self.override_other_acl({})
@@ -320,22 +396,28 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
 
     def test_move_thread_with_top(self):
         """api moves thread to other category, sets top"""
-        self.override_acl({
-            'can_move_threads': True
-        })
-        self.override_other_acl({
-            'can_start_threads': 2
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'category', 'value': self.category_b.pk},
-            {
-                'op': 'add',
-                'path': 'top-category',
-                'value': Category.objects.root_category().pk,
-            },
-            {'op': 'replace', 'path': 'flatten-categories', 'value': None},
-        ])
+        self.override_acl({'can_move_threads': True})
+        self.override_other_acl({'can_start_threads': 2})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'category',
+                    'value': self.category_b.pk,
+                },
+                {
+                    'op': 'add',
+                    'path': 'top-category',
+                    'value': Category.objects.root_category().pk,
+                },
+                {
+                    'op': 'replace',
+                    'path': 'flatten-categories',
+                    'value': None,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         self.override_other_acl({})
@@ -349,19 +431,24 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
 
     def test_move_thread_no_permission(self):
         """api move thread to other category with no permission fails"""
-        self.override_acl({
-            'can_move_threads': False
-        })
+        self.override_acl({'can_move_threads': False})
         self.override_other_acl({})
 
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'category', 'value': self.category_b.pk}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'category',
+                    'value': self.category_b.pk,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
-        self.assertEqual(response_json['detail'][0],
-            "You don't have permission to move this thread.")
+        self.assertEqual(
+            response_json['detail'][0], "You don't have permission to move this thread."
+        )
 
         self.override_other_acl({})
 
@@ -370,16 +457,18 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
 
     def test_move_thread_no_category_access(self):
         """api move thread to category with no access fails"""
-        self.override_acl({
-            'can_move_threads': True
-        })
-        self.override_other_acl({
-            'can_see': False
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'category', 'value': self.category_b.pk}
-        ])
+        self.override_acl({'can_move_threads': True})
+        self.override_other_acl({'can_see': False})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'category',
+                    'value': self.category_b.pk,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -392,21 +481,25 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
 
     def test_move_thread_no_category_browse(self):
         """api move thread to category with no browsing access fails"""
-        self.override_acl({
-            'can_move_threads': True
-        })
-        self.override_other_acl({
-            'can_browse': False
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'category', 'value': self.category_b.pk}
-        ])
+        self.override_acl({'can_move_threads': True})
+        self.override_other_acl({'can_browse': False})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'category',
+                    'value': self.category_b.pk,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
-        self.assertEqual(response_json['detail'][0],
-            'You don\'t have permission to browse "Category B" contents.')
+        self.assertEqual(
+            response_json['detail'][0],
+            'You don\'t have permission to browse "Category B" contents.'
+        )
 
         self.override_other_acl({})
 
@@ -415,21 +508,24 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
 
     def test_move_thread_same_category(self):
         """api move thread to category it's already in fails"""
-        self.override_acl({
-            'can_move_threads': True
-        })
-        self.override_other_acl({
-            'can_start_threads': 2
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'category', 'value': self.thread.category_id}
-        ])
+        self.override_acl({'can_move_threads': True})
+        self.override_other_acl({'can_start_threads': 2})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'category',
+                    'value': self.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.")
+        self.assertEqual(
+            response_json['detail'][0], "You can't move thread to the category it's already in."
+        )
 
         self.override_other_acl({})
 
@@ -438,9 +534,15 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
 
     def test_thread_flatten_categories(self):
         """api flatten thread categories"""
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'flatten-categories', 'value': None}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'flatten-categories',
+                    'value': None,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
@@ -453,14 +555,20 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
 
         self.override_other_acl({})
 
-        response = self.patch(self.api_link, [
-            {
-                'op': 'add',
-                'path': 'top-category',
-                'value': Category.objects.root_category().pk,
-            },
-            {'op': 'replace', 'path': 'flatten-categories', 'value': None},
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'add',
+                    'path': 'top-category',
+                    'value': Category.objects.root_category().pk,
+                },
+                {
+                    'op': 'replace',
+                    'path': 'flatten-categories',
+                    'value': None,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
@@ -471,13 +579,17 @@ class ThreadMoveApiTests(ThreadPatchApiTestCase):
 class ThreadCloseApiTests(ThreadPatchApiTestCase):
     def test_close_thread(self):
         """api makes it possible to close thread"""
-        self.override_acl({
-            'can_close_threads': True
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-closed', 'value': True}
-        ])
+        self.override_acl({'can_close_threads': True})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-closed',
+                    'value': True,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         thread_json = self.get_thread_json()
@@ -491,13 +603,17 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_closed'])
 
-        self.override_acl({
-            'can_close_threads': True
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-closed', 'value': False}
-        ])
+        self.override_acl({'can_close_threads': True})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-closed',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         thread_json = self.get_thread_json()
@@ -505,18 +621,23 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
 
     def test_close_thread_no_permission(self):
         """api close thread with no permission fails"""
-        self.override_acl({
-            'can_close_threads': False
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-closed', 'value': True}
-        ])
+        self.override_acl({'can_close_threads': False})
+
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            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'])
@@ -529,18 +650,23 @@ class ThreadCloseApiTests(ThreadPatchApiTestCase):
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_closed'])
 
-        self.override_acl({
-            'can_close_threads': False
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-closed', 'value': False}
-        ])
+        self.override_acl({'can_close_threads': False})
+
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            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'])
@@ -552,13 +678,17 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
         self.thread.is_unapproved = True
         self.thread.save()
 
-        self.override_acl({
-            'can_approve_content': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-unapproved', 'value': False}
-        ])
+        self.override_acl({'can_approve_content': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-unapproved',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         thread_json = self.get_thread_json()
@@ -566,35 +696,40 @@ class ThreadApproveApiTests(ThreadPatchApiTestCase):
 
     def test_unapprove_thread(self):
         """api returns permission error on approval removal"""
-        self.override_acl({
-            'can_approve_content': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-unapproved', 'value': True}
-        ])
+        self.override_acl({'can_approve_content': 1})
+
+        response = self.patch(
+            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):
     def test_hide_thread(self):
         """api makes it possible to hide thread"""
-        self.override_acl({
-            'can_hide_threads': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'can_hide_threads': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': True,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
-        self.override_acl({
-            'can_hide_threads': 1
-        })
+        self.override_acl({'can_hide_threads': 1})
 
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_hidden'])
@@ -604,43 +739,48 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
         self.thread.is_hidden = True
         self.thread.save()
 
-        self.override_acl({
-            'can_hide_threads': 1
-        })
+        self.override_acl({'can_hide_threads': 1})
 
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_hidden'])
 
-        self.override_acl({
-            'can_hide_threads': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        self.override_acl({'can_hide_threads': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
-        self.override_acl({
-            'can_hide_threads': 1
-        })
+        self.override_acl({'can_hide_threads': 1})
 
         thread_json = self.get_thread_json()
         self.assertFalse(thread_json['is_hidden'])
 
     def test_hide_thread_no_permission(self):
         """api hide thread with no permission fails"""
-        self.override_acl({
-            'can_hide_threads': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'can_hide_threads': 0})
+
+        response = self.patch(
+            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 don't have permission to hide this thread.")
+        self.assertEqual(
+            response_json['detail'][0], "You don't have permission to hide this thread."
+        )
 
         thread_json = self.get_thread_json()
         self.assertFalse(thread_json['is_hidden'])
@@ -650,29 +790,37 @@ class ThreadHideApiTests(ThreadPatchApiTestCase):
         self.thread.is_hidden = True
         self.thread.save()
 
-        self.override_acl({
-            'can_hide_threads': 1
-        })
+        self.override_acl({'can_hide_threads': 1})
 
         thread_json = self.get_thread_json()
         self.assertTrue(thread_json['is_hidden'])
 
-        self.override_acl({
-            'can_hide_threads': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        self.override_acl({'can_hide_threads': 0})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 404)
 
 
 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'}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'subscription',
+                    'value': 'notify',
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
 
@@ -684,9 +832,15 @@ 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'}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'subscription',
+                    'value': 'email',
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
 
@@ -698,9 +852,15 @@ 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'}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'subscription',
+                    'value': 'remove',
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 200)
 
@@ -713,19 +873,32 @@ class ThreadSubscribeApiTests(ThreadPatchApiTestCase):
         """api makes it impossible to subscribe thread"""
         self.logout_user()
 
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'subscription', 'value': 'email'}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'subscription',
+                    'value': 'email',
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 403)
 
     def test_subscribe_nonexistant_thread(self):
         """api makes it impossible to subscribe nonexistant thread"""
         bad_api_link = self.api_link.replace(
-            six.text_type(self.thread.pk), six.text_type(self.thread.pk + 9))
-
-        response = self.patch(bad_api_link, [
-            {'op': 'replace', 'path': 'subscription', 'value': 'email'}
-        ])
+            six.text_type(self.thread.pk), six.text_type(self.thread.pk + 9)
+        )
+
+        response = self.patch(
+            bad_api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'subscription',
+                    'value': 'email',
+                },
+            ]
+        )
 
         self.assertEqual(response.status_code, 404)

+ 13 - 8
misago/threads/tests/test_thread_poll_api.py

@@ -16,9 +16,11 @@ class ThreadPollApiTestCase(AuthenticatedUserTestCase):
         self.thread = testutils.post_thread(self.category, poster=self.user)
         self.override_acl()
 
-        self.api_link = reverse('misago:api:thread-poll-list', kwargs={
-            'thread_pk': self.thread.pk
-        })
+        self.api_link = reverse(
+            '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')
@@ -39,7 +41,7 @@ class ThreadPollApiTestCase(AuthenticatedUserTestCase):
             'can_edit_polls': 1,
             'can_delete_polls': 1,
             'poll_edit_time': 0,
-            'can_always_see_poll_voters': 0
+            'can_always_see_poll_voters': 0,
         })
 
         if user:
@@ -52,7 +54,10 @@ class ThreadPollApiTestCase(AuthenticatedUserTestCase):
     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
-        })
+        self.api_link = reverse(
+            'misago:api:thread-poll-detail',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.poll.pk,
+            }
+        )

+ 134 - 131
misago/threads/tests/test_thread_pollcreate_api.py

@@ -16,36 +16,36 @@ 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'
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-list', kwargs={
+                'thread_pk': 'kjha6dsa687sa',
+            }
+        )
 
         response = self.post(api_link)
         self.assertEqual(response.status_code, 404)
 
     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
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-list', kwargs={
+                'thread_pk': self.thread.pk + 1,
+            }
+        )
 
         response = self.post(api_link)
         self.assertEqual(response.status_code, 404)
 
     def test_no_permission(self):
         """api validates that user has permission to start poll in thread"""
-        self.override_acl({
-            'can_start_polls': 0
-        })
+        self.override_acl({'can_start_polls': 0})
 
         response = self.post(self.api_link)
         self.assertContains(response, "can't start polls", status_code=403)
 
     def test_no_permission_closed_thread(self):
         """api validates that user has permission to start poll in closed thread"""
-        self.override_acl(category={
-            'can_close_threads': 0
-        })
+        self.override_acl(category={'can_close_threads': 0})
 
         self.thread.is_closed = True
         self.thread.save()
@@ -53,18 +53,14 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         response = self.post(self.api_link)
         self.assertContains(response, "thread is closed", status_code=403)
 
-        self.override_acl(category={
-            'can_close_threads': 1
-        })
+        self.override_acl(category={'can_close_threads': 1})
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
 
     def test_no_permission_closed_category(self):
         """api validates that user has permission to start poll in closed category"""
-        self.override_acl(category={
-            'can_close_threads': 0
-        })
+        self.override_acl(category={'can_close_threads': 0})
 
         self.category.is_closed = True
         self.category.save()
@@ -72,18 +68,14 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         response = self.post(self.api_link)
         self.assertContains(response, "category is closed", status_code=403)
 
-        self.override_acl(category={
-            'can_close_threads': 1
-        })
+        self.override_acl(category={'can_close_threads': 1})
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
 
     def test_no_permission_other_user_thread(self):
         """api validates that user has permission to start poll in other user's thread"""
-        self.override_acl({
-            'can_start_polls': 1
-        })
+        self.override_acl({'can_start_polls': 1})
 
         self.thread.starter = None
         self.thread.save()
@@ -91,9 +83,7 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
         response = self.post(self.api_link)
         self.assertContains(response, "can't start polls in other users threads", status_code=403)
 
-        self.override_acl({
-            'can_start_polls': 2
-        })
+        self.override_acl({'can_start_polls': 2})
 
         response = self.post(self.api_link)
         self.assertEqual(response.status_code, 400)
@@ -108,8 +98,12 @@ class ThreadPollCreateTests(ThreadPollApiTestCase):
             poster_ip='127.0.0.1',
             length=30,
             question='Test',
-            choices=[{'hash': 't3st'}],
-            allowed_choices=1
+            choices=[
+                {
+                    'hash': 't3st'
+                },
+            ],
+            allowed_choices=1,
         )
 
         response = self.post(self.api_link)
@@ -125,156 +119,165 @@ 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."
-        ])
+        self.assertEqual(
+            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."
-        ])
+        self.assertEqual(
+            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."
-        ])
+        self.assertEqual(
+            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': ''
-                }
-            ]
-        })
+        response = self.post(
+            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."
-        ])
-
-        response = self.post(self.api_link, data={
-            'choices': [
-                {
-                    'hash': 'qwertyuiopas',
-                    'label': 'abcd' * 255
-                }
-            ]
-        })
+        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,
+                    },
+                ],
+            }
+        )
         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."
-        ])
+        self.assertEqual(
+            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)
-        })
+        response = self.post(
+            self.api_link, data={
+                'choices': [
+                    {
+                        'label': 'Choice',
+                    },
+                ] * (MAX_POLL_OPTIONS + 1),
+            }
+        )
         self.assertEqual(response.status_code, 400)
 
         error_formats = (MAX_POLL_OPTIONS, MAX_POLL_OPTIONS + 1)
 
         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
-        ])
+        self.assertEqual(
+            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 = self.post(self.api_link, data={
-            'length': 0,
-            'question': "Lorem ipsum",
-            'allowed_choices': 3,
-            'choices': [
-                {
-                    'label': 'Choice'
-                },
-                {
-                    'label': 'Choice'
-                }
-            ]
-        })
+        self.assertEqual(
+            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',
+                    },
+                ],
+            }
+        )
         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."
-        ])
+        self.assertEqual(
+            response_json['non_field_errors'],
+            ["Number of allowed choices can't be greater than number of all choices."]
+        )
 
     def test_poll_created(self):
         """api creates public poll if provided with valid data"""
-        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'
-                }
-            ]
-        })
+        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',
+                    },
+                ],
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()

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

@@ -23,71 +23,78 @@ 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
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-detail',
+            kwargs={
+                'thread_pk': 'kjha6dsa687sa',
+                'pk': self.poll.pk,
+            }
+        )
 
         response = self.client.delete(api_link)
         self.assertEqual(response.status_code, 404)
 
     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
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-detail',
+            kwargs={
+                'thread_pk': self.thread.pk + 1,
+                'pk': self.poll.pk,
+            }
+        )
 
         response = self.client.delete(api_link)
         self.assertEqual(response.status_code, 404)
 
     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'
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-detail',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': 'sad98as7d97sa98',
+            }
+        )
 
         response = self.client.delete(api_link)
         self.assertEqual(response.status_code, 404)
 
     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
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-detail',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.poll.pk + 123,
+            }
+        )
 
         response = self.client.delete(api_link)
         self.assertEqual(response.status_code, 404)
 
     def test_no_permission(self):
         """api validates that user has permission to delete poll in thread"""
-        self.override_acl({
-            'can_delete_polls': 0
-        })
+        self.override_acl({'can_delete_polls': 0})
 
         response = self.client.delete(self.api_link)
         self.assertContains(response, "can't delete polls", status_code=403)
 
     def test_no_permission_timeout(self):
         """api validates that user's window to delete poll in thread has closed"""
-        self.override_acl({
-            'can_delete_polls': 1,
-            'poll_edit_time': 5
-        })
+        self.override_acl({'can_delete_polls': 1, 'poll_edit_time': 5})
 
         self.poll.posted_on = timezone.now() - timedelta(minutes=15)
         self.poll.save()
 
         response = self.client.delete(self.api_link)
-        self.assertContains(response, "can't delete polls that are older than 5 minutes", status_code=403)
+        self.assertContains(
+            response, "can't delete polls that are older than 5 minutes", status_code=403
+        )
 
     def test_no_permission_poll_closed(self):
         """api validates that user's window to delete poll in thread has closed"""
-        self.override_acl({
-            'can_delete_polls': 1
-        })
+        self.override_acl({'can_delete_polls': 1})
 
         self.poll.posted_on = timezone.now() - timedelta(days=15)
         self.poll.length = 5
@@ -98,9 +105,7 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
 
     def test_no_permission_other_user_poll(self):
         """api validates that user has permission to delete other user poll in thread"""
-        self.override_acl({
-            'can_delete_polls': 1
-        })
+        self.override_acl({'can_delete_polls': 1})
 
         self.poll.poster = None
         self.poll.save()
@@ -110,9 +115,7 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
 
     def test_no_permission_closed_thread(self):
         """api validates that user has permission to delete poll in closed thread"""
-        self.override_acl(category={
-            'can_close_threads': 0
-        })
+        self.override_acl(category={'can_close_threads': 0})
 
         self.thread.is_closed = True
         self.thread.save()
@@ -120,18 +123,14 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         response = self.client.delete(self.api_link)
         self.assertContains(response, "thread is closed", status_code=403)
 
-        self.override_acl(category={
-            'can_close_threads': 1
-        })
+        self.override_acl(category={'can_close_threads': 1})
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
 
     def test_no_permission_closed_category(self):
         """api validates that user has permission to delete poll in closed category"""
-        self.override_acl(category={
-            'can_close_threads': 0
-        })
+        self.override_acl(category={'can_close_threads': 0})
 
         self.category.is_closed = True
         self.category.save()
@@ -139,9 +138,7 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
         response = self.client.delete(self.api_link)
         self.assertContains(response, "category is closed", status_code=403)
 
-        self.override_acl(category={
-            'can_close_threads': 1
-        })
+        self.override_acl(category={'can_close_threads': 1})
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
@@ -162,10 +159,7 @@ class ThreadPollDeleteTests(ThreadPollApiTestCase):
 
     def test_other_user_poll_delete(self):
         """api deletes other user's poll and associated votes, even if its over"""
-        self.override_acl({
-            'can_delete_polls': 2,
-            'poll_edit_time': 5
-        })
+        self.override_acl({'can_delete_polls': 2, 'poll_edit_time': 5})
 
         self.poll.poster = None
         self.poll.posted_on = timezone.now() - timedelta(days=15)

+ 282 - 264
misago/threads/tests/test_thread_polledit_api.py

@@ -23,71 +23,78 @@ 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
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-detail',
+            kwargs={
+                'thread_pk': 'kjha6dsa687sa',
+                'pk': self.poll.pk,
+            }
+        )
 
         response = self.put(api_link)
         self.assertEqual(response.status_code, 404)
 
     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
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-detail',
+            kwargs={
+                'thread_pk': self.thread.pk + 1,
+                'pk': self.poll.pk,
+            }
+        )
 
         response = self.put(api_link)
         self.assertEqual(response.status_code, 404)
 
     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'
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-detail',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': 'sad98as7d97sa98',
+            }
+        )
 
         response = self.put(api_link)
         self.assertEqual(response.status_code, 404)
 
     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
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-detail',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.poll.pk + 123,
+            }
+        )
 
         response = self.put(api_link)
         self.assertEqual(response.status_code, 404)
 
     def test_no_permission(self):
         """api validates that user has permission to edit poll in thread"""
-        self.override_acl({
-            'can_edit_polls': 0
-        })
+        self.override_acl({'can_edit_polls': 0})
 
         response = self.put(self.api_link)
         self.assertContains(response, "can't edit polls", status_code=403)
 
     def test_no_permission_timeout(self):
         """api validates that user's window to edit poll in thread has closed"""
-        self.override_acl({
-            'can_edit_polls': 1,
-            'poll_edit_time': 5
-        })
+        self.override_acl({'can_edit_polls': 1, 'poll_edit_time': 5})
 
         self.poll.posted_on = timezone.now() - timedelta(minutes=15)
         self.poll.save()
 
         response = self.put(self.api_link)
-        self.assertContains(response, "can't edit polls that are older than 5 minutes", status_code=403)
+        self.assertContains(
+            response, "can't edit polls that are older than 5 minutes", status_code=403
+        )
 
     def test_no_permission_poll_closed(self):
         """api validates that user's window to edit poll in thread has closed"""
-        self.override_acl({
-            'can_edit_polls': 1
-        })
+        self.override_acl({'can_edit_polls': 1})
 
         self.poll.posted_on = timezone.now() - timedelta(days=15)
         self.poll.length = 5
@@ -98,9 +105,7 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
     def test_no_permission_other_user_poll(self):
         """api validates that user has permission to edit other user poll in thread"""
-        self.override_acl({
-            'can_edit_polls': 1
-        })
+        self.override_acl({'can_edit_polls': 1})
 
         self.poll.poster = None
         self.poll.save()
@@ -110,9 +115,7 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
     def test_no_permission_closed_thread(self):
         """api validates that user has permission to edit poll in closed thread"""
-        self.override_acl(category={
-            'can_close_threads': 0
-        })
+        self.override_acl(category={'can_close_threads': 0})
 
         self.thread.is_closed = True
         self.thread.save()
@@ -120,18 +123,14 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         response = self.put(self.api_link)
         self.assertContains(response, "thread is closed", status_code=403)
 
-        self.override_acl(category={
-            'can_close_threads': 1
-        })
+        self.override_acl(category={'can_close_threads': 1})
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
 
     def test_no_permission_closed_category(self):
         """api validates that user has permission to edit poll in closed category"""
-        self.override_acl(category={
-            'can_close_threads': 0
-        })
+        self.override_acl(category={'can_close_threads': 0})
 
         self.category.is_closed = True
         self.category.save()
@@ -139,9 +138,7 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
         response = self.put(self.api_link)
         self.assertContains(response, "category is closed", status_code=403)
 
-        self.override_acl(category={
-            'can_close_threads': 1
-        })
+        self.override_acl(category={'can_close_threads': 1})
 
         response = self.put(self.api_link)
         self.assertEqual(response.status_code, 400)
@@ -156,156 +153,165 @@ 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."
-        ])
+        self.assertEqual(
+            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."
-        ])
+        self.assertEqual(
+            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."
-        ])
+        self.assertEqual(
+            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': ''
-                }
-            ]
-        })
+        response = self.put(
+            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."
-        ])
-
-        response = self.put(self.api_link, data={
-            'choices': [
-                {
-                    'hash': 'qwertyuiopas',
-                    'label': 'abcd' * 255
-                }
-            ]
-        })
+        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,
+                    },
+                ],
+            }
+        )
         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."
-        ])
+        self.assertEqual(
+            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)
-        })
+        response = self.put(
+            self.api_link, data={
+                'choices': [
+                    {
+                        'label': 'Choice',
+                    },
+                ] * (MAX_POLL_OPTIONS + 1),
+            }
+        )
         self.assertEqual(response.status_code, 400)
 
         error_formats = (MAX_POLL_OPTIONS, MAX_POLL_OPTIONS + 1)
 
         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
-        ])
+        self.assertEqual(
+            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 = self.put(self.api_link, data={
-            'length': 0,
-            'question': "Lorem ipsum",
-            'allowed_choices': 3,
-            'choices': [
-                {
-                    'label': 'Choice'
-                },
-                {
-                    'label': 'Choice'
-                }
-            ]
-        })
+        self.assertEqual(
+            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',
+                    },
+                ],
+            }
+        )
         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."
-        ])
+        self.assertEqual(
+            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):
         """api edits all poll choices out"""
-        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'
-                }
-            ]
-        })
+        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',
+                    },
+                ],
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
@@ -332,35 +338,38 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
     def test_poll_current_choices_edited(self):
         """api edits current poll choices"""
-        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
-                }
-            ]
-        })
+        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,
+                    },
+                ],
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
@@ -376,63 +385,69 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
         # choices were updated
         self.assertEqual(len(response_json['choices']), 4)
-        self.assertEqual(response_json['choices'], [
-            {
-                'hash': 'aaaaaaaaaaaa',
-                'label': 'First',
-                'votes': 1,
-                'selected': False
-            },
-            {
-                'hash': 'bbbbbbbbbbbb',
-                'label': 'Second',
-                'votes': 0,
-                'selected': False
-            },
-            {
-                'hash': 'gggggggggggg',
-                'label': 'Third',
-                'votes': 2,
-                'selected': True
-            },
-            {
-                'hash': 'dddddddddddd',
-                'label': 'Fourth',
-                'votes': 1,
-                'selected': True
-            }
-        ])
-
-        # no votes were removed
-        self.assertEqual(response_json['votes'], 4)
-        self.assertEqual(self.poll.pollvote_set.count(), 4)
-
-    def test_poll_some_choices_edited(self):
-        """api edits some poll choices"""
-        response = self.put(self.api_link, data={
-            'length': 40,
-            'question': "Select two best colors",
-            'allowed_choices': 2,
-            'allow_revotes': True,
-            'is_public': True,
-            'choices': [
+        self.assertEqual(
+            response_json['choices'],
+            [
                 {
                     'hash': 'aaaaaaaaaaaa',
-                    'label': '\nFirst ',
-                    'votes': 5555
+                    'label': 'First',
+                    'votes': 1,
+                    'selected': False,
                 },
                 {
                     'hash': 'bbbbbbbbbbbb',
                     'label': 'Second',
-                    'votes': 5555
+                    'votes': 0,
+                    'selected': False,
                 },
                 {
-                    'hash': 'dsadsadsa788',
-                    'label': 'New Option',
-                    'votes': 5555
-                }
-            ]
-        })
+                    'hash': 'gggggggggggg',
+                    'label': 'Third',
+                    'votes': 2,
+                    'selected': True,
+                },
+                {
+                    'hash': 'dddddddddddd',
+                    'label': 'Fourth',
+                    'votes': 1,
+                    'selected': True,
+                },
+            ],
+        )
+
+        # no votes were removed
+        self.assertEqual(response_json['votes'], 4)
+        self.assertEqual(self.poll.pollvote_set.count(), 4)
+
+    def test_poll_some_choices_edited(self):
+        """api edits some poll choices"""
+        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,
+                    },
+                ],
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
@@ -448,26 +463,29 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
         # choices were updated
         self.assertEqual(len(response_json['choices']), 3)
-        self.assertEqual(response_json['choices'], [
-            {
-                'hash': 'aaaaaaaaaaaa',
-                'label': 'First',
-                'votes': 1,
-                'selected': False
-            },
-            {
-                'hash': 'bbbbbbbbbbbb',
-                'label': 'Second',
-                'votes': 0,
-                'selected': False
-            },
-            {
-                'hash': response_json['choices'][2]['hash'],
-                'label': 'New Option',
-                'votes': 0,
-                'selected': False
-            }
-        ])
+        self.assertEqual(
+            response_json['choices'],
+            [
+                {
+                    'hash': 'aaaaaaaaaaaa',
+                    'label': 'First',
+                    'votes': 1,
+                    'selected': False,
+                },
+                {
+                    'hash': 'bbbbbbbbbbbb',
+                    'label': 'Second',
+                    '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)
@@ -475,34 +493,34 @@ class ThreadPollEditTests(ThreadPollApiTestCase):
 
     def test_moderate_user_poll(self):
         """api edits all poll choices out in other users poll, even if its over"""
-        self.override_acl({
-            'can_edit_polls': 2,
-            'poll_edit_time': 5
-        })
+        self.override_acl({'can_edit_polls': 2, 'poll_edit_time': 5})
 
         self.poll.poster = None
         self.poll.posted_on = timezone.now() - timedelta(days=15)
         self.poll.length = 5
         self.poll.save()
 
-        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'
-                }
-            ]
-        })
+        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',
+                    },
+                ],
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()

+ 63 - 51
misago/threads/tests/test_thread_pollvotes_api.py

@@ -21,10 +21,13 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
         self.poll.is_public = True
         self.poll.save()
 
-        self.api_link = reverse('misago:api:thread-poll-votes', kwargs={
-            'thread_pk': self.thread.pk,
-            'pk': self.poll.pk
-        })
+        self.api_link = reverse(
+            'misago:api:thread-poll-votes',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.poll.pk,
+            }
+        )
 
     def test_anonymous(self):
         """api allows guests to get poll votes"""
@@ -35,49 +38,59 @@ 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
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-votes',
+            kwargs={
+                'thread_pk': 'kjha6dsa687sa',
+                'pk': self.poll.pk,
+            }
+        )
 
         response = self.client.get(api_link)
         self.assertEqual(response.status_code, 404)
 
     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
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-votes',
+            kwargs={
+                'thread_pk': self.thread.pk + 1,
+                'pk': self.poll.pk,
+            }
+        )
 
         response = self.client.get(api_link)
         self.assertEqual(response.status_code, 404)
 
     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'
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-votes',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': 'sad98as7d97sa98',
+            }
+        )
 
         response = self.client.get(api_link)
         self.assertEqual(response.status_code, 404)
 
     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
-        })
+        api_link = reverse(
+            'misago:api:thread-poll-votes',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.poll.pk + 123,
+            }
+        )
 
         response = self.client.get(api_link)
         self.assertEqual(response.status_code, 404)
 
     def test_no_permission(self):
         """api chcecks permission to see poll voters"""
-        self.override_acl({
-            'can_always_see_poll_voters': False
-        })
+        self.override_acl({'can_always_see_poll_voters': False})
 
         self.poll.is_public = False
         self.poll.save()
@@ -107,18 +120,17 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
         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')
 
-        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())
 
     def test_get_votes_private_poll(self):
         """api returns list of voters on private poll for user with permission"""
-        self.override_acl({
-            'can_always_see_poll_voters': True
-        })
+        self.override_acl({'can_always_see_poll_voters': True})
 
         self.poll.is_public = False
         self.poll.save()
@@ -133,12 +145,13 @@ class ThreadGetVotesTests(ThreadPollApiTestCase):
         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')
 
-        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):
@@ -147,10 +160,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
-        })
+        self.api_link = reverse(
+            '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
@@ -195,7 +211,9 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         self.poll.save()
 
         response = self.post(self.api_link, data=['aaaaaaaaaaaa', 'bbbbbbbbbbbb'])
-        self.assertContains(response, "This poll disallows voting for more than 1 choice.", status_code=400)
+        self.assertContains(
+            response, "This poll disallows voting for more than 1 choice.", status_code=400
+        )
 
     def test_revote(self):
         """api validates if user is trying to change vote in poll that disallows revoting"""
@@ -209,9 +227,7 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
 
     def test_vote_in_closed_thread(self):
         """api validates is user has permission to vote poll in closed thread"""
-        self.override_acl(category={
-            'can_close_threads': 0
-        })
+        self.override_acl(category={'can_close_threads': 0})
 
         self.thread.is_closed = True
         self.thread.save()
@@ -221,18 +237,14 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         response = self.post(self.api_link)
         self.assertContains(response, "thread is closed", status_code=403)
 
-        self.override_acl(category={
-            'can_close_threads': 1
-        })
+        self.override_acl(category={'can_close_threads': 1})
 
         response = self.post(self.api_link)
         self.assertContains(response, "You have to make a choice.", status_code=400)
 
     def test_vote_in_closed_category(self):
         """api validates is user has permission to vote poll in closed category"""
-        self.override_acl(category={
-            'can_close_threads': 0
-        })
+        self.override_acl(category={'can_close_threads': 0})
 
         self.category.is_closed = True
         self.category.save()
@@ -242,9 +254,7 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         response = self.post(self.api_link)
         self.assertContains(response, "category is closed", status_code=403)
 
-        self.override_acl(category={
-            'can_close_threads': 1
-        })
+        self.override_acl(category={'can_close_threads': 1})
 
         response = self.post(self.api_link)
         self.assertContains(response, "You have to make a choice.", status_code=400)
@@ -287,7 +297,8 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         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([c['selected'] for c in response_json['choices']],
+                         [True, True, False, False])
 
         self.assertFalse(response_json['acl']['can_vote'])
 
@@ -313,6 +324,7 @@ class ThreadPostVotesTests(ThreadPollApiTestCase):
         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([c['selected'] for c in response_json['choices']],
+                         [True, True, False, False])
 
         self.assertTrue(response_json['acl']['can_vote'])

+ 39 - 37
misago/threads/tests/test_thread_postdelete_api.py

@@ -15,10 +15,13 @@ 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
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-detail',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.post.pk,
+            }
+        )
 
     def test_delete_anonymous(self):
         """api validates if deleting user is authenticated"""
@@ -29,10 +32,7 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
     def test_no_permission(self):
         """api validates permission to delete post"""
-        self.override_acl({
-            'can_hide_own_posts': 1,
-            'can_hide_posts': 1
-        })
+        self.override_acl({'can_hide_own_posts': 1, 'can_hide_posts': 1})
 
         response = self.client.delete(self.api_link)
         self.assertContains(response, "You can't delete posts in this category.", status_code=403)
@@ -42,7 +42,7 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.override_acl({
             'post_edit_time': 0,
             'can_hide_own_posts': 2,
-            'can_hide_posts': 0
+            'can_hide_posts': 0,
         })
 
         self.post.poster = None
@@ -50,7 +50,8 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertContains(
-            response, "You can't delete other users posts in this category", status_code=403)
+            response, "You can't delete other users posts in this category", status_code=403
+        )
 
     def test_delete_protected_post_no_permission(self):
         """api validates if user can delete protected post"""
@@ -65,7 +66,8 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertContains(
-            response, "This post is protected. You can't delete it.", status_code=403)
+            response, "This post is protected. You can't delete it.", status_code=403
+        )
 
     def test_delete_protected_post_after_edit_time(self):
         """api validates if user can delete delete post after edit time"""
@@ -80,7 +82,8 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertContains(
-            response, "You can't delete posts that are older than 1 minute.", status_code=403)
+            response, "You can't delete posts that are older than 1 minute.", status_code=403
+        )
 
     def test_delete_post_closed_thread_no_permission(self):
         """api valdiates if user can delete posts in closed threads"""
@@ -95,7 +98,8 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertContains(
-            response, "This thread is closed. You can't delete posts in it.", status_code=403)
+            response, "This thread is closed. You can't delete posts in it.", status_code=403
+        )
 
     def test_delete_post_closed_category_no_permission(self):
         """api valdiates if user can delete posts in closed categories"""
@@ -110,19 +114,20 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
         response = self.client.delete(self.api_link)
         self.assertContains(
-            response, "This category is closed. You can't delete posts in it.", status_code=403)
+            response, "This category is closed. You can't delete posts in it.", status_code=403
+        )
 
     def test_delete_first_post(self):
         """api disallows first post's deletion"""
-        self.override_acl({
-            'can_hide_own_posts': 2,
-            'can_hide_posts': 2
-        })
+        self.override_acl({'can_hide_own_posts': 2, 'can_hide_posts': 2})
 
-        api_link = reverse('misago:api:thread-post-detail', kwargs={
-            'thread_pk': self.thread.pk,
-            'pk': self.thread.first_post_id
-        })
+        api_link = reverse(
+            'misago:api:thread-post-detail',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.thread.first_post_id,
+            }
+        )
 
         response = self.client.delete(api_link)
         self.assertContains(response, "You can't delete thread's first post.", status_code=403)
@@ -132,8 +137,7 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.override_acl({
             'can_hide_own_posts': 2,
             'can_hide_posts': 2,
-
-            'can_hide_events': 0
+            'can_hide_events': 0,
         })
 
         self.post.is_event = True
@@ -147,7 +151,7 @@ class PostDeleteApiTests(ThreadsApiTestCase):
         self.override_acl({
             'post_edit_time': 0,
             'can_hide_own_posts': 2,
-            'can_hide_posts': 0
+            'can_hide_posts': 0,
         })
 
         response = self.client.delete(self.api_link)
@@ -161,10 +165,7 @@ class PostDeleteApiTests(ThreadsApiTestCase):
 
     def test_delete_post(self):
         """api deletes thread post"""
-        self.override_acl({
-            'can_hide_own_posts': 0,
-            'can_hide_posts': 2
-        })
+        self.override_acl({'can_hide_own_posts': 0, 'can_hide_posts': 2})
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)
@@ -182,10 +183,13 @@ class EventDeleteApiTests(ThreadsApiTestCase):
 
         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
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-detail',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.event.pk,
+            }
+        )
 
     def test_delete_anonymous(self):
         """api validates if deleting user is authenticated"""
@@ -199,8 +203,7 @@ class EventDeleteApiTests(ThreadsApiTestCase):
         self.override_acl({
             'can_hide_own_posts': 2,
             'can_hide_posts': 2,
-
-            'can_hide_events': 0
+            'can_hide_events': 0,
         })
 
         response = self.client.delete(self.api_link)
@@ -211,8 +214,7 @@ class EventDeleteApiTests(ThreadsApiTestCase):
         self.override_acl({
             'can_hide_own_posts': 0,
             'can_hide_posts': 0,
-
-            'can_hide_events': 2
+            'can_hide_events': 2,
         })
 
         response = self.client.delete(self.api_link)

+ 13 - 20
misago/threads/tests/test_thread_postedits_api.py

@@ -1,15 +1,9 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-import json
-from datetime import timedelta
-
 from django.urls import reverse
 
-from misago.acl.testutils import override_acl
-from misago.categories.models import Category
 from misago.threads import testutils
-from misago.threads.models import Post
 
 from .test_threads_api import ThreadsApiTestCase
 
@@ -20,10 +14,13 @@ 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
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-edits',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.post.pk,
+            }
+        )
 
         self.override_acl()
 
@@ -37,7 +34,7 @@ class ThreadPostEditsApiTestCase(ThreadsApiTestCase):
                 editor_slug=self.user.slug,
                 editor_ip='127.0.0.1',
                 edited_from="Original body",
-                edited_to="First Edit"
+                edited_to="First Edit",
             ),
             self.post.edits_record.create(
                 category=self.category,
@@ -46,7 +43,7 @@ class ThreadPostEditsApiTestCase(ThreadsApiTestCase):
                 editor_slug='deleted',
                 editor_ip='127.0.0.1',
                 edited_from="First Edit",
-                edited_to="Second Edit"
+                edited_to="Second Edit",
             ),
             self.post.edits_record.create(
                 category=self.category,
@@ -56,8 +53,8 @@ class ThreadPostEditsApiTestCase(ThreadsApiTestCase):
                 editor_slug=self.user.slug,
                 editor_ip='127.0.0.1',
                 edited_from="Second Edit",
-                edited_to="Last Edit"
-            )
+                edited_to="Last Edit",
+            ),
         ]
 
         self.post.original = 'Last Edit'
@@ -138,9 +135,7 @@ class ThreadPostPostEditTests(ThreadPostEditsApiTestCase):
         super(ThreadPostPostEditTests, self).setUp()
         self.edits = self.mock_edit_record()
 
-        self.override_acl({
-            'can_edit_posts': 2
-        })
+        self.override_acl({'can_edit_posts': 2})
 
     def test_empty_edit_id(self):
         """api handles empty edit in querystring"""
@@ -166,9 +161,7 @@ class ThreadPostPostEditTests(ThreadPostEditsApiTestCase):
 
     def test_no_permission(self):
         """api validates permission to revert post"""
-        self.override_acl({
-            'can_edit_posts': 0
-        })
+        self.override_acl({'can_edit_posts': 0})
 
         response = self.client.post('{}?edit=1321'.format(self.api_link))
         self.assertEqual(response.status_code, 403)

+ 15 - 14
misago/threads/tests/test_thread_postlikes_api.py

@@ -12,25 +12,24 @@ 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
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-likes',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.post.pk,
+            }
+        )
 
     def test_no_permission(self):
         """api errors if user has no permission to see likes"""
-        self.override_acl({
-            'can_see_posts_likes': 0
-        })
+        self.override_acl({'can_see_posts_likes': 0})
 
         response = self.client.get(self.api_link)
         self.assertContains(response, "You can't see who liked this post.", status_code=403)
 
     def test_no_permission_to_list(self):
         """api errors if user has no permission to see likes, but can see likes count"""
-        self.override_acl({
-            'can_see_posts_likes': 1
-        })
+        self.override_acl({'can_see_posts_likes': 1})
 
         response = self.client.get(self.api_link)
         self.assertContains(response, "You can't see who liked this post.", status_code=403)
@@ -51,7 +50,9 @@ class ThreadPostLikesApiTestCase(ThreadsApiTestCase):
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(response.json(), [
-            PostLikeSerializer(other_like.__dict__).data,
-            PostLikeSerializer(like.__dict__).data,
-        ])
+        self.assertEqual(
+            response.json(), [
+                PostLikeSerializer(other_like.__dict__).data,
+                PostLikeSerializer(like.__dict__).data,
+            ]
+        )

+ 234 - 126
misago/threads/tests/test_thread_postmerge_api.py

@@ -2,12 +2,8 @@
 from __future__ import unicode_literals
 
 import json
-from datetime import timedelta
 
 from django.urls import reverse
-from django.utils import timezone
-from django.utils.encoding import smart_str
-from django.utils.six.moves import range
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
@@ -25,9 +21,11 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         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
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-merge', kwargs={
+                'thread_pk': self.thread.pk,
+            }
+        )
 
         self.override_acl()
 
@@ -43,7 +41,7 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
             'can_reply_threads': 0,
             'can_edit_posts': 1,
             'can_approve_content': 0,
-            'can_merge_posts': 1
+            'can_merge_posts': 1,
         })
 
         if extra_acl:
@@ -55,16 +53,22 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         """you need to authenticate to merge 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)
 
     def test_no_permission(self):
         """api validates permission to merge"""
-        self.override_acl({
-            'can_merge_posts': 0
-        })
+        self.override_acl({'can_merge_posts': 0})
 
-        response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({}),
+            content_type="application/json",
+        )
         self.assertContains(response, "You can't merge posts in this thread.", status_code=403)
 
     def test_closed_thread(self):
@@ -72,165 +76,263 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        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.assertContains(response, "You can't merge posts in this thread.", status_code=403)
 
         # allow closing threads
-        self.override_acl({
-            'can_close_threads': 1
-        })
+        self.override_acl({'can_close_threads': 1})
 
-        response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
-        self.assertContains(response, "You have to select at least two posts to merge.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({}),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "You have to select at least two posts to merge.", status_code=400
+        )
 
     def test_closed_category(self):
         """api validates permission to merge in closed category"""
         self.category.is_closed = True
         self.category.save()
 
-        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.assertContains(response, "You can't merge posts in this thread.", status_code=403)
 
         # allow closing threads
-        self.override_acl({
-            'can_close_threads': 1
-        })
+        self.override_acl({'can_close_threads': 1})
 
-        response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
-        self.assertContains(response, "You have to select at least two posts to merge.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({}),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "You have to select at least two posts to merge.", status_code=400
+        )
 
     def test_empty_data(self):
         """api handles empty data"""
-        response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
-        self.assertContains(response, "You have to select at least two posts to merge.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({}),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "You have to select at least two posts to merge.", status_code=400
+        )
 
     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.assertContains(response, "You have to select at least two posts to merge.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': []
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "You have to select at least two posts to merge.", status_code=400
+        )
 
     def test_invalid_posts_data(self):
         """api handles invalid data"""
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': 'string'
-        }), content_type="application/json")
-        self.assertContains(response, "One or more post ids received were invalid.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': 'string'
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "One or more post ids received were invalid.", status_code=400
+        )
 
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': [1, 2, 'string']
-        }), content_type="application/json")
-        self.assertContains(response, "One or more post ids received were invalid.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [1, 2, 'string']
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "One or more post ids received were invalid.", status_code=400
+        )
 
     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.assertContains(response, "You have to select at least two posts to merge.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [1]
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "You have to select at least two posts to merge.", status_code=400
+        )
 
     def test_merge_limit(self):
         """api rejects more posts than merge limit"""
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': list(range(MERGE_LIMIT + 1))
-        }), content_type="application/json")
-        self.assertContains(response, "No more than {} posts can be merged".format(MERGE_LIMIT), status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': list(range(MERGE_LIMIT + 1))
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "No more than {} posts can be merged".format(MERGE_LIMIT), status_code=400
+        )
 
     def test_merge_event(self):
         """api recjects events"""
         event = testutils.reply_thread(self.thread, is_event=True, poster=self.user)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': [self.post.pk, event.pk]
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [self.post.pk, event.pk]
+            }),
+            content_type="application/json",
+        )
         self.assertContains(response, "Events can't be merged.", status_code=400)
 
     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]
-        }), content_type="application/json")
-        self.assertContains(response, "One or more posts to merge could not be found.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [self.post.pk, self.post.pk * 1000]
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "One or more posts to merge could not be found.", status_code=400
+        )
 
     def test_merge_cross_threads(self):
         """api recjects attempt to merge with post made in other thread"""
         other_thread = testutils.post_thread(category=self.category)
         other_post = testutils.reply_thread(other_thread, poster=self.user)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': [self.post.pk, other_post.pk]
-        }), content_type="application/json")
-        self.assertContains(response, "One or more posts to merge could not be found.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [self.post.pk, other_post.pk]
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "One or more posts to merge could not be found.", status_code=400
+        )
 
     def test_merge_authenticated_with_guest_post(self):
         """api recjects attempt to merge with post made by deleted user"""
         other_post = testutils.reply_thread(self.thread)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': [self.post.pk, other_post.pk]
-        }), content_type="application/json")
-        self.assertContains(response, "Posts made by different users can't be merged.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [self.post.pk, other_post.pk]
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "Posts made by different users can't be merged.", status_code=400
+        )
 
     def test_merge_guest_with_authenticated_post(self):
         """api recjects attempt to merge with post made by deleted user"""
         other_post = testutils.reply_thread(self.thread)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': [other_post.pk, self.post.pk]
-        }), content_type="application/json")
-        self.assertContains(response, "Posts made by different users can't be merged.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [other_post.pk, self.post.pk]
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "Posts made by different users can't be merged.", status_code=400
+        )
 
     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
-            ]
-        }), content_type="application/json")
-        self.assertContains(response, "Posts made by different users can't be merged.", status_code=400)
+        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,
+                ]
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "Posts made by different users can't be merged.", status_code=400
+        )
 
     def test_merge_different_visibility(self):
         """api recjects attempt to merge posts with different visibility"""
-        self.override_acl({
-            'can_hide_posts': 1
-        })
-
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': [
-                testutils.reply_thread(self.thread, poster="Bob", is_hidden=True).pk,
-                testutils.reply_thread(self.thread, poster="Bob", is_hidden=False).pk
-            ]
-        }), content_type="application/json")
-        self.assertContains(response, "Posts with different visibility can't be merged.", status_code=400)
+        self.override_acl({'can_hide_posts': 1})
+
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [
+                    testutils.reply_thread(self.thread, poster="Bob", is_hidden=True).pk,
+                    testutils.reply_thread(self.thread, poster="Bob", is_hidden=False).pk,
+                ]
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "Posts with different visibility can't be merged.", status_code=400
+        )
 
     def test_merge_different_approval(self):
         """api recjects attempt to merge posts with different approval"""
-        self.override_acl({
-            'can_approve_content': 1
-        })
-
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': [
-                testutils.reply_thread(self.thread, poster="Bob", is_unapproved=True).pk,
-                testutils.reply_thread(self.thread, poster="Bob", is_unapproved=False).pk
-            ]
-        }), content_type="application/json")
-        self.assertContains(response, "Posts with different visibility can't be merged.", status_code=400)
+        self.override_acl({'can_approve_content': 1})
+
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [
+                    testutils.reply_thread(self.thread, poster="Bob", is_unapproved=True).pk,
+                    testutils.reply_thread(self.thread, poster="Bob", is_unapproved=False).pk,
+                ]
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "Posts with different visibility can't be merged.", status_code=400
+        )
 
     def test_merge_posts(self):
         """api merges two posts"""
-        post_a = testutils.reply_thread(self.thread, poster="Bob", message="Battęry")
-        post_b = testutils.reply_thread(self.thread, poster="Bob", message="Hórse")
+        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]
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [post_a.pk, post_b.pk]
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)
 
         self.refresh_thread()
@@ -244,30 +346,34 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
 
     def test_merge_hidden_posts(self):
         """api merges two hidden posts"""
-        self.override_acl({
-            'can_hide_posts': 1
-        })
-
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': [
-                testutils.reply_thread(self.thread, poster="Bob", is_hidden=True).pk,
-                testutils.reply_thread(self.thread, poster="Bob", is_hidden=True).pk
-            ]
-        }), content_type="application/json")
+        self.override_acl({'can_hide_posts': 1})
+
+        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,
+                ]
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)
 
     def test_merge_unapproved_posts(self):
         """api merges two unapproved posts"""
-        self.override_acl({
-            'can_approve_content': 1
-        })
-
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': [
-                testutils.reply_thread(self.thread, poster="Bob", is_unapproved=True).pk,
-                testutils.reply_thread(self.thread, poster="Bob", is_unapproved=True).pk
-            ]
-        }), content_type="application/json")
+        self.override_acl({'can_approve_content': 1})
+
+        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,
+                ]
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)
 
     def test_merge_with_hidden_thread(self):
@@ -278,11 +384,13 @@ class ThreadPostMergeApiTestCase(AuthenticatedUserTestCase):
 
         post_visible = testutils.reply_thread(self.thread, poster=self.user, is_hidden=False)
 
-        self.override_acl({
-            'can_hide_threads': 1
-        })
+        self.override_acl({'can_hide_threads': 1})
 
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': [self.thread.first_post.pk, post_visible.pk]
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [self.thread.first_post.pk, post_visible.pk]
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)

+ 162 - 103
misago/threads/tests/test_thread_postmove_api.py

@@ -4,7 +4,6 @@ from __future__ import unicode_literals
 import json
 
 from django.urls import reverse
-from django.utils.six.moves import range
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
@@ -21,14 +20,20 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         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
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-move', kwargs={
+                'thread_pk': self.thread.pk,
+            }
+        )
 
         Category(
             name='Category B',
             slug='category-b',
-        ).insert_at(self.category, position='last-child', save=True)
+        ).insert_at(
+            self.category,
+            position='last-child',
+            save=True,
+        )
         self.category_b = Category.objects.get(slug='category-b')
 
         self.override_acl()
@@ -46,7 +51,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             'can_reply_threads': 1,
             'can_edit_posts': 1,
             'can_approve_content': 0,
-            'can_move_posts': 1
+            'can_move_posts': 1,
         })
 
         if extra_acl:
@@ -63,7 +68,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
             'can_reply_threads': 0,
             'can_edit_posts': 1,
             'can_approve_content': 0,
-            'can_move_posts': 1
+            'can_move_posts': 1,
         })
 
         if acl:
@@ -76,10 +81,12 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         if other_category_acl['can_see']:
             visible_categories.append(self.category_b.pk)
 
-        override_acl(self.user, {
-            'visible_categories': visible_categories,
-            'categories': categories_acl,
-        })
+        override_acl(
+            self.user, {
+                'visible_categories': visible_categories,
+                'categories': categories_acl,
+            }
+        )
 
     def test_anonymous_user(self):
         """you need to authenticate to move posts"""
@@ -90,9 +97,7 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
 
     def test_no_permission(self):
         """api validates permission to move"""
-        self.override_acl({
-            'can_move_posts': 0
-        })
+        self.override_acl({'can_move_posts': 0})
 
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
         self.assertContains(response, "You can't move posts in this thread.", status_code=403)
@@ -105,16 +110,20 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
     def test_invalid_url(self):
         """api validates thread url"""
         response = self.client.post(self.api_link, {
-            'thread_url': self.user.get_absolute_url()
+            'thread_url': self.user.get_absolute_url(),
         })
         self.assertContains(response, "This is not a valid thread link.", status_code=400)
 
     def test_current_thread_url(self):
         """api validates if thread url given is to current thread"""
-        response = self.client.post(self.api_link, {
-            'thread_url': self.thread.get_absolute_url()
-        })
-        self.assertContains(response, "Thread to move posts to is same as current one.", status_code=400)
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': self.thread.get_absolute_url(),
+            }
+        )
+        self.assertContains(
+            response, "Thread to move posts to is same as current one.", status_code=400
+        )
 
     def test_other_thread_exists(self):
         """api validates if other thread exists"""
@@ -125,167 +134,217 @@ class ThreadPostMoveApiTestCase(AuthenticatedUserTestCase):
         other_thread.delete()
 
         response = self.client.post(self.api_link, {
-            'thread_url': other_thread_url
+            'thread_url': other_thread_url,
         })
-        self.assertContains(response, "The thread you have entered link to doesn't exist", status_code=400)
+        self.assertContains(
+            response, "The thread you have entered link to doesn't exist", status_code=400
+        )
 
     def test_other_thread_is_invisible(self):
         """api validates if other thread is visible"""
-        self.override_other_acl({
-            'can_see': 0
-        })
+        self.override_other_acl({'can_see': 0})
 
         other_thread = testutils.post_thread(self.category_b)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url()
-        })
-        self.assertContains(response, "The thread you have entered link to doesn't exist", status_code=400)
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+            }
+        )
+        self.assertContains(
+            response, "The thread you have entered link to doesn't exist", status_code=400
+        )
 
     def test_other_thread_isnt_replyable(self):
         """api validates if other thread can be replied"""
-        self.override_other_acl({
-            'can_reply_threads': 0
-        })
+        self.override_other_acl({'can_reply_threads': 0})
 
         other_thread = testutils.post_thread(self.category_b)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url()
-        })
-        self.assertContains(response, "You can't move posts to threads you can't reply.", status_code=400)
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+            }
+        )
+        self.assertContains(
+            response, "You can't move posts to threads you can't reply.", status_code=400
+        )
 
     def test_empty_data(self):
         """api handles empty data"""
         other_thread = testutils.post_thread(self.category)
 
-        response = self.client.post(self.api_link, {
-            'thread_url': other_thread.get_absolute_url()
-        })
-        self.assertContains(response, "You have to specify at least one post to move.", status_code=400)
+        response = self.client.post(
+            self.api_link, {
+                'thread_url': other_thread.get_absolute_url(),
+            }
+        )
+        self.assertContains(
+            response, "You have to specify at least one post to move.", status_code=400
+        )
 
     def test_no_posts_ids(self):
         """api rejects no posts ids"""
         other_thread = testutils.post_thread(self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'thread_url': other_thread.get_absolute_url(),
-            'posts': []
-        }), content_type="application/json")
-        self.assertContains(response, "You have to specify at least one post to move.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'thread_url': other_thread.get_absolute_url(),
+                'posts': [],
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "You have to specify at least one post to move.", status_code=400
+        )
 
     def test_invalid_posts_data(self):
         """api handles invalid data"""
         other_thread = testutils.post_thread(self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'thread_url': other_thread.get_absolute_url(),
-            'posts': 'string'
-        }), content_type="application/json")
-        self.assertContains(response, "One or more post ids received were invalid.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'thread_url': other_thread.get_absolute_url(),
+                'posts': 'string',
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "One or more post ids received were invalid.", status_code=400
+        )
 
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
         other_thread = testutils.post_thread(self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'thread_url': other_thread.get_absolute_url(),
-            'posts': [1, 2, 'string']
-        }), content_type="application/json")
-        self.assertContains(response, "One or more post ids received were invalid.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'thread_url': other_thread.get_absolute_url(),
+                'posts': [1, 2, 'string'],
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "One or more post ids received were invalid.", status_code=400
+        )
 
     def test_move_limit(self):
         """api rejects more posts than move limit"""
         other_thread = testutils.post_thread(self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'thread_url': other_thread.get_absolute_url(),
-            'posts': list(range(MOVE_LIMIT + 1))
-        }), content_type="application/json")
-        self.assertContains(response, "No more than {} posts can be moved".format(MOVE_LIMIT), status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'thread_url': other_thread.get_absolute_url(),
+                'posts': list(range(MOVE_LIMIT + 1)),
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "No more than {} posts can be moved".format(MOVE_LIMIT), status_code=400
+        )
 
     def test_move_invisible(self):
         """api validates posts visibility"""
         other_thread = testutils.post_thread(self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'thread_url': other_thread.get_absolute_url(),
-            'posts': [
-                testutils.reply_thread(self.thread, is_unapproved=True).pk
-            ]
-        }), content_type="application/json")
-        self.assertContains(response, "One or more posts to move could not be found.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'thread_url': other_thread.get_absolute_url(),
+                'posts': [testutils.reply_thread(self.thread, is_unapproved=True).pk],
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "One or more posts to move could not be found.", status_code=400
+        )
 
     def test_move_other_thread_posts(self):
         """api recjects attempt to move other thread's post"""
         other_thread = testutils.post_thread(self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'thread_url': other_thread.get_absolute_url(),
-            'posts': [
-                testutils.reply_thread(other_thread, is_hidden=True).pk
-            ]
-        }), content_type="application/json")
-        self.assertContains(response, "One or more posts to move could not be found.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'thread_url': other_thread.get_absolute_url(),
+                'posts': [testutils.reply_thread(other_thread, is_hidden=True).pk],
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "One or more posts to move could not be found.", status_code=400
+        )
 
     def test_move_event(self):
         """api rejects events move"""
         other_thread = testutils.post_thread(self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'thread_url': other_thread.get_absolute_url(),
-            'posts': [
-                testutils.reply_thread(self.thread, is_event=True).pk
-            ]
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'thread_url': other_thread.get_absolute_url(),
+                'posts': [testutils.reply_thread(self.thread, is_event=True).pk],
+            }),
+            content_type="application/json",
+        )
         self.assertContains(response, "Events can't be moved.", status_code=400)
 
     def test_move_first_post(self):
         """api rejects first post move"""
         other_thread = testutils.post_thread(self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'thread_url': other_thread.get_absolute_url(),
-            'posts': [
-                self.thread.first_post_id
-            ]
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'thread_url': other_thread.get_absolute_url(),
+                'posts': [self.thread.first_post_id],
+            }),
+            content_type="application/json",
+        )
         self.assertContains(response, "You can't move thread's first post.", status_code=400)
 
     def test_move_hidden_posts(self):
         """api recjects attempt to move urneadable hidden post"""
         other_thread = testutils.post_thread(self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'thread_url': other_thread.get_absolute_url(),
-            'posts': [
-                testutils.reply_thread(self.thread, is_hidden=True).pk
-            ]
-        }), content_type="application/json")
-        self.assertContains(response, "You can't move posts the content you can't see.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'thread_url': other_thread.get_absolute_url(),
+                'posts': [testutils.reply_thread(self.thread, is_hidden=True).pk],
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "You can't move posts the content you can't see.", status_code=400
+        )
 
     def test_move_posts(self):
         """api moves posts to other thread"""
-        self.override_other_acl({
-            'can_reply_threads': 1
-        })
+        self.override_other_acl({'can_reply_threads': 1})
 
         other_thread = testutils.post_thread(self.category_b)
 
         posts = (
-            testutils.reply_thread(self.thread).pk,
-            testutils.reply_thread(self.thread).pk,
-            testutils.reply_thread(self.thread).pk,
-            testutils.reply_thread(self.thread).pk,
+            testutils.reply_thread(self.thread).pk, testutils.reply_thread(self.thread).pk,
+            testutils.reply_thread(self.thread).pk, testutils.reply_thread(self.thread).pk,
         )
 
         self.refresh_thread()
         self.assertEqual(self.thread.replies, 4)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'thread_url': other_thread.get_absolute_url(),
-            'posts': posts
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'thread_url': other_thread.get_absolute_url(),
+                'posts': posts,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)
 
         # replies were moved

+ 538 - 315
misago/threads/tests/test_thread_postpatch_api.py

@@ -10,7 +10,7 @@ from django.utils import timezone
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.threads import testutils
-from misago.threads.models import Post, Thread
+from misago.threads.models import Post
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -22,10 +22,13 @@ class ThreadPostPatchApiTestCase(AuthenticatedUserTestCase):
         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
-        })
+        self.api_link = reverse(
+            '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")
@@ -40,7 +43,7 @@ class ThreadPostPatchApiTestCase(AuthenticatedUserTestCase):
             'can_browse': 1,
             'can_start_threads': 0,
             'can_reply_threads': 0,
-            'can_edit_posts': 1
+            'can_edit_posts': 1,
         })
 
         if extra_acl:
@@ -53,7 +56,11 @@ 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}
+            {
+                'op': 'add',
+                'path': 'acl',
+                'value': True,
+            },
         ])
         self.assertEqual(response.status_code, 200)
 
@@ -63,7 +70,11 @@ class PostAddAclApiTests(ThreadPostPatchApiTestCase):
     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}
+            {
+                'op': 'add',
+                'path': 'acl',
+                'value': False,
+            },
         ])
         self.assertEqual(response.status_code, 200)
 
@@ -71,7 +82,11 @@ class PostAddAclApiTests(ThreadPostPatchApiTestCase):
         self.assertIsNone(response_json['acl'])
 
         response = self.patch(self.api_link, [
-            {'op': 'add', 'path': 'acl', 'value': True}
+            {
+                'op': 'add',
+                'path': 'acl',
+                'value': True,
+            },
         ])
         self.assertEqual(response.status_code, 200)
 
@@ -79,13 +94,17 @@ class PostAddAclApiTests(ThreadPostPatchApiTestCase):
 class PostProtectApiTests(ThreadPostPatchApiTestCase):
     def test_protect_post(self):
         """api makes it possible to protect post"""
-        self.override_acl({
-            'can_protect_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-protected', 'value': True}
-        ])
+        self.override_acl({'can_protect_posts': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-protected',
+                    'value': True,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         self.refresh_post()
@@ -96,13 +115,17 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         self.post.is_protected = True
         self.post.save()
 
-        self.override_acl({
-            'can_protect_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-protected', 'value': False}
-        ])
+        self.override_acl({'can_protect_posts': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-protected',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         self.refresh_post()
@@ -110,13 +133,17 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
 
     def test_protect_post_no_permission(self):
         """api validates permission to protect post"""
-        self.override_acl({
-            'can_protect_posts': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-protected', 'value': True}
-        ])
+        self.override_acl({'can_protect_posts': 0})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-protected',
+                    'value': True,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -130,13 +157,17 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
         self.post.is_protected = True
         self.post.save()
 
-        self.override_acl({
-            'can_protect_posts': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-protected', 'value': False}
-        ])
+        self.override_acl({'can_protect_posts': 0})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-protected',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -147,14 +178,17 @@ class PostProtectApiTests(ThreadPostPatchApiTestCase):
 
     def test_unprotect_post_not_editable(self):
         """api validates if we can edit post we want to protect"""
-        self.override_acl({
-            'can_edit_posts': 0,
-            'can_protect_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-protected', 'value': True}
-        ])
+        self.override_acl({'can_edit_posts': 0, 'can_protect_posts': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-protected',
+                    'value': True,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -170,13 +204,17 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.post.is_unapproved = True
         self.post.save()
 
-        self.override_acl({
-            'can_approve_content': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-unapproved', 'value': False}
-        ])
+        self.override_acl({'can_approve_content': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-unapproved',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         self.refresh_post()
@@ -184,13 +222,17 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
 
     def test_unapprove_post(self):
         """unapproving posts is not supported by api"""
-        self.override_acl({
-            'can_approve_content': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-unapproved', 'value': True}
-        ])
+        self.override_acl({'can_approve_content': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-unapproved',
+                    'value': True,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         self.refresh_post()
@@ -201,13 +243,17 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.post.is_unapproved = True
         self.post.save()
 
-        self.override_acl({
-            'can_approve_content': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-unapproved', 'value': False}
-        ])
+        self.override_acl({'can_approve_content': 0})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-unapproved',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -224,13 +270,17 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.thread.set_first_post(self.post)
         self.thread.save()
 
-        self.override_acl({
-            'can_approve_content': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-unapproved', 'value': False}
-        ])
+        self.override_acl({'can_approve_content': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-unapproved',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -245,17 +295,23 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
         self.post.is_hidden = True
         self.post.save()
 
-        self.override_acl({
-            'can_approve_content': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-unapproved', 'value': False}
-        ])
+        self.override_acl({'can_approve_content': 1})
+
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            response_json['detail'][0], "You can't approve posts the content you can't see."
+        )
 
         self.refresh_post()
         self.assertTrue(self.post.is_unapproved)
@@ -264,13 +320,17 @@ class PostApproveApiTests(ThreadPostPatchApiTestCase):
 class PostHideApiTests(ThreadPostPatchApiTestCase):
     def test_hide_post(self):
         """api makes it possible to hide post"""
-        self.override_acl({
-            'can_hide_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'can_hide_posts': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': True,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         self.refresh_post()
@@ -284,13 +344,17 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
 
-        self.override_acl({
-            'can_hide_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        self.override_acl({'can_hide_posts': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         self.refresh_post()
@@ -298,13 +362,17 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
 
     def test_hide_own_post(self):
         """api makes it possible to hide owned post"""
-        self.override_acl({
-            'can_hide_own_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'can_hide_own_posts': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': True,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         self.refresh_post()
@@ -318,13 +386,17 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
 
-        self.override_acl({
-            'can_hide_own_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        self.override_acl({'can_hide_own_posts': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         self.refresh_post()
@@ -332,13 +404,17 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
 
     def test_hide_post_no_permission(self):
         """api hide post with no permission fails"""
-        self.override_acl({
-            'can_hide_posts': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'can_hide_posts': 0})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': True,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -355,13 +431,17 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
 
-        self.override_acl({
-            'can_hide_posts': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        self.override_acl({'can_hide_posts': 0})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -375,14 +455,17 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.post.is_protected = True
         self.post.save()
 
-        self.override_acl({
-            'can_protect_posts': 0,
-            'can_hide_own_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'can_protect_posts': 0, 'can_hide_own_posts': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': True,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -396,21 +479,26 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.post.is_hidden = True
         self.post.save()
 
-        self.override_acl({
-            'can_protect_posts': 0,
-            'can_hide_own_posts': 1
-        })
+        self.override_acl({'can_protect_posts': 0, 'can_hide_own_posts': 1})
 
         self.post.is_protected = True
         self.post.save()
 
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            response_json['detail'][0], "This post is protected. You can't reveal it."
+        )
 
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
@@ -420,17 +508,23 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.post.poster = None
         self.post.save()
 
-        self.override_acl({
-            'can_hide_own_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'can_hide_own_posts': 1})
+
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            response_json['detail'][0], "You can't hide other users posts in this category."
+        )
 
         self.refresh_post()
         self.assertFalse(self.post.is_hidden)
@@ -441,17 +535,23 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.post.poster = None
         self.post.save()
 
-        self.override_acl({
-            'can_hide_own_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        self.override_acl({'can_hide_own_posts': 1})
+
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            response_json['detail'][0], "You can't reveal other users posts in this category."
+        )
 
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
@@ -461,18 +561,23 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.post.posted_on = timezone.now() - timedelta(minutes=10)
         self.post.save()
 
-        self.override_acl({
-            'post_edit_time': 1,
-            'can_hide_own_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'post_edit_time': 1, 'can_hide_own_posts': 1})
+
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            response_json['detail'][0], "You can't hide posts that are older than 1 minute."
+        )
 
         self.refresh_post()
         self.assertFalse(self.post.is_hidden)
@@ -483,18 +588,23 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.post.posted_on = timezone.now() - timedelta(minutes=10)
         self.post.save()
 
-        self.override_acl({
-            'post_edit_time': 1,
-            'can_hide_own_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        self.override_acl({'post_edit_time': 1, 'can_hide_own_posts': 1})
+
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            response_json['detail'][0], "You can't reveal posts that are older than 1 minute."
+        )
 
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
@@ -504,17 +614,23 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.thread.is_closed = True
         self.thread.save()
 
-        self.override_acl({
-            'can_hide_own_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'can_hide_own_posts': 1})
+
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            response_json['detail'][0], "This thread is closed. You can't hide posts in it."
+        )
 
         self.refresh_post()
         self.assertFalse(self.post.is_hidden)
@@ -527,17 +643,23 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.post.is_hidden = True
         self.post.save()
 
-        self.override_acl({
-            'can_hide_own_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        self.override_acl({'can_hide_own_posts': 1})
+
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            response_json['detail'][0], "This thread is closed. You can't reveal posts in it."
+        )
 
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
@@ -547,17 +669,23 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.category.is_closed = True
         self.category.save()
 
-        self.override_acl({
-            'can_hide_own_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'can_hide_own_posts': 1})
+
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            response_json['detail'][0], "This category is closed. You can't hide posts in it."
+        )
 
         self.refresh_post()
         self.assertFalse(self.post.is_hidden)
@@ -570,17 +698,23 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.post.is_hidden = True
         self.post.save()
 
-        self.override_acl({
-            'can_hide_own_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        self.override_acl({'can_hide_own_posts': 1})
+
+        response = self.patch(
+            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.")
+        self.assertEqual(
+            response_json['detail'][0], "This category is closed. You can't reveal posts in it."
+        )
 
         self.refresh_post()
         self.assertTrue(self.post.is_hidden)
@@ -590,13 +724,17 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.thread.set_first_post(self.post)
         self.thread.save()
 
-        self.override_acl({
-            'can_hide_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'can_hide_posts': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': True,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -607,13 +745,17 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
         self.thread.set_first_post(self.post)
         self.thread.save()
 
-        self.override_acl({
-            'can_hide_posts': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        self.override_acl({'can_hide_posts': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -623,42 +765,58 @@ class PostHideApiTests(ThreadPostPatchApiTestCase):
 class PostLikeApiTests(ThreadPostPatchApiTestCase):
     def test_like_no_see_permission(self):
         """api validates user's permission to see posts likes"""
-        self.override_acl({
-            'can_see_posts_likes': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-liked', 'value': True}
-        ])
+        self.override_acl({'can_see_posts_likes': 0})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-liked',
+                    'value': True,
+                },
+            ]
+        )
         self.assertContains(response, "You can't like posts in this category.", status_code=400)
 
     def test_like_no_like_permission(self):
         """api validates user's permission to see posts likes"""
-        self.override_acl({
-            'can_like_posts': False
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-liked', 'value': True}
-        ])
+        self.override_acl({'can_like_posts': False})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-liked',
+                    'value': True,
+                },
+            ]
+        )
         self.assertContains(response, "You can't like posts in this category.", status_code=400)
 
     def test_like_post(self):
         """api adds user like to post"""
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-liked', 'value': True}
-        ])
+        response = self.patch(
+            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['last_likes'], [
-            {
-                'id': self.user.id,
-                'username': self.user.username
-            }
-        ])
+        self.assertEqual(
+            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'])
@@ -671,32 +829,40 @@ class PostLikeApiTests(ThreadPostPatchApiTestCase):
         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}
-        ])
+        response = self.patch(
+            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['last_likes'], [
-            {
-                'id': self.user.id,
-                'username': self.user.username
-            },
-            {
-                'id': None,
-                'username': 'Miku'
-            },
-            {
-                'id': None,
-                'username': 'Bob'
-            },
-            {
-                'id': None,
-                'username': 'Mugi'
-            }
-        ])
+        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',
+                },
+            ]
+        )
 
         post = Post.objects.get(pk=self.post.pk)
         self.assertEqual(post.likes, response_json['likes'])
@@ -706,9 +872,15 @@ class PostLikeApiTests(ThreadPostPatchApiTestCase):
         """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}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-liked',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
@@ -724,20 +896,28 @@ class PostLikeApiTests(ThreadPostPatchApiTestCase):
         """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}
-        ])
+        response = self.patch(
+            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['last_likes'], [
-            {
-                'id': self.user.id,
-                'username': self.user.username
-            }
-        ])
+        self.assertEqual(
+            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'])
@@ -745,9 +925,15 @@ class PostLikeApiTests(ThreadPostPatchApiTestCase):
 
     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}
-        ])
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-liked',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         response_json = response.json()
@@ -762,10 +948,13 @@ class ThreadEventPatchApiTestCase(ThreadPostPatchApiTestCase):
 
         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
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-detail',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.event.pk,
+            }
+        )
 
     def refresh_event(self):
         self.event = self.thread.post_set.get(pk=self.event.pk)
@@ -777,7 +966,11 @@ class EventAnonPatchApiTests(ThreadEventPatchApiTestCase):
         self.logout_user()
 
         response = self.patch(self.api_link, [
-            {'op': 'add', 'path': 'acl', 'value': True}
+            {
+                'op': 'add',
+                'path': 'acl',
+                'value': True,
+            },
         ])
         self.assertEqual(response.status_code, 403)
 
@@ -786,7 +979,11 @@ 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}
+            {
+                'op': 'add',
+                'path': 'acl',
+                'value': True,
+            },
         ])
         self.assertEqual(response.status_code, 200)
 
@@ -796,7 +993,11 @@ class EventAddAclApiTests(ThreadEventPatchApiTestCase):
     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}
+            {
+                'op': 'add',
+                'path': 'acl',
+                'value': False,
+            },
         ])
         self.assertEqual(response.status_code, 200)
 
@@ -804,7 +1005,11 @@ class EventAddAclApiTests(ThreadEventPatchApiTestCase):
         self.assertIsNone(response_json['acl'])
 
         response = self.patch(self.api_link, [
-            {'op': 'add', 'path': 'acl', 'value': True}
+            {
+                'op': 'add',
+                'path': 'acl',
+                'value': True,
+            },
         ])
         self.assertEqual(response.status_code, 200)
 
@@ -812,13 +1017,17 @@ class EventAddAclApiTests(ThreadEventPatchApiTestCase):
 class EventHideApiTests(ThreadEventPatchApiTestCase):
     def test_hide_event(self):
         """api makes it possible to hide event"""
-        self.override_acl({
-            'can_hide_events': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'can_hide_events': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': True,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         self.refresh_event()
@@ -832,13 +1041,17 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         self.refresh_event()
         self.assertTrue(self.event.is_hidden)
 
-        self.override_acl({
-            'can_hide_events': 1
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        self.override_acl({'can_hide_events': 1})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 200)
 
         self.refresh_event()
@@ -846,17 +1059,23 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
 
     def test_hide_event_no_permission(self):
         """api hide event with no permission fails"""
-        self.override_acl({
-            'can_hide_events': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': True}
-        ])
+        self.override_acl({'can_hide_events': 0})
+
+        response = self.patch(
+            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 don't have permission to hide this event.")
+        self.assertEqual(
+            response_json['detail'][0], "You don't have permission to hide this event."
+        )
 
         self.refresh_event()
         self.assertFalse(self.event.is_hidden)
@@ -869,11 +1088,15 @@ class EventHideApiTests(ThreadEventPatchApiTestCase):
         self.refresh_event()
         self.assertTrue(self.event.is_hidden)
 
-        self.override_acl({
-            'can_hide_events': 0
-        })
-
-        response = self.patch(self.api_link, [
-            {'op': 'replace', 'path': 'is-hidden', 'value': False}
-        ])
+        self.override_acl({'can_hide_events': 0})
+
+        response = self.patch(
+            self.api_link, [
+                {
+                    'op': 'replace',
+                    'path': 'is-hidden',
+                    'value': False,
+                },
+            ]
+        )
         self.assertEqual(response.status_code, 404)

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

@@ -2,7 +2,6 @@ from django.urls import reverse
 from django.utils import timezone
 
 from misago.threads import testutils
-from misago.threads.models import Post, Thread
 
 from .test_threads_api import ThreadsApiTestCase
 
@@ -14,13 +13,16 @@ class PostReadApiTests(ThreadsApiTestCase):
         self.post = testutils.reply_thread(
             self.thread,
             poster=self.user,
-            posted_on=timezone.now()
+            posted_on=timezone.now(),
         )
 
-        self.api_link = reverse('misago:api:thread-post-read', kwargs={
-            'thread_pk': self.thread.pk,
-            'pk': self.post.pk
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-read',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.post.pk,
+            }
+        )
 
     def test_read_anonymous(self):
         """api validates if reading user is authenticated"""
@@ -47,7 +49,7 @@ 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)

+ 346 - 237
misago/threads/tests/test_thread_postsplit_api.py

@@ -4,8 +4,6 @@ from __future__ import unicode_literals
 import json
 
 from django.urls import reverse
-from django.utils.encoding import smart_str
-from django.utils.six.moves import range
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
@@ -22,18 +20,23 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         self.category = Category.objects.get(slug='first-category')
         self.thread = testutils.post_thread(category=self.category)
         self.posts = [
-            testutils.reply_thread(self.thread).pk,
-            testutils.reply_thread(self.thread).pk
+            testutils.reply_thread(self.thread).pk, testutils.reply_thread(self.thread).pk
         ]
 
-        self.api_link = reverse('misago:api:thread-post-split', kwargs={
-            'thread_pk': self.thread.pk
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-split', kwargs={
+                'thread_pk': self.thread.pk,
+            }
+        )
 
         Category(
             name='Category B',
             slug='category-b',
-        ).insert_at(self.category, position='last-child', save=True)
+        ).insert_at(
+            self.category,
+            position='last-child',
+            save=True,
+        )
         self.category_b = Category.objects.get(slug='category-b')
 
         self.override_acl()
@@ -51,7 +54,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             'can_reply_threads': 1,
             'can_edit_posts': 1,
             'can_approve_content': 0,
-            'can_move_posts': 1
+            'can_move_posts': 1,
         })
 
         if extra_acl:
@@ -68,7 +71,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             'can_reply_threads': 0,
             'can_edit_posts': 1,
             'can_approve_content': 0,
-            'can_move_posts': 1
+            'can_move_posts': 1,
         })
 
         if acl:
@@ -81,10 +84,12 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
         if other_category_acl['can_see']:
             visible_categories.append(self.category_b.pk)
 
-        override_acl(self.user, {
-            'visible_categories': visible_categories,
-            'categories': categories_acl,
-        })
+        override_acl(
+            self.user, {
+                'visible_categories': visible_categories,
+                'categories': categories_acl,
+            }
+        )
 
     def test_anonymous_user(self):
         """you need to authenticate to split posts"""
@@ -95,9 +100,7 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
 
     def test_no_permission(self):
         """api validates permission to split"""
-        self.override_acl({
-            'can_move_posts': 0
-        })
+        self.override_acl({'can_move_posts': 0})
 
         response = self.client.post(self.api_link, json.dumps({}), content_type="application/json")
         self.assertContains(response, "You can't split posts from this thread.", status_code=403)
@@ -105,319 +108,421 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
     def test_empty_data(self):
         """api handles empty data"""
         response = self.client.post(self.api_link)
-        self.assertContains(response, "You have to specify at least one post to split.", status_code=400)
+        self.assertContains(
+            response, "You have to specify at least one post to split.", status_code=400
+        )
 
     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.assertContains(response, "You have to specify at least one post to split.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [],
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "You have to specify at least one post to split.", status_code=400
+        )
 
     def test_invalid_posts_data(self):
         """api handles invalid data"""
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': 'string'
-        }), content_type="application/json")
-        self.assertContains(response, "One or more post ids received were invalid.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': 'string',
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "One or more post ids received were invalid.", status_code=400
+        )
 
     def test_invalid_posts_ids(self):
         """api handles invalid post id"""
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': [1, 2, 'string']
-        }), content_type="application/json")
-        self.assertContains(response, "One or more post ids received were invalid.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [1, 2, 'string'],
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "One or more post ids received were invalid.", status_code=400
+        )
 
     def test_split_limit(self):
         """api rejects more posts than split limit"""
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': list(range(SPLIT_LIMIT + 1))
-        }), content_type="application/json")
-        self.assertContains(response, "No more than {} posts can be split".format(SPLIT_LIMIT), status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': list(range(SPLIT_LIMIT + 1)),
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "No more than {} posts can be split".format(SPLIT_LIMIT), status_code=400
+        )
 
     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
-            ]
-        }), content_type="application/json")
-        self.assertContains(response, "One or more posts to split could not be found.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [testutils.reply_thread(self.thread, is_unapproved=True).pk],
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "One or more posts to split could not be found.", status_code=400
+        )
 
     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
-            ]
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [testutils.reply_thread(self.thread, is_event=True).pk],
+            }),
+            content_type="application/json",
+        )
         self.assertContains(response, "Events can't be split.", status_code=400)
 
     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
-            ]
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [self.thread.first_post_id],
+            }),
+            content_type="application/json",
+        )
         self.assertContains(response, "You can't split thread's first post.", status_code=400)
 
     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
-            ]
-        }), content_type="application/json")
-        self.assertContains(response, "You can't split posts the content you can't see.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [testutils.reply_thread(self.thread, is_hidden=True).pk],
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "You can't split posts the content you can't see.", status_code=400
+        )
 
     def test_split_other_thread_posts(self):
         """api recjects attempt to split other thread's post"""
         other_thread = testutils.post_thread(self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': [
-                testutils.reply_thread(other_thread, is_hidden=True).pk
-            ]
-        }), content_type="application/json")
-        self.assertContains(response, "One or more posts to split could not be found.", status_code=400)
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': [testutils.reply_thread(other_thread, is_hidden=True).pk],
+            }),
+            content_type="application/json",
+        )
+        self.assertContains(
+            response, "One or more posts to split could not be found.", status_code=400
+        )
 
     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
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': self.posts,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'title': ['This field is required.'],
-            'category': ['This field is required.'],
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'title': ['This field is required.'],
+                'category': ['This field is required.'],
+            }
+        )
 
     def test_split_invalid_final_title(self):
         """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
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': self.posts,
+                'title': '$$$',
+                'category': self.category.id,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'title': ["Thread title should be at least 5 characters long (it has 3)."]
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'title': ["Thread title should be at least 5 characters long (it has 3)."],
+            }
+        )
 
     def test_split_invalid_category(self):
         """api rejects split because final category was invalid"""
-        self.override_other_acl({
-            'can_see': 0
-        })
-
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': self.posts,
-            'title': 'Valid thread title',
-            'category': self.category_b.id
-        }), content_type="application/json")
+        self.override_other_acl({'can_see': 0})
+
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': self.posts,
+                'title': 'Valid thread title',
+                'category': self.category_b.id,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'category': ["Requested category could not be found."]
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'category': ["Requested category could not be found."],
+            }
+        )
 
     def test_split_unallowed_start_thread(self):
         """api rejects split because category isn't allowing starting threads"""
-        self.override_acl({
-            'can_start_threads': 0
-        })
-
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': self.posts,
-            'title': 'Valid thread title',
-            'category': self.category.id
-        }), content_type="application/json")
+        self.override_acl({'can_start_threads': 0})
+
+        response = self.client.post(
+            self.api_link,
+            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 = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'category': [
-                "You can't create new threads in selected category."
-            ]
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'category': ["You can't create new threads in selected category."],
+            }
+        )
 
     def test_split_invalid_weight(self):
         """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,
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            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 = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'weight': ["Ensure this value is less than or equal to 2."]
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'weight': ["Ensure this value is less than or equal to 2."],
+            }
+        )
 
     def test_split_unallowed_global_weight(self):
         """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,
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            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 = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'weight': [
-                "You don't have permission to pin threads globally in this category."
-            ]
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'weight': ["You don't have permission to pin threads globally in this category."],
+            }
+        )
 
     def test_split_unallowed_local_weight(self):
         """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")
+        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"
+        )
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'weight': [
-                "You don't have permission to pin threads in this category."
-            ]
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'weight': ["You don't have permission to pin threads in this category."],
+            }
+        )
 
     def test_split_allowed_local_weight(self):
         """api allows local weight"""
-        self.override_acl({
-            'can_pin_threads': 1
-        })
-
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': self.posts,
-            'title': '$$$',
-            'category': self.category.id,
-            'weight': 1,
-        }), content_type="application/json")
+        self.override_acl({'can_pin_threads': 1})
+
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': self.posts,
+                'title': '$$$',
+                'category': self.category.id,
+                'weight': 1,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'title': ["Thread title should be at least 5 characters long (it has 3)."]
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'title': ["Thread title should be at least 5 characters long (it has 3)."],
+            }
+        )
 
     def test_split_allowed_global_weight(self):
         """api allows global weight"""
-        self.override_acl({
-            'can_pin_threads': 2
-        })
-
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': self.posts,
-            'title': '$$$',
-            'category': self.category.id,
-            'weight': 2,
-        }), content_type="application/json")
+        self.override_acl({'can_pin_threads': 2})
+
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': self.posts,
+                'title': '$$$',
+                'category': self.category.id,
+                'weight': 2,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 400)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'title': ["Thread title should be at least 5 characters long (it has 3)."]
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'title': ["Thread title should be at least 5 characters long (it has 3)."],
+            }
+        )
 
     def test_split_unallowed_close(self):
         """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,
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            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 = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'is_closed': [
-                "You don't have permission to close threads in this category."
-            ]
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'is_closed': ["You don't have permission to close threads in this category."],
+            }
+        )
 
     def test_split_with_close(self):
         """api allows for closing thread"""
-        self.override_acl({
-            'can_close_threads': True
-        })
-
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': self.posts,
-            'title': '$$$',
-            'category': self.category.id,
-            'weight': 0,
-            'is_closed': True,
-        }), content_type="application/json")
+        self.override_acl({'can_close_threads': True})
+
+        response = self.client.post(
+            self.api_link,
+            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 = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'title': ["Thread title should be at least 5 characters long (it has 3)."]
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'title': ["Thread title should be at least 5 characters long (it has 3)."],
+            }
+        )
 
     def test_split_unallowed_hidden(self):
         """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,
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            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 = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'is_hidden': [
-                "You don't have permission to hide threads in this category."
-            ]
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'is_hidden': ["You don't have permission to hide threads in this category."],
+            }
+        )
 
     def test_split_with_hide(self):
         """api allows for hiding thread"""
-        self.override_acl({
-            'can_hide_threads': True
-        })
-
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': self.posts,
-            'title': '$$$',
-            'category': self.category.id,
-            'weight': 0,
-            'is_hidden': True,
-        }), content_type="application/json")
+        self.override_acl({'can_hide_threads': True})
+
+        response = self.client.post(
+            self.api_link,
+            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 = json.loads(smart_str(response.content))
-        self.assertEqual(response_json, {
-            'title': ["Thread title should be at least 5 characters long (it has 3)."]
-        })
+        response_json = response.json()
+        self.assertEqual(
+            response_json, {
+                'title': ["Thread title should be at least 5 characters long (it has 3)."],
+            }
+        )
 
     def test_split(self):
         """api splits posts to new thread"""
         self.refresh_thread()
         self.assertEqual(self.thread.replies, 2)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': self.posts,
-            'title': 'Split thread.',
-            'category': self.category.id
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            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
@@ -440,17 +545,21 @@ class ThreadPostSplitApiTestCase(AuthenticatedUserTestCase):
             'can_start_threads': 2,
             'can_close_threads': True,
             'can_hide_threads': True,
-            'can_pin_threads': 2
+            'can_pin_threads': 2,
         })
 
-        response = self.client.post(self.api_link, json.dumps({
-            'posts': self.posts,
-            'title': 'Split thread',
-            'category': self.category_b.id,
-            'weight': 2,
-            'is_closed': 1,
-            'is_hidden': 1
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'posts': self.posts,
+                'title': 'Split thread',
+                'category': self.category_b.id,
+                'weight': 2,
+                'is_closed': 1,
+                'is_hidden': 1,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)
 
         # thread was created

+ 44 - 43
misago/threads/tests/test_thread_reply_api.py

@@ -1,10 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-import json
-
 from django.urls import reverse
-from django.utils.encoding import smart_str
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
@@ -20,9 +17,11 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         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
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-list', kwargs={
+                'thread_pk': self.thread.pk,
+            }
+        )
 
     def override_acl(self, extra_acl=None):
         new_acl = self.user.acl_cache
@@ -30,7 +29,7 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
             'can_see': 1,
             'can_browse': 1,
             'can_start_threads': 0,
-            'can_reply_threads': 1
+            'can_reply_threads': 1,
         })
 
         if extra_acl:
@@ -61,49 +60,47 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
 
     def test_cant_reply_thread(self):
         """permission to reply thread is validated"""
-        self.override_acl({
-            'can_reply_threads': 0
-        })
+        self.override_acl({'can_reply_threads': 0})
 
         response = self.client.post(self.api_link)
-        self.assertContains(response, "You can't reply to threads in this category.", status_code=403)
+        self.assertContains(
+            response, "You can't reply to threads in this category.", status_code=403
+        )
 
     def test_closed_category(self):
         """permssion to reply in closed category is validated"""
-        self.override_acl({
-            'can_close_threads': 0
-        })
+        self.override_acl({'can_close_threads': 0})
 
         self.category.is_closed = True
         self.category.save()
 
         response = self.client.post(self.api_link)
-        self.assertContains(response, "This category is closed. You can't reply to threads in it.", status_code=403)
+        self.assertContains(
+            response,
+            "This category is closed. You can't reply to threads in it.",
+            status_code=403
+        )
 
         # allow to post in closed category
-        self.override_acl({
-            'can_close_threads': 1
-        })
+        self.override_acl({'can_close_threads': 1})
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
 
     def test_closed_thread(self):
         """permssion to reply in closed thread is validated"""
-        self.override_acl({
-            'can_close_threads': 0
-        })
+        self.override_acl({'can_close_threads': 0})
 
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.client.post(self.api_link)
-        self.assertContains(response, "You can't reply to closed threads in this category.", status_code=403)
+        self.assertContains(
+            response, "You can't reply to closed threads in this category.", status_code=403
+        )
 
         # allow to post in closed thread
-        self.override_acl({
-            'can_close_threads': 1
-        })
+        self.override_acl({'can_close_threads': 1})
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 400)
@@ -114,33 +111,35 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
 
         response = self.client.post(self.api_link, data={})
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(json.loads(smart_str(response.content)), {
-            'post': [
-                "You have to enter a message."
-            ]
+        self.assertEqual(response.json(), {
+            'post': ["You have to enter a message."],
         })
 
     def test_post_is_validated(self):
         """post is validated"""
         self.override_acl()
 
-        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(json.loads(smart_str(response.content)), {
-            'post': [
-                "Posted message should be at least 5 characters long (it has 1)."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'post': ["Posted message should be at least 5 characters long (it has 1)."],
+            }
+        )
 
     def test_can_reply_thread(self):
         """endpoint creates new reply"""
         self.override_acl()
-        response = self.client.post(self.api_link, data={
-            'post': "This is test response!"
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': "This is test response!",
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         thread = Thread.objects.get(pk=self.thread.pk)
@@ -178,7 +177,9 @@ class ReplyThreadTests(AuthenticatedUserTestCase):
         """unicode characters can be posted"""
         self.override_acl()
 
-        response = self.client.post(self.api_link, data={
-            'post': "Chrzążczyżewoszyce, powiat Łękółody."
-        })
+        response = self.client.post(
+            self.api_link, data={
+                'post': "Chrzążczyżewoszyce, powiat Łękółody.",
+            }
+        )
         self.assertEqual(response.status_code, 200)

+ 139 - 109
misago/threads/tests/test_thread_start_api.py

@@ -4,10 +4,7 @@ from __future__ import unicode_literals
 from django.urls import reverse
 
 from misago.acl.testutils import override_acl
-from misago.categories import THREADS_ROOT_NAME
 from misago.categories.models import Category
-from misago.threads.models import Thread
-from misago.threads.threadtypes import trees_map
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -15,8 +12,6 @@ class StartThreadTests(AuthenticatedUserTestCase):
     def setUp(self):
         super(StartThreadTests, self).setUp()
 
-        threads_tree_id = trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
-
         self.category = Category.objects.get(slug='first-category')
         self.api_link = reverse('misago:api:thread-list')
 
@@ -29,7 +24,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
             'can_pin_threads': 0,
             'can_close_threads': 0,
             'can_hide_threads': 0,
-            'can_hide_own_threads': 0
+            'can_hide_own_threads': 0,
         })
 
         if extra_acl:
@@ -56,7 +51,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.override_acl({'can_see': 0})
 
         response = self.client.post(self.api_link, {
-            'category': self.category.pk
+            'category': self.category.pk,
         })
 
         self.assertContains(response, "Selected category is invalid.", status_code=400)
@@ -66,7 +61,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.override_acl({'can_browse': 0})
 
         response = self.client.post(self.api_link, {
-            'category': self.category.pk
+            'category': self.category.pk,
         })
 
         self.assertContains(response, "Selected category is invalid.", status_code=400)
@@ -76,10 +71,12 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.override_acl({'can_start_threads': 0})
 
         response = self.client.post(self.api_link, {
-            'category': self.category.pk
+            'category': self.category.pk,
         })
 
-        self.assertContains(response, "You don't have permission to start new threads", status_code=400)
+        self.assertContains(
+            response, "You don't have permission to start new threads", status_code=400
+        )
 
     def test_cant_start_thread_in_locked_category(self):
         """can't post in closed category"""
@@ -89,7 +86,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
         self.override_acl({'can_close_threads': 0})
 
         response = self.client.post(self.api_link, {
-            'category': self.category.pk
+            'category': self.category.pk,
         })
 
         self.assertContains(response, "This category is closed.", status_code=400)
@@ -101,9 +98,7 @@ class StartThreadTests(AuthenticatedUserTestCase):
 
         self.override_acl({'can_close_threads': 0})
 
-        response = self.client.post(self.api_link, {
-            'category': self.category.pk * 100000
-        })
+        response = self.client.post(self.api_link, {'category': self.category.pk * 100000})
 
         self.assertContains(response, "Selected category doesn't exist", status_code=400)
 
@@ -113,60 +108,65 @@ 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."
-            ]
-        })
+        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."],
+            }
+        )
 
     def test_title_is_validated(self):
         """title is validated"""
         self.override_acl()
 
-        response = self.client.post(self.api_link, data={
-            'category': self.category.pk,
-            'title': "------",
-            'post': "Lorem ipsum dolor met, sit amet elit!",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'title': ["Thread title should contain alpha-numeric characters."],
+            }
+        )
 
     def test_post_is_validated(self):
         """post is validated"""
         self.override_acl()
 
-        response = self.client.post(self.api_link, data={
-            'category': self.category.pk,
-            'title': "Lorem ipsum dolor met",
-            'post': "a",
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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)."
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'post': ["Posted message should be at least 5 characters long (it has 1)."],
+            }
+        )
 
     def test_can_start_thread(self):
         """endpoint creates new thread"""
         self.override_acl()
-        response = self.client.post(self.api_link, data={
-            'category': self.category.pk,
-            'title': "Hello, I am test thread!",
-            'post': "Lorem ipsum dolor met!"
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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]
@@ -215,12 +215,15 @@ class StartThreadTests(AuthenticatedUserTestCase):
         """permission is checked before thread is closed"""
         self.override_acl({'can_close_threads': 0})
 
-        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
-        })
+        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,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         thread = self.user.thread_set.all()[:1][0]
@@ -230,12 +233,15 @@ class StartThreadTests(AuthenticatedUserTestCase):
         """can post closed thread"""
         self.override_acl({'can_close_threads': 1})
 
-        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
-        })
+        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,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         thread = self.user.thread_set.all()[:1][0]
@@ -245,12 +251,15 @@ class StartThreadTests(AuthenticatedUserTestCase):
         """can post unpinned thread"""
         self.override_acl({'can_pin_threads': 1})
 
-        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
-        })
+        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,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         thread = self.user.thread_set.all()[:1][0]
@@ -260,12 +269,15 @@ class StartThreadTests(AuthenticatedUserTestCase):
         """can post locally pinned thread"""
         self.override_acl({'can_pin_threads': 1})
 
-        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
-        })
+        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,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         thread = self.user.thread_set.all()[:1][0]
@@ -275,12 +287,15 @@ class StartThreadTests(AuthenticatedUserTestCase):
         """can post globally pinned thread"""
         self.override_acl({'can_pin_threads': 2})
 
-        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
-        })
+        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,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         thread = self.user.thread_set.all()[:1][0]
@@ -290,12 +305,15 @@ class StartThreadTests(AuthenticatedUserTestCase):
         """cant post globally pinned thread without permission"""
         self.override_acl({'can_pin_threads': 1})
 
-        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
-        })
+        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,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         thread = self.user.thread_set.all()[:1][0]
@@ -305,12 +323,15 @@ class StartThreadTests(AuthenticatedUserTestCase):
         """cant post locally pinned thread without permission"""
         self.override_acl({'can_pin_threads': 0})
 
-        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
-        })
+        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,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         thread = self.user.thread_set.all()[:1][0]
@@ -320,12 +341,15 @@ class StartThreadTests(AuthenticatedUserTestCase):
         """can post hidden thread"""
         self.override_acl({'can_hide_threads': 1})
 
-        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
-        })
+        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,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         thread = self.user.thread_set.all()[:1][0]
@@ -338,12 +362,15 @@ class StartThreadTests(AuthenticatedUserTestCase):
         """cant post hidden thread without permission"""
         self.override_acl({'can_hide_threads': 0})
 
-        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
-        })
+        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,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         thread = self.user.thread_set.all()[:1][0]
@@ -353,9 +380,12 @@ class StartThreadTests(AuthenticatedUserTestCase):
         """unicode characters can be posted"""
         self.override_acl()
 
-        response = self.client.post(self.api_link, data={
-            'category': self.category.pk,
-            'title': "Brzęczyżczykiewicz",
-            'post': "Chrzążczyżewoszyce, powiat Łękółody."
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                'category': self.category.pk,
+                'title': "Brzęczyżczykiewicz",
+                'post': "Chrzążczyżewoszyce, powiat Łękółody.",
+            }
+        )
         self.assertEqual(response.status_code, 200)

+ 3 - 4
misago/threads/tests/test_threadparticipant_model.py

@@ -21,7 +21,7 @@ class ThreadParticipantTests(TestCase):
             starter_slug='tester',
             last_post_on=datetime,
             last_poster_name='Tester',
-            last_poster_slug='tester'
+            last_poster_slug='tester',
         )
 
         self.thread.set_title("Test thread")
@@ -36,7 +36,7 @@ class ThreadParticipantTests(TestCase):
             parsed="<p>Hello! I am test message!</p>",
             checksum="nope",
             posted_on=datetime,
-            updated_on=datetime
+            updated_on=datetime,
         )
 
         self.thread.first_post = post
@@ -91,5 +91,4 @@ class ThreadParticipantTests(TestCase):
         self.assertEqual(self.thread.participants.count(), 1)
 
         with self.assertRaises(ThreadParticipant.DoesNotExist):
-            participant = ThreadParticipant.objects.get(
-                thread=self.thread, user=user)
+            ThreadParticipant.objects.get(thread=self.thread, user=user)

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

@@ -33,7 +33,7 @@ class ThreadsApiTestCase(AuthenticatedUserTestCase):
             'can_edit_posts': 0,
             'can_hide_posts': 0,
             'can_hide_own_posts': 0,
-            'can_merge_threads': 0
+            'can_merge_threads': 0,
         })
 
         if acl:
@@ -49,13 +49,15 @@ class ThreadsApiTestCase(AuthenticatedUserTestCase):
         if not final_acl['can_browse'] and self.category.pk in browseable_categories:
             browseable_categories.remove(self.category.pk)
 
-        override_acl(self.user, {
-            'visible_categories': visible_categories,
-            'browseable_categories': browseable_categories,
-            'categories': {
-                self.category.pk: final_acl
+        override_acl(
+            self.user, {
+                'visible_categories': visible_categories,
+                'browseable_categories': browseable_categories,
+                'categories': {
+                    self.category.pk: final_acl,
+                },
             }
-        })
+        )
 
     def get_thread_json(self):
         response = self.client.get(self.thread.get_api_url())
@@ -92,9 +94,7 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
     def test_api_shows_owned_thread(self):
         """api handles "owned threads only"""
         for link in self.tested_links:
-            self.override_acl({
-                'can_see_all_threads': 0
-            })
+            self.override_acl({'can_see_all_threads': 0})
 
             response = self.client.get(link)
             self.assertEqual(response.status_code, 404)
@@ -103,9 +103,7 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
         self.thread.save()
 
         for link in self.tested_links:
-            self.override_acl({
-                'can_see_all_threads': 0
-            })
+            self.override_acl({'can_see_all_threads': 0})
 
             response = self.client.get(link)
             self.assertEqual(response.status_code, 200)
@@ -113,9 +111,7 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
     def test_api_validates_category_see_permission(self):
         """api validates category visiblity"""
         for link in self.tested_links:
-            self.override_acl({
-                'can_see': 0
-            })
+            self.override_acl({'can_see': 0})
 
             response = self.client.get(link)
             self.assertEqual(response.status_code, 404)
@@ -123,46 +119,45 @@ class ThreadRetrieveApiTests(ThreadsApiTestCase):
     def test_api_validates_category_browse_permission(self):
         """api validates category browsability"""
         for link in self.tested_links:
-            self.override_acl({
-                'can_browse': 0
-            })
+            self.override_acl({'can_browse': 0})
 
             response = self.client.get(link)
             self.assertEqual(response.status_code, 404)
 
     def test_api_validates_posts_visibility(self):
         """api validates posts visiblity"""
-        self.override_acl({
-            'can_hide_posts': 0
-        })
+        self.override_acl({'can_hide_posts': 0})
 
-        hidden_post = testutils.reply_thread(self.thread, is_hidden=True, message="I'am hidden test message!")
+        hidden_post = testutils.reply_thread(
+            self.thread,
+            is_hidden=True,
+            message="I'am hidden test message!",
+        )
 
         response = self.client.get(self.tested_links[1])
-        self.assertNotContains(response, hidden_post.parsed) # post's body is hidden
+        self.assertNotContains(response, hidden_post.parsed)  # post's body is hidden
 
         # add permission to see hidden posts
-        self.override_acl({
-            'can_hide_posts': 1
-        })
+        self.override_acl({'can_hide_posts': 1})
 
         response = self.client.get(self.tested_links[1])
-        self.assertContains(response, hidden_post.parsed) # hidden post's body is visible with permission
+        self.assertContains(
+            response, hidden_post.parsed
+        )  # hidden post's body is visible with permission
 
-        self.override_acl({
-            'can_approve_content': 0
-        })
+        self.override_acl({'can_approve_content': 0})
 
         # 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,
+        )
 
         response = self.client.get(self.tested_links[1])
         self.assertNotContains(response, unapproved_post.get_absolute_url())
 
         # add permission to see unapproved posts
-        self.override_acl({
-            'can_approve_content': 1
-        })
+        self.override_acl({'can_approve_content': 1})
 
         response = self.client.get(self.tested_links[1])
         self.assertContains(response, unapproved_post.get_absolute_url())
@@ -189,18 +184,14 @@ class ThreadsReadApiTests(ThreadsApiTestCase):
 
     def test_read_category_no_see(self):
         """api validates permission to see category"""
-        self.override_acl({
-            'can_see': 0
-        })
+        self.override_acl({'can_see': 0})
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 404)
 
     def test_read_category_no_browse(self):
         """api validates permission to browse category"""
-        self.override_acl({
-            'can_browse': 0
-        })
+        self.override_acl({'can_browse': 0})
 
         response = self.client.post(self.api_link)
         self.assertEqual(response.status_code, 403)
@@ -229,29 +220,24 @@ class ThreadsReadApiTests(ThreadsApiTestCase):
 class ThreadDeleteApiTests(ThreadsApiTestCase):
     def test_delete_thread_no_permission(self):
         """DELETE to API link with no permission to delete fails"""
-        self.override_acl({
-            'can_hide_threads': 1
-        })
+        self.override_acl({'can_hide_threads': 1})
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
 
-        self.override_acl({
-            'can_hide_threads': 0
-        })
+        self.override_acl({'can_hide_threads': 0})
 
         response_json = response.json()
-        self.assertEqual(response_json['detail'],
-            "You don't have permission to delete this thread.")
+        self.assertEqual(
+            response_json['detail'], "You don't have permission to delete this thread."
+        )
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 403)
 
     def test_delete_thread(self):
         """DELETE to API link with permission deletes thread"""
-        self.override_acl({
-            'can_hide_threads': 2
-        })
+        self.override_acl({'can_hide_threads': 2})
 
         response = self.client.delete(self.api_link)
         self.assertEqual(response.status_code, 200)

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

@@ -1,8 +1,6 @@
-import json
 import os
 
 from django.urls import reverse
-from django.utils.encoding import smart_str
 
 from misago.acl import add_acl
 from misago.acl.testutils import override_acl
@@ -61,12 +59,14 @@ class EditorApiTestCase(AuthenticatedUserTestCase):
         if final_acl['can_browse']:
             browseable_categories.append(self.category.pk)
 
-        override_acl(self.user, {
-            'browseable_categories': browseable_categories,
-            'categories': {
-                self.category.pk: final_acl
+        override_acl(
+            self.user, {
+                'browseable_categories': browseable_categories,
+                'categories': {
+                    self.category.pk: final_acl,
+                },
             }
-        })
+        )
 
 
 class ThreadPostEditorApiTests(EditorApiTestCase):
@@ -91,19 +91,14 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
 
     def test_category_disallowing_new_threads(self):
         """endpoint omits category disallowing starting threads"""
-        self.override_acl({
-            'can_start_threads': 0,
-        })
+        self.override_acl({'can_start_threads': 0})
 
         response = self.client.get(self.api_link)
         self.assertContains(response, "No categories that allow new threads", status_code=403)
 
     def test_category_closed_disallowing_new_threads(self):
         """endpoint omits closed category"""
-        self.override_acl({
-            'can_start_threads': 2,
-            'can_close_threads': 0,
-        })
+        self.override_acl({'can_start_threads': 2, 'can_close_threads': 0})
 
         self.category.is_closed = True
         self.category.save()
@@ -113,10 +108,7 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
 
     def test_category_closed_allowing_new_threads(self):
         """endpoint adds closed category that allows new threads"""
-        self.override_acl({
-            'can_start_threads': 2,
-            'can_close_threads': 1,
-        })
+        self.override_acl({'can_start_threads': 2, 'can_close_threads': 1})
 
         self.category.is_closed = True
         self.category.save()
@@ -124,146 +116,143 @@ class ThreadPostEditorApiTests(EditorApiTestCase):
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json[0], {
-            'id': self.category.pk,
-            'name': self.category.name,
-            'level': 0,
-            'post': {
-                'close': True,
-                'hide': False,
-                'pin': 0
+        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,
+                },
             }
-        })
+        )
 
     def test_category_allowing_new_threads(self):
         """endpoint adds category that allows new threads"""
-        self.override_acl({
-            'can_start_threads': 2,
-        })
+        self.override_acl({'can_start_threads': 2})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json[0], {
-            'id': self.category.pk,
-            'name': self.category.name,
-            'level': 0,
-            'post': {
-                'close': False,
-                'hide': False,
-                'pin': 0
+        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,
+                },
             }
-        })
+        )
 
     def test_category_allowing_closing_threads(self):
         """endpoint adds category that allows new closed threads"""
-        self.override_acl({
-            'can_start_threads': 2,
-            'can_close_threads': 1,
-        })
+        self.override_acl({'can_start_threads': 2, 'can_close_threads': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json[0], {
-            'id': self.category.pk,
-            'name': self.category.name,
-            'level': 0,
-            'post': {
-                'close': True,
-                'hide': False,
-                'pin': 0
+        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,
+                },
             }
-        })
+        )
 
     def test_category_allowing_locally_pinned_threads(self):
         """endpoint adds category that allows locally pinned threads"""
-        self.override_acl({
-            'can_start_threads': 2,
-            'can_pin_threads': 1,
-        })
+        self.override_acl({'can_start_threads': 2, 'can_pin_threads': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json[0], {
-            'id': self.category.pk,
-            'name': self.category.name,
-            'level': 0,
-            'post': {
-                'close': False,
-                'hide': False,
-                'pin': 1
+        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,
+                },
             }
-        })
+        )
 
     def test_category_allowing_globally_pinned_threads(self):
         """endpoint adds category that allows globally pinned threads"""
-        self.override_acl({
-            'can_start_threads': 2,
-            'can_pin_threads': 2,
-        })
+        self.override_acl({'can_start_threads': 2, 'can_pin_threads': 2})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json[0], {
-            'id': self.category.pk,
-            'name': self.category.name,
-            'level': 0,
-            'post': {
-                'close': False,
-                'hide': False,
-                'pin': 2
+        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,
+                },
             }
-        })
+        )
 
     def test_category_allowing_hidden_threads(self):
         """endpoint adds category that allows globally pinned threads"""
-        self.override_acl({
-            'can_start_threads': 2,
-            'can_hide_threads': 1,
-        })
+        self.override_acl({'can_start_threads': 2, 'can_hide_threads': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json[0], {
-            'id': self.category.pk,
-            'name': self.category.name,
-            'level': 0,
-            'post': {
-                'close': 0,
-                'hide': 1,
-                'pin': 0
+        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,
+                },
             }
-        })
+        )
 
-        self.override_acl({
-            'can_start_threads': 2,
-            'can_hide_threads': 2,
-        })
+        self.override_acl({'can_start_threads': 2, 'can_hide_threads': 2})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json[0], {
-            'id': self.category.pk,
-            'name': self.category.name,
-            'level': 0,
-            'post': {
-                'close': False,
-                'hide': True,
-                'pin': 0
+        response_json = response.json()
+        self.assertEqual(
+            response_json[0], {
+                'id': self.category.pk,
+                'name': self.category.name,
+                'level': 0,
+                'post': {
+                    'close': False,
+                    'hide': True,
+                    'pin': 0,
+                },
             }
-        })
+        )
 
 
 class ThreadReplyEditorApiTests(EditorApiTestCase):
@@ -271,9 +260,11 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
         super(ThreadReplyEditorApiTests, self).setUp()
 
         self.thread = testutils.post_thread(category=self.category)
-        self.api_link = reverse('misago:api:thread-post-editor', kwargs={
-            'thread_pk': self.thread.pk
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-editor', kwargs={
+                'thread_pk': self.thread.pk,
+            }
+        )
 
     def test_anonymous_user_request(self):
         """endpoint validates if user is authenticated"""
@@ -298,82 +289,73 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
 
     def test_no_reply_permission(self):
         """permssion to reply is validated"""
-        self.override_acl({
-            'can_reply_threads': 0
-        })
+        self.override_acl({'can_reply_threads': 0})
 
         response = self.client.get(self.api_link)
-        self.assertContains(response, "You can't reply to threads in this category.", status_code=403)
+        self.assertContains(
+            response, "You can't reply to threads in this category.", status_code=403
+        )
 
     def test_closed_category(self):
         """permssion to reply in closed category is validated"""
-        self.override_acl({
-            'can_reply_threads': 1,
-            'can_close_threads': 0
-        })
+        self.override_acl({'can_reply_threads': 1, 'can_close_threads': 0})
 
         self.category.is_closed = True
         self.category.save()
 
         response = self.client.get(self.api_link)
-        self.assertContains(response, "This category is closed. You can't reply to threads in it.", status_code=403)
+        self.assertContains(
+            response,
+            "This category is closed. You can't reply to threads in it.",
+            status_code=403
+        )
 
         # allow to post in closed category
-        self.override_acl({
-            'can_reply_threads': 1,
-            'can_close_threads': 1
-        })
+        self.override_acl({'can_reply_threads': 1, 'can_close_threads': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
     def test_closed_thread(self):
         """permssion to reply in closed thread is validated"""
-        self.override_acl({
-            'can_reply_threads': 1,
-            'can_close_threads': 0
-        })
+        self.override_acl({'can_reply_threads': 1, 'can_close_threads': 0})
 
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.client.get(self.api_link)
-        self.assertContains(response, "You can't reply to closed threads in this category.", status_code=403)
+        self.assertContains(
+            response, "You can't reply to closed threads in this category.", status_code=403
+        )
 
         # allow to post in closed thread
-        self.override_acl({
-            'can_reply_threads': 1,
-            'can_close_threads': 1
-        })
+        self.override_acl({'can_reply_threads': 1, 'can_close_threads': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
     def test_allow_reply_thread(self):
         """api returns 200 code if thread reply is allowed"""
-        self.override_acl({
-            'can_reply_threads': 1
-        })
+        self.override_acl({'can_reply_threads': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
     def test_reply_to_visibility(self):
         """api validates replied post visibility"""
-        self.override_acl({
-            'can_reply_threads': 1
-        })
+        self.override_acl({'can_reply_threads': 1})
 
         # unapproved reply can't be replied to
-        unapproved_reply = testutils.reply_thread(self.thread, is_unapproved=True)
+        unapproved_reply = testutils.reply_thread(
+            self.thread,
+            is_unapproved=True,
+        )
 
         response = self.client.get('{}?reply={}'.format(self.api_link, unapproved_reply.pk))
         self.assertEqual(response.status_code, 404)
 
         # hidden reply can't be replied to
-        self.override_acl({
-            'can_reply_threads': 1
-        })
+        self.override_acl({'can_reply_threads': 1})
 
         hidden_reply = testutils.reply_thread(self.thread, is_hidden=True)
 
@@ -390,9 +372,7 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
 
     def test_reply_to_event(self):
         """events can't be edited"""
-        self.override_acl({
-            'can_reply_threads': 1
-        })
+        self.override_acl({'can_reply_threads': 1})
 
         reply_to = testutils.reply_thread(self.thread, is_event=True)
 
@@ -402,20 +382,20 @@ class ThreadReplyEditorApiTests(EditorApiTestCase):
 
     def test_reply_to(self):
         """api includes replied to post details in response"""
-        self.override_acl({
-            'can_reply_threads': 1
-        })
+        self.override_acl({'can_reply_threads': 1})
 
         reply_to = testutils.reply_thread(self.thread)
 
         response = self.client.get('{}?reply={}'.format(self.api_link, reply_to.pk))
 
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(json.loads(smart_str(response.content)), {
-            'id': reply_to.pk,
-            'post': reply_to.original,
-            'poster': reply_to.poster_name
-        })
+        self.assertEqual(
+            response.json(), {
+                'id': reply_to.pk,
+                'post': reply_to.original,
+                'poster': reply_to.poster_name,
+            }
+        )
 
 
 class EditReplyEditorApiTests(EditorApiTestCase):
@@ -425,10 +405,13 @@ class EditReplyEditorApiTests(EditorApiTestCase):
         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-editor', kwargs={
-            'thread_pk': self.thread.pk,
-            'pk': self.post.pk
-        })
+        self.api_link = reverse(
+            'misago:api:thread-post-editor',
+            kwargs={
+                'thread_pk': self.thread.pk,
+                'pk': self.post.pk,
+            }
+        )
 
     def test_anonymous_user_request(self):
         """endpoint validates if user is authenticated"""
@@ -453,121 +436,96 @@ class EditReplyEditorApiTests(EditorApiTestCase):
 
     def test_no_edit_permission(self):
         """permssion to edit is validated"""
-        self.override_acl({
-            'can_edit_posts': 0
-        })
+        self.override_acl({'can_edit_posts': 0})
 
         response = self.client.get(self.api_link)
         self.assertContains(response, "You can't edit posts in this category.", status_code=403)
 
     def test_closed_category(self):
         """permssion to edit in closed category is validated"""
-        self.override_acl({
-            'can_edit_posts': 1,
-            'can_close_threads': 0
-        })
+        self.override_acl({'can_edit_posts': 1, 'can_close_threads': 0})
 
         self.category.is_closed = True
         self.category.save()
 
         response = self.client.get(self.api_link)
-        self.assertContains(response, "This category is closed. You can't edit posts in it.", status_code=403)
+        self.assertContains(
+            response, "This category is closed. You can't edit posts in it.", status_code=403
+        )
 
         # allow to edit in closed category
-        self.override_acl({
-            'can_edit_posts': 1,
-            'can_close_threads': 1
-        })
+        self.override_acl({'can_edit_posts': 1, 'can_close_threads': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
     def test_closed_thread(self):
         """permssion to edit in closed thread is validated"""
-        self.override_acl({
-            'can_edit_posts': 1,
-            'can_close_threads': 0
-        })
+        self.override_acl({'can_edit_posts': 1, 'can_close_threads': 0})
 
         self.thread.is_closed = True
         self.thread.save()
 
         response = self.client.get(self.api_link)
-        self.assertContains(response, "This thread is closed. You can't edit posts in it.", status_code=403)
+        self.assertContains(
+            response, "This thread is closed. You can't edit posts in it.", status_code=403
+        )
 
         # allow to edit in closed thread
-        self.override_acl({
-            'can_edit_posts': 1,
-            'can_close_threads': 1
-        })
+        self.override_acl({'can_edit_posts': 1, 'can_close_threads': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
     def test_protected_post(self):
         """permssion to edit protected post is validated"""
-        self.override_acl({
-            'can_edit_posts': 1,
-            'can_protect_posts': 0
-        })
+        self.override_acl({'can_edit_posts': 1, 'can_protect_posts': 0})
 
         self.post.is_protected = True
         self.post.save()
 
         response = self.client.get(self.api_link)
-        self.assertContains(response, "This post is protected. You can't edit it.", status_code=403)
+        self.assertContains(
+            response, "This post is protected. You can't edit it.", status_code=403
+        )
 
         # allow to post in closed thread
-        self.override_acl({
-            'can_edit_posts': 1,
-            'can_protect_posts': 1
-        })
+        self.override_acl({'can_edit_posts': 1, 'can_protect_posts': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
     def test_post_visibility(self):
         """edited posts visibility is validated"""
-        self.override_acl({
-            'can_edit_posts': 1
-        })
+        self.override_acl({'can_edit_posts': 1})
 
-        self.post.is_hidden = True;
+        self.post.is_hidden = True
         self.post.save()
 
         response = self.client.get(self.api_link)
         self.assertContains(response, "This post is hidden, you can't edit it.", status_code=403)
 
         # allow hidden edition
-        self.override_acl({
-            'can_edit_posts': 1,
-            'can_hide_posts': 1
-        })
+        self.override_acl({'can_edit_posts': 1, 'can_hide_posts': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
         # test unapproved post
-        self.post.is_hidden = False;
-        self.post.poster = None;
+        self.post.is_hidden = False
+        self.post.poster = None
         self.post.save()
 
-        self.override_acl({
-            'can_edit_posts': 2,
-            'can_approve_content': 0
-        })
+        self.override_acl({'can_edit_posts': 2, 'can_approve_content': 0})
 
-        self.post.is_unapproved = True;
+        self.post.is_unapproved = True
         self.post.save()
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 404)
 
         # allow unapproved edition
-        self.override_acl({
-            'can_edit_posts': 2,
-            'can_approve_content': 1
-        })
+        self.override_acl({'can_edit_posts': 2, 'can_approve_content': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
@@ -585,55 +543,53 @@ class EditReplyEditorApiTests(EditorApiTestCase):
 
     def test_other_user_post(self):
         """api validates if other user's post can be edited"""
-        self.override_acl({
-            'can_edit_posts': 1,
-        })
+        self.override_acl({'can_edit_posts': 1})
 
-        self.post.poster = None;
+        self.post.poster = None
         self.post.save()
 
         response = self.client.get(self.api_link)
-        self.assertContains(response, "You can't edit other users posts in this category.", status_code=403)
+        self.assertContains(
+            response, "You can't edit other users posts in this category.", status_code=403
+        )
 
         # allow other users post edition
-        self.override_acl({
-            'can_edit_posts': 2,
-        })
+        self.override_acl({'can_edit_posts': 2})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
     def test_edit_first_post_hidden(self):
         """endpoint returns valid configuration for editor of hidden thread's first post"""
-        self.override_acl({
-            'can_hide_threads': 1,
-            'can_edit_posts': 2
-        })
+        self.override_acl({'can_hide_threads': 1, 'can_edit_posts': 2})
 
         self.thread.is_hidden = True
         self.thread.save()
         self.thread.first_post.is_hidden = True
         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
-        })
+        api_link = reverse(
+            '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)
 
     def test_edit(self):
         """endpoint returns valid configuration for editor"""
-        for i in range(3):
-            self.override_acl({
-                'max_attachment_size': 1000,
-            })
+        for _ in range(3):
+            self.override_acl({'max_attachment_size': 1000})
 
             with open(TEST_DOCUMENT_PATH, 'rb') as upload:
-                response = self.client.post(reverse('misago:api:attachment-list'), data={
-                    'upload': upload
-                })
+                response = self.client.post(
+                    reverse('misago:api:attachment-list'), data={
+                        'upload': upload,
+                    }
+                )
             self.assertEqual(response.status_code, 200)
 
         attachments = list(Attachment.objects.order_by('id'))
@@ -645,24 +601,24 @@ class EditReplyEditorApiTests(EditorApiTestCase):
             attachment.post = self.post
             attachment.save()
 
-        self.override_acl({
-            'can_edit_posts': 1,
-        })
+        self.override_acl({'can_edit_posts': 1})
         response = self.client.get(self.api_link)
 
         for attachment in attachments:
             add_acl(self.user, attachment)
 
         self.assertEqual(response.status_code, 200)
-        self.assertEqual(json.loads(smart_str(response.content)), {
-            '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,
-            ]
-        })
+        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,
+                ],
+            }
+        )

+ 434 - 288
misago/threads/tests/test_threads_merge_api.py

@@ -1,10 +1,8 @@
 import json
 
 from django.urls import reverse
-from django.utils.six.moves import range
 
 from misago.acl import add_acl
-from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.threads import testutils
 from misago.threads.api.threadendpoints.merge import MERGE_LIMIT
@@ -22,7 +20,11 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         Category(
             name='Category B',
             slug='category-b',
-        ).insert_at(self.category, position='last-child', save=True)
+        ).insert_at(
+            self.category,
+            position='last-child',
+            save=True,
+        )
         self.category_b = Category.objects.get(slug='category-b')
 
     def test_merge_no_threads(self):
@@ -31,115 +33,155 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
         self.assertEqual(response.status_code, 403)
 
         response_json = response.json()
-        self.assertEqual(response_json, {
-            'detail': "You have to select at least two threads to merge."
-        })
+        self.assertEqual(
+            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")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'threads': [],
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 403)
 
         response_json = response.json()
-        self.assertEqual(response_json, {
-            'detail': "You have to select at least two threads to merge."
-        })
+        self.assertEqual(
+            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'
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'threads': 'abcd',
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 403)
 
         response_json = response.json()
-        self.assertEqual(response_json, {
-            'detail': "One or more thread ids received were invalid."
-        })
-
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': ['a', '-', 'c']
-        }), content_type="application/json")
+        self.assertEqual(
+            response_json, {
+                'detail': "One or more thread ids received were invalid.",
+            }
+        )
+
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'threads': ['a', '-', 'c'],
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 403)
 
         response_json = response.json()
-        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.",
+            }
+        )
 
     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]
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'threads': [self.thread.id],
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 403)
 
         response_json = response.json()
-        self.assertEqual(response_json, {
-            'detail': "You have to select at least two threads to merge."
-        })
+        self.assertEqual(
+            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"""
-        unaccesible_thread = testutils.post_thread(category=self.category_b)
-
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, self.thread.id + 1000]
-        }), content_type="application/json")
+        testutils.post_thread(category=self.category_b)
+
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'threads': [self.thread.id, self.thread.id + 1000],
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 403)
 
         response_json = response.json()
-        self.assertEqual(response_json, {
-            'detail': "One or more threads to merge could not be found."
-        })
+        self.assertEqual(
+            response_json, {
+                'detail': "One or more threads to merge could not be found.",
+            }
+        )
 
     def test_merge_with_invisible_thread(self):
         """api validates if we are trying to merge with inaccesible thread"""
         unaccesible_thread = testutils.post_thread(category=self.category_b)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, unaccesible_thread.id]
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'threads': [self.thread.id, unaccesible_thread.id],
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 403)
 
         response_json = response.json()
-        self.assertEqual(response_json, {
-            'detail': "One or more threads to merge could not be found."
-        })
+        self.assertEqual(
+            response_json, {
+                'detail': "One or more threads to merge could not be found.",
+            }
+        )
 
     def test_merge_no_permission(self):
         """api validates permission to merge threads"""
         thread = testutils.post_thread(category=self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, thread.id]
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'threads': [self.thread.id, thread.id],
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 403)
 
         response_json = response.json()
-        self.assertEqual(response_json, [
-            {
-                'id': thread.pk,
-                'title': thread.title,
-                'errors': [
-                    "You don't have permission to merge this thread with others."
-                ]
-            },
-            {
-                'id': self.thread.pk,
-                'title': self.thread.title,
-                'errors': [
-                    "You don't have permission to merge this thread with others."
-                ]
-            },
-        ])
+        self.assertEqual(
+            response_json, [
+                {
+                    'id': thread.pk,
+                    'title': thread.title,
+                    'errors': ["You don't have permission to merge this thread with others."],
+                },
+                {
+                    'id': self.thread.pk,
+                    'title': self.thread.title,
+                    'errors': ["You don't have permission to merge this thread with others."],
+                },
+            ]
+        )
 
     def test_merge_too_many_threads(self):
         """api rejects too many threads to merge"""
         threads = []
-        for i in range(MERGE_LIMIT + 1):
+        for _ in range(MERGE_LIMIT + 1):
             threads.append(testutils.post_thread(category=self.category).pk)
 
         self.override_acl({
@@ -149,15 +191,21 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             'can_reply_threads': False,
         })
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': threads
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'threads': threads,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 403)
 
         response_json = response.json()
-        self.assertEqual(response_json, {
-            'detail': "No more than %s threads can be merged at single time." % MERGE_LIMIT
-        })
+        self.assertEqual(
+            response_json, {
+                'detail': "No more than %s threads can be merged at single time." % MERGE_LIMIT,
+            }
+        )
 
     def test_merge_no_final_thread(self):
         """api rejects merge because no data to merge threads was specified"""
@@ -170,16 +218,22 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, thread.id]
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'threads': [self.thread.id, thread.id],
+            }),
+            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.'],
-        })
+        self.assertEqual(
+            response_json, {
+                'title': ['This field is required.'],
+                'category': ['This field is required.'],
+            }
+        )
 
     def test_merge_invalid_final_title(self):
         """api rejects merge because final thread title was invalid"""
@@ -192,17 +246,23 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, thread.id],
-            'title': '$$$',
-            'category': self.category.id,
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'threads': [self.thread.id, thread.id],
+                '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)."]
-        })
+        self.assertEqual(
+            response_json, {
+                'title': ["Thread title should be at least 5 characters long (it has 3)."],
+            }
+        )
 
     def test_merge_invalid_category(self):
         """api rejects merge because final category was invalid"""
@@ -215,17 +275,23 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, thread.id],
-            'title': 'Valid thread title',
-            'category': self.category_b.id,
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'threads': [self.thread.id, thread.id],
+                'title': 'Valid thread title',
+                'category': self.category_b.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."]
-        })
+        self.assertEqual(
+            response_json, {
+                'category': ["Requested category could not be found."],
+            }
+        )
 
     def test_merge_unallowed_start_thread(self):
         """api rejects merge because category isn't allowing starting threads"""
@@ -234,24 +300,28 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             'can_close_threads': False,
             'can_edit_threads': False,
             'can_reply_threads': False,
-            'can_start_threads': 0
+            'can_start_threads': 0,
         })
 
         thread = testutils.post_thread(category=self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, thread.id],
-            'title': 'Valid thread title',
-            'category': self.category.id
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            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)
 
         response_json = response.json()
-        self.assertEqual(response_json, {
-            'category': [
-                "You can't create new threads in selected category."
-            ]
-        })
+        self.assertEqual(
+            response_json, {
+                'category': ["You can't create new threads in selected category."],
+            }
+        )
 
     def test_merge_invalid_weight(self):
         """api rejects merge because final weight was invalid"""
@@ -264,18 +334,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        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,
-        }), content_type="application/json")
+        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,
+            }),
+            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."]
-        })
+        self.assertEqual(
+            response_json, {
+                'weight': ["Ensure this value is less than or equal to 2."],
+            }
+        )
 
     def test_merge_unallowed_global_weight(self):
         """api rejects merge because global weight was unallowed"""
@@ -288,20 +364,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        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,
-        }), content_type="application/json")
+        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,
+            }),
+            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."
-            ]
-        })
+        self.assertEqual(
+            response_json, {
+                'weight': ["You don't have permission to pin threads globally in this category."],
+            }
+        )
 
     def test_merge_unallowed_local_weight(self):
         """api rejects merge because local weight was unallowed"""
@@ -314,20 +394,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        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,
-        }), content_type="application/json")
+        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,
+            }),
+            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."
-            ]
-        })
+        self.assertEqual(
+            response_json, {
+                'weight': ["You don't have permission to pin threads in this category."],
+            }
+        )
 
     def test_merge_allowed_local_weight(self):
         """api allows local weight"""
@@ -341,18 +425,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, thread.id],
-            'title': '$$$',
-            'category': self.category.id,
-            'weight': 1,
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            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)
 
         response_json = response.json()
-        self.assertEqual(response_json, {
-            'title': ["Thread title should be at least 5 characters long (it has 3)."]
-        })
+        self.assertEqual(
+            response_json, {
+                'title': ["Thread title should be at least 5 characters long (it has 3)."],
+            }
+        )
 
     def test_merge_allowed_global_weight(self):
         """api allows global weight"""
@@ -366,18 +456,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, thread.id],
-            'title': '$$$',
-            'category': self.category.id,
-            'weight': 2,
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            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)
 
         response_json = response.json()
-        self.assertEqual(response_json, {
-            'title': ["Thread title should be at least 5 characters long (it has 3)."]
-        })
+        self.assertEqual(
+            response_json, {
+                'title': ["Thread title should be at least 5 characters long (it has 3)."],
+            }
+        )
 
     def test_merge_unallowed_close(self):
         """api rejects merge because closing thread was unallowed"""
@@ -390,20 +486,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        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,
-        }), content_type="application/json")
+        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,
+            }),
+            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."
-            ]
-        })
+        self.assertEqual(
+            response_json, {
+                'is_closed': ["You don't have permission to close threads in this category."],
+            }
+        )
 
     def test_merge_with_close(self):
         """api allows for closing thread"""
@@ -416,19 +516,25 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        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,
-        }), content_type="application/json")
+        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,
+            }),
+            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)."]
-        })
+        self.assertEqual(
+            response_json, {
+                'title': ["Thread title should be at least 5 characters long (it has 3)."],
+            }
+        )
 
     def test_merge_unallowed_hidden(self):
         """api rejects merge because hidden thread was unallowed"""
@@ -442,20 +548,24 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        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,
-        }), content_type="application/json")
+        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,
+            }),
+            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."
-            ]
-        })
+        self.assertEqual(
+            response_json, {
+                'is_hidden': ["You don't have permission to hide threads in this category."],
+            }
+        )
 
     def test_merge_with_hide(self):
         """api allows for hiding thread"""
@@ -469,19 +579,25 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        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,
-        }), content_type="application/json")
+        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,
+            }),
+            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)."]
-        })
+        self.assertEqual(
+            response_json, {
+                'title': ["Thread title should be at least 5 characters long (it has 3)."],
+            }
+        )
 
     def test_merge(self):
         """api performs basic merge"""
@@ -496,11 +612,15 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, thread.id],
-            'title': 'Merged thread!',
-            'category': self.category.id,
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            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)
 
         # is response json with new thread?
@@ -531,19 +651,23 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
             'can_merge_threads': True,
             'can_close_threads': True,
             'can_hide_threads': 1,
-            'can_pin_threads': 2
+            'can_pin_threads': 2,
         })
 
         thread = testutils.post_thread(category=self.category)
 
-        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
-        }), content_type="application/json")
+        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,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)
 
         # is response json with new thread?
@@ -583,12 +707,16 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
         thread = testutils.post_thread(category=self.category)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'top_category': self.root.id,
-            'threads': [self.thread.id, thread.id],
-            'title': 'Merged thread!',
-            'category': self.category.id,
-        }), content_type="application/json")
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'top_category': self.root.id,
+                'threads': [self.thread.id, thread.id],
+                'title': 'Merged thread!',
+                'category': self.category.id,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)
 
         # is response json with new thread?
@@ -613,18 +741,20 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
     def test_merge_threads_kept_poll(self):
         """api merges two threads successfully, keeping poll from old thread"""
-        self.override_acl({
-            'can_merge_threads': True,
-        })
+        self.override_acl({'can_merge_threads': True})
 
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(other_thread, self.user)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, other_thread.id],
-            'title': 'Merged thread!',
-            'category': self.category.id,
-        }), content_type="application/json")
+        response = self.client.post(
+            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, 200)
 
         response_json = response.json()
@@ -639,18 +769,20 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
     def test_merge_threads_moved_poll(self):
         """api merges two threads successfully, moving poll from other thread"""
-        self.override_acl({
-            'can_merge_threads': True,
-        })
+        self.override_acl({'can_merge_threads': True})
 
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, other_thread.id],
-            'title': 'Merged thread!',
-            'category': self.category.id,
-        }), content_type="application/json")
+        response = self.client.post(
+            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, 200)
 
         response_json = response.json()
@@ -665,28 +797,32 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
     def test_threads_merge_conflict(self):
         """api errors on merge conflict, returning list of available polls"""
-        self.override_acl({
-            'can_merge_threads': True,
-        })
+        self.override_acl({'can_merge_threads': True})
 
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, other_thread.id],
-            'title': 'Merged thread!',
-            'category': self.category.id,
-        }), content_type="application/json")
+        response = self.client.post(
+            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(), {
-            'polls': [
-                [0, "Delete all polls"],
-                [poll.pk, poll.question],
-                [other_poll.pk, other_poll.question]
-            ]
-        })
+        self.assertEqual(
+            response.json(), {
+                'polls': [
+                    [0, "Delete all polls"],
+                    [poll.pk, poll.question],
+                    [other_poll.pk, other_poll.question],
+                ],
+            }
+        )
 
         # polls and votes were untouched
         self.assertEqual(Poll.objects.count(), 2)
@@ -694,24 +830,27 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
     def test_threads_merge_conflict_invalid_resolution(self):
         """api errors on invalid merge conflict resolution"""
-        self.override_acl({
-            'can_merge_threads': True,
-        })
+        self.override_acl({'can_merge_threads': True})
 
         other_thread = testutils.post_thread(self.category)
-        poll = testutils.post_poll(self.thread, self.user)
-        other_poll = testutils.post_poll(other_thread, self.user)
 
-        response = self.client.post(self.api_link, json.dumps({
-            'threads': [self.thread.id, other_thread.id],
-            'title': 'Merged thread!',
-            'category': self.category.id,
-            'poll': 'dsa7dsadsa9789'
-        }), content_type="application/json")
+        testutils.post_poll(self.thread, self.user)
+        testutils.post_poll(other_thread, self.user)
+
+        response = self.client.post(
+            self.api_link,
+            json.dumps({
+                'threads': [self.thread.id, other_thread.id],
+                'title': 'Merged thread!',
+                'category': self.category.id,
+                'poll': 'dsa7dsadsa9789',
+            }),
+            content_type="application/json",
+        )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
-            'detail': "Invalid choice."
+            'detail': "Invalid choice.",
         })
 
         # polls and votes were untouched
@@ -720,20 +859,23 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
     def test_threads_merge_conflict_delete_all(self):
         """api deletes all polls when delete all choice is selected"""
-        self.override_acl({
-            'can_merge_threads': True,
-        })
+        self.override_acl({'can_merge_threads': True})
 
         other_thread = testutils.post_thread(self.category)
-        poll = testutils.post_poll(self.thread, self.user)
-        other_poll = testutils.post_poll(other_thread, self.user)
 
-        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
-        }), content_type="application/json")
+        testutils.post_poll(self.thread, self.user)
+        testutils.post_poll(other_thread, self.user)
+
+        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,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)
 
         # polls and votes are gone
@@ -742,20 +884,22 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
     def test_threads_merge_conflict_keep_first_poll(self):
         """api deletes other poll on merge"""
-        self.override_acl({
-            'can_merge_threads': True,
-        })
+        self.override_acl({'can_merge_threads': True})
 
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
 
-        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
-        }), content_type="application/json")
+        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,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)
 
         # other poll and its votes are gone
@@ -768,20 +912,22 @@ class ThreadsMergeApiTests(ThreadsApiTestCase):
 
     def test_threads_merge_conflict_keep_other_poll(self):
         """api deletes first poll on merge"""
-        self.override_acl({
-            'can_merge_threads': True,
-        })
+        self.override_acl({'can_merge_threads': True})
 
         other_thread = testutils.post_thread(self.category)
         poll = testutils.post_poll(self.thread, self.user)
         other_poll = testutils.post_poll(other_thread, self.user)
 
-        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
-        }), content_type="application/json")
+        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,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)
 
         # other poll and its votes are gone

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

@@ -1,6 +1,6 @@
 from misago.categories.models import Category
 from misago.threads import moderation, testutils
-from misago.threads.models import Post, Thread
+from misago.threads.models import Thread
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
@@ -26,7 +26,9 @@ 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!"))
+        self.assertTrue(
+            moderation.change_thread_title(self.request, self.thread, "New title is here!")
+        )
 
         self.reload_thread()
         self.assertEqual(self.thread.title, "New title is here!")
@@ -73,9 +75,7 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         self.assertEqual(event.event_type, 'pinned_locally')
 
     def test_pin_invalid_thread(self):
-        """
-        pin_thread_locally returns false for already locally pinned thread
-        """
+        """pin_thread_locally returns false for already locally pinned thread"""
         self.thread.weight = 1
 
         self.assertFalse(moderation.pin_thread_locally(self.request, self.thread))
@@ -139,12 +139,15 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
         Category(
             name='New Category',
             slug='new-category',
-        ).insert_at(root_category, position='last-child', save=True)
+        ).insert_at(
+            root_category,
+            position='last-child',
+            save=True,
+        )
         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))
+        self.assertTrue(moderation.move_thread(self.request, self.thread, new_category))
 
         self.reload_thread()
         self.assertEqual(self.thread.category, new_category)
@@ -157,8 +160,7 @@ class ThreadsModerationTests(AuthenticatedUserTestCase):
     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)

+ 257 - 274
misago/threads/tests/test_threadslists.py

@@ -1,40 +1,23 @@
 from datetime import timedelta
-from json import loads as json_loads
 
 from django.urls import reverse
 from django.utils import timezone
 from django.utils.encoding import smart_str
-from django.utils.six.moves import range
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
 from misago.conf import settings
-from misago.core import threadstore
-from misago.core.cache import cache
-from misago.readtracker import categoriestracker, threadstracker
+from misago.readtracker import threadstracker
 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/', )
 
 
 class ThreadsListTestCase(AuthenticatedUserTestCase):
     def setUp(self):
-        super(ThreadsListTestCase, self).setUp()
-
-        self.api_link = reverse('misago:api:thread-list')
-
-        self.root = Category.objects.root_category()
-        self.first_category = Category.objects.get(slug='first-category')
-
         """
         Create categories tree for test cases:
 
@@ -48,16 +31,31 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
         Category E
           + Subcategory F
         """
+        super(ThreadsListTestCase, self).setUp()
+
+        self.api_link = reverse('misago:api:thread-list')
+
+        self.root = Category.objects.root_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)
+        ).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)
+        ).insert_at(
+            self.root,
+            position='last-child',
+            save=True,
+        )
 
         self.root = Category.objects.root_category()
 
@@ -67,7 +65,11 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
             name='Category B',
             slug='category-b',
             css_class='showing-category-b',
-        ).insert_at(self.category_a, position='last-child', save=True)
+        ).insert_at(
+            self.category_a,
+            position='last-child',
+            save=True,
+        )
 
         self.category_b = Category.objects.get(slug='category-b')
 
@@ -75,12 +77,20 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
             name='Category C',
             slug='category-c',
             css_class='showing-category-c',
-        ).insert_at(self.category_b, position='last-child', save=True)
+        ).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)
+        ).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')
@@ -90,7 +100,11 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
             name='Category F',
             slug='category-f',
             css_class='showing-category-f',
-        ).insert_at(self.category_e, position='last-child', save=True)
+        ).insert_at(
+            self.category_e,
+            position='last-child',
+            save=True,
+        )
 
         self.category_f = Category.objects.get(slug='category-f')
 
@@ -115,7 +129,7 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
             'categories': {},
             'visible_categories': [],
             'browseable_categories': [],
-            'can_approve_content': []
+            'can_approve_content': [],
         }
 
         # copy first category's acl to other categories to make base for overrides
@@ -135,7 +149,7 @@ class ThreadsListTestCase(AuthenticatedUserTestCase):
                 'can_see_all_threads': 1,
                 'can_see_own_threads': 0,
                 'can_hide_threads': 0,
-                'can_approve_content': 0
+                'can_approve_content': 0,
             })
 
             if category_acl:
@@ -150,26 +164,17 @@ 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)
 
 
@@ -195,7 +200,7 @@ class AllThreadsListTests(ThreadsListTestCase):
             response = self.client.get('%s?list=%s' % (self.api_link, url.strip('/') or 'all'))
             self.assertEqual(response.status_code, 200)
 
-            response_json = json_loads(smart_str(response.content))
+            response_json = response.json()
             self.assertEqual(len(response_json['results']), 0)
 
     def test_list_authenticated_only_views(self):
@@ -215,11 +220,10 @@ class AllThreadsListTests(ThreadsListTestCase):
             self.access_all_categories()
 
             self.access_all_categories()
-            response = self.client.get('%s?category=%s&list=%s' % (
-                self.api_link,
-                self.category_b.pk,
-                url.strip('/') or 'all',
-            ))
+            response = self.client.get(
+                '%s?category=%s&list=%s' %
+                (self.api_link, self.category_b.pk, url.strip('/') or 'all', )
+            )
             self.assertEqual(response.status_code, 200)
 
         self.logout_user()
@@ -235,11 +239,10 @@ class AllThreadsListTests(ThreadsListTestCase):
             self.assertEqual(response.status_code, 403)
 
             self.access_all_categories()
-            response = self.client.get('%s?category=%s&list=%s' % (
-                self.api_link,
-                self.category_b.pk,
-                url.strip('/') or 'all',
-            ))
+            response = self.client.get(
+                '%s?category=%s&list=%s' %
+                (self.api_link, self.category_b.pk, url.strip('/') or 'all', )
+            )
             self.assertEqual(response.status_code, 403)
 
     def test_list_renders_categories_picker(self):
@@ -247,7 +250,11 @@ class AllThreadsListTests(ThreadsListTestCase):
         Category(
             name='Hidden Category',
             slug='hidden-category',
-        ).insert_at(self.root, position='last-child', save=True)
+        ).insert_at(
+            self.root,
+            position='last-child',
+            save=True,
+        )
         test_category = Category.objects.get(slug='hidden-category')
 
         testutils.post_thread(
@@ -257,28 +264,22 @@ class AllThreadsListTests(ThreadsListTestCase):
         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)
 
         self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertIn(self.category_a.pk, response_json['subcategories'])
         self.assertNotIn(self.category_b.pk, response_json['subcategories'])
 
@@ -288,24 +289,19 @@ class AllThreadsListTests(ThreadsListTestCase):
         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)
 
         self.access_all_categories()
         response = self.client.get('%s?category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
-        self.assertEqual(
-            response_json['subcategories'][0], self.category_b.pk)
+        response_json = response.json()
+        self.assertEqual(response_json['subcategories'][0], self.category_b.pk)
 
     def test_display_pinned_threads(self):
         """
@@ -322,9 +318,7 @@ class AllThreadsListTests(ThreadsListTestCase):
             is_pinned=True,
         )
 
-        standard = testutils.post_thread(
-            category=self.first_category
-        )
+        standard = testutils.post_thread(category=self.first_category)
 
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
@@ -373,21 +367,21 @@ class AllThreadsListTests(ThreadsListTestCase):
 
     def test_noscript_pagination(self):
         """threads list is paginated for users with js disabled"""
+        threads_per_page = settings.MISAGO_THREADS_PER_PAGE
+
         threads = []
-        for i in range(settings.MISAGO_THREADS_PER_PAGE * 3):
-            threads.append(testutils.post_thread(
-                category=self.first_category
-            ))
+        for _ in range(settings.MISAGO_THREADS_PER_PAGE * 3):
+            threads.append(testutils.post_thread(category=self.first_category))
 
         # secondary page renders
         response = self.client.get('/?page=2')
         self.assertEqual(response.status_code, 200)
 
-        for thread in threads[:settings.MISAGO_THREADS_PER_PAGE]:
+        for thread in threads[:threads_per_page]:
             self.assertNotContains(response, thread.get_absolute_url())
-        for thread in threads[settings.MISAGO_THREADS_PER_PAGE:settings.MISAGO_THREADS_PER_PAGE * 2]:
+        for thread in threads[threads_per_page:threads_per_page * 2]:
             self.assertContains(response, thread.get_absolute_url())
-        for thread in threads[settings.MISAGO_THREADS_PER_PAGE * 2:]:
+        for thread in threads[threads_per_page * 2:]:
             self.assertNotContains(response, thread.get_absolute_url())
 
         self.assertNotContains(response, '/?page=1')
@@ -397,9 +391,9 @@ class AllThreadsListTests(ThreadsListTestCase):
         response = self.client.get('/?page=3')
         self.assertEqual(response.status_code, 200)
 
-        for thread in threads[settings.MISAGO_THREADS_PER_PAGE:]:
+        for thread in threads[threads_per_page:]:
             self.assertNotContains(response, thread.get_absolute_url())
-        for thread in threads[:settings.MISAGO_THREADS_PER_PAGE]:
+        for thread in threads[:threads_per_page]:
             self.assertContains(response, thread.get_absolute_url())
 
         self.assertContains(response, '/?page=2')
@@ -416,7 +410,11 @@ class CategoryThreadsListTests(ThreadsListTestCase):
         Category(
             name='Hidden Category',
             slug='hidden-category',
-        ).insert_at(self.root, position='last-child', save=True)
+        ).insert_at(
+            self.root,
+            position='last-child',
+            save=True,
+        )
         test_category = Category.objects.get(slug='hidden-category')
 
         for url in LISTS_URLS:
@@ -431,40 +429,46 @@ class CategoryThreadsListTests(ThreadsListTestCase):
         Category(
             name='Hidden Category',
             slug='hidden-category',
-        ).insert_at(self.root, position='last-child', save=True)
+        ).insert_at(
+            self.root,
+            position='last-child',
+            save=True,
+        )
         test_category = Category.objects.get(slug='hidden-category')
 
         for url in LISTS_URLS:
-            override_acl(self.user, {
-                'visible_categories': [test_category.pk],
-                'browseable_categories': [test_category.pk],
-                'categories': {
-                    test_category.pk: {
-                        'can_see': 1,
-                        'can_browse': 0,
-                    }
+            override_acl(
+                self.user, {
+                    'visible_categories': [test_category.pk],
+                    'browseable_categories': [test_category.pk],
+                    'categories': {
+                        test_category.pk: {
+                            'can_see': 1,
+                            'can_browse': 0,
+                        },
+                    },
                 }
-            });
+            )
 
             response = self.client.get(test_category.get_absolute_url() + url)
             self.assertEqual(response.status_code, 403)
 
-            override_acl(self.user, {
-                'visible_categories': [test_category.pk],
-                'browseable_categories': [test_category.pk],
-                'categories': {
-                    test_category.pk: {
-                        'can_see': 1,
-                        'can_browse': 0,
-                    }
+            override_acl(
+                self.user, {
+                    'visible_categories': [test_category.pk],
+                    'browseable_categories': [test_category.pk],
+                    'categories': {
+                        test_category.pk: {
+                            'can_see': 1,
+                            'can_browse': 0,
+                        },
+                    },
                 }
-            });
+            )
 
-            response = self.client.get('%s?category=%s&list=%s' % (
-                self.api_link,
-                test_category.pk,
-                url.strip('/'),
-            ))
+            response = self.client.get(
+                '%s?category=%s&list=%s' % (self.api_link, test_category.pk, url.strip('/'), )
+            )
             self.assertEqual(response.status_code, 403)
 
     def test_display_pinned_threads(self):
@@ -482,9 +486,7 @@ class CategoryThreadsListTests(ThreadsListTestCase):
             is_pinned=True,
         )
 
-        standard = testutils.post_thread(
-            category=self.first_category
-        )
+        standard = testutils.post_thread(category=self.first_category)
 
         response = self.client.get(self.first_category.get_absolute_url())
         self.assertEqual(response.status_code, 200)
@@ -544,21 +546,17 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
 
         self.assertContains(response, test_thread.get_absolute_url())
 
-        self.assertContains(response,
-            'subcategory-%s' % self.category_a.css_class)
-        self.assertContains(response,
-            'subcategory-%s' % self.category_e.css_class)
-        self.assertContains(response,
-            'thread-category-%s' % self.category_a.css_class)
-        self.assertContains(response,
-            'thread-category-%s' % self.category_c.css_class)
+        self.assertContains(response, 'subcategory-%s' % self.category_a.css_class)
+        self.assertContains(response, 'subcategory-%s' % self.category_e.css_class)
+        self.assertContains(response, 'thread-category-%s' % self.category_a.css_class)
+        self.assertContains(response, 'thread-category-%s' % self.category_c.css_class)
 
         # api displays same data
         self.access_all_categories()
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        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'])
@@ -571,17 +569,15 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         # thread displays
         self.assertContains(response, test_thread.get_absolute_url())
 
-        self.assertNotContains(response,
-            'thread-category-%s' % self.category_b.css_class)
-        self.assertContains(response,
-            'thread-category-%s' % self.category_c.css_class)
+        self.assertNotContains(response, 'thread-category-%s' % self.category_b.css_class)
+        self.assertContains(response, 'thread-category-%s' % self.category_c.css_class)
 
         # api displays same data
         self.access_all_categories()
         response = self.client.get('%s?category=%s' % (self.api_link, self.category_b.pk))
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        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)
@@ -591,33 +587,41 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         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_thread = testutils.post_thread(
-            category=test_category
+        ).insert_at(
+            self.root,
+            position='last-child',
+            save=True,
         )
 
+        test_category = Category.objects.get(slug='hidden-category')
+        test_thread = testutils.post_thread(category=test_category)
+
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "empty-message")
+        self.assertNotContains(response, test_thread.get_absolute_url())
 
     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)
+        ).insert_at(
+            self.root,
+            position='last-child',
+            save=True,
+        )
+
         test_category = Category.objects.get(slug='hidden-category')
 
-        test_thread = testutils.post_thread(
+        testutils.post_thread(
             category=test_category,
         )
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_user_see_own_unapproved_thread(self):
@@ -637,7 +641,7 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
     def test_list_user_cant_see_unapproved_thread(self):
@@ -656,7 +660,7 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_user_cant_see_hidden_thread(self):
@@ -675,7 +679,7 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_user_cant_see_own_hidden_thread(self):
@@ -695,7 +699,7 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_user_can_see_own_hidden_thread(self):
@@ -706,79 +710,63 @@ class ThreadsVisibilityTests(ThreadsListTestCase):
             is_hidden=True,
         )
 
-        self.access_all_categories({
-            'can_hide_threads': 1
-        })
+        self.access_all_categories({'can_hide_threads': 1})
 
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, test_thread.get_absolute_url())
 
         # test api
-        self.access_all_categories({
-            'can_hide_threads': 1
-        })
+        self.access_all_categories({'can_hide_threads': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
     def test_list_user_can_see_hidden_thread(self):
-        """
-        list shows hidden thread that belongs to other user due to permission
-        """
+        """list shows hidden thread that belongs to other user due to permission"""
         test_thread = testutils.post_thread(
             category=self.category_a,
             is_hidden=True,
         )
 
-        self.access_all_categories({
-            'can_hide_threads': 1
-        })
+        self.access_all_categories({'can_hide_threads': 1})
 
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, test_thread.get_absolute_url())
 
         # test api
-        self.access_all_categories({
-            'can_hide_threads': 1
-        })
+        self.access_all_categories({'can_hide_threads': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
     def test_list_user_can_see_unapproved_thread(self):
-        """
-        list shows hidden thread that belongs to other user due to permission
-        """
+        """list shows hidden thread that belongs to other user due to permission"""
         test_thread = testutils.post_thread(
             category=self.category_a,
             is_unapproved=True,
         )
 
-        self.access_all_categories({
-            'can_approve_content': 1
-        })
+        self.access_all_categories({'can_approve_content': 1})
 
         response = self.client.get('/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, test_thread.get_absolute_url())
 
         # test api
-        self.access_all_categories({
-            'can_approve_content': 1
-        })
+        self.access_all_categories({'can_approve_content': 1})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
 
@@ -802,13 +790,13 @@ class MyThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=my' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
         self.access_all_categories()
         response = self.client.get('%s?list=my&category=%s' % (self.api_link, self.category_a.pk))
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_renders_test_thread(self):
@@ -818,9 +806,7 @@ class MyThreadsListTests(ThreadsListTestCase):
             poster=self.user,
         )
 
-        other_thread = testutils.post_thread(
-            category=self.category_a,
-        )
+        other_thread = testutils.post_thread(category=self.category_a)
 
         self.access_all_categories()
 
@@ -841,7 +827,7 @@ class MyThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=my' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
@@ -849,7 +835,7 @@ class MyThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=my&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
@@ -874,20 +860,18 @@ class NewThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
         self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_renders_new_thread(self):
         """list renders new thread"""
-        test_thread = testutils.post_thread(
-            category=self.category_a,
-        )
+        test_thread = testutils.post_thread(category=self.category_a)
 
         self.access_all_categories()
 
@@ -906,7 +890,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
@@ -914,7 +898,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
@@ -925,11 +909,12 @@ class NewThreadsListTests(ThreadsListTestCase):
 
         test_thread = testutils.post_thread(
             category=self.category_a,
-            started_on=self.user.joined_on - timedelta(days=2)
+            started_on=self.user.joined_on - timedelta(days=2),
         )
 
-        testutils.reply_thread(test_thread,
-            posted_on=self.user.joined_on + timedelta(days=4)
+        testutils.reply_thread(
+            test_thread,
+            posted_on=self.user.joined_on + timedelta(days=4),
         )
 
         self.access_all_categories()
@@ -949,7 +934,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
@@ -957,7 +942,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
@@ -968,9 +953,7 @@ 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),
         )
 
         self.access_all_categories()
@@ -990,14 +973,14 @@ class NewThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
         self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_hides_user_cutoff_thread(self):
@@ -1007,7 +990,7 @@ class NewThreadsListTests(ThreadsListTestCase):
 
         test_thread = testutils.post_thread(
             category=self.category_a,
-            started_on=self.user.joined_on - timedelta(minutes=1)
+            started_on=self.user.joined_on - timedelta(minutes=1),
         )
 
         self.access_all_categories()
@@ -1027,14 +1010,14 @@ class NewThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
         self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_hides_user_read_thread(self):
@@ -1042,9 +1025,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
 
-        test_thread = testutils.post_thread(
-            category=self.category_a
-        )
+        test_thread = testutils.post_thread(category=self.category_a)
 
         threadstracker.make_thread_read_aware(self.user, test_thread)
         threadstracker.read_thread(self.user, test_thread, test_thread.last_post)
@@ -1066,14 +1047,14 @@ class NewThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
         self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_hides_category_read_thread(self):
@@ -1081,9 +1062,7 @@ class NewThreadsListTests(ThreadsListTestCase):
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
 
-        test_thread = testutils.post_thread(
-            category=self.category_a
-        )
+        test_thread = testutils.post_thread(category=self.category_a)
 
         self.user.categoryread_set.create(
             category=self.category_a,
@@ -1107,14 +1086,14 @@ class NewThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=new' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
         self.access_all_categories()
         response = self.client.get('%s?list=new&category=%s' % (self.api_link, self.category_a.pk))
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
 
@@ -1138,14 +1117,16 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
         self.access_all_categories()
-        response = self.client.get('%s?list=unread&category=%s' % (self.api_link, self.category_a.pk))
+        response = self.client.get(
+            '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
+        )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_renders_unread_thread(self):
@@ -1153,9 +1134,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
 
-        test_thread = testutils.post_thread(
-            category=self.category_a
-        )
+        test_thread = testutils.post_thread(category=self.category_a)
 
         threadstracker.make_thread_read_aware(self.user, test_thread)
         threadstracker.read_thread(self.user, test_thread, test_thread.last_post)
@@ -1179,15 +1158,17 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
         self.access_all_categories()
-        response = self.client.get('%s?list=unread&category=%s' % (self.api_link, self.category_a.pk))
+        response = self.client.get(
+            '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
+        )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 1)
         self.assertEqual(response_json['results'][0]['id'], test_thread.pk)
 
@@ -1196,9 +1177,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
 
-        test_thread = testutils.post_thread(
-            category=self.category_a
-        )
+        test_thread = testutils.post_thread(category=self.category_a)
 
         self.access_all_categories()
 
@@ -1217,14 +1196,16 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
         self.access_all_categories()
-        response = self.client.get('%s?list=unread&category=%s' % (self.api_link, self.category_a.pk))
+        response = self.client.get(
+            '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
+        )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_hides_read_thread(self):
@@ -1232,9 +1213,7 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         self.user.joined_on = timezone.now() - timedelta(days=5)
         self.user.save()
 
-        test_thread = testutils.post_thread(
-            category=self.category_a
-        )
+        test_thread = testutils.post_thread(category=self.category_a)
 
         threadstracker.make_thread_read_aware(self.user, test_thread)
         threadstracker.read_thread(self.user, test_thread, test_thread.last_post)
@@ -1256,14 +1235,16 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
         self.access_all_categories()
-        response = self.client.get('%s?list=unread&category=%s' % (self.api_link, self.category_a.pk))
+        response = self.client.get(
+            '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
+        )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_hides_global_cutoff_thread(self):
@@ -1273,9 +1254,7 @@ 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),
         )
 
         threadstracker.make_thread_read_aware(self.user, test_thread)
@@ -1300,14 +1279,16 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
         self.access_all_categories()
-        response = self.client.get('%s?list=unread&category=%s' % (self.api_link, self.category_a.pk))
+        response = self.client.get(
+            '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
+        )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_hides_user_cutoff_thread(self):
@@ -1317,13 +1298,16 @@ class UnreadThreadsListTests(ThreadsListTestCase):
 
         test_thread = testutils.post_thread(
             category=self.category_a,
-            started_on=self.user.joined_on - timedelta(days=2)
+            started_on=self.user.joined_on - timedelta(days=2),
         )
 
         threadstracker.make_thread_read_aware(self.user, test_thread)
         threadstracker.read_thread(self.user, test_thread, test_thread.last_post)
 
-        testutils.reply_thread(test_thread, posted_on=test_thread.started_on + timedelta(days=1))
+        testutils.reply_thread(
+            test_thread,
+            posted_on=test_thread.started_on + timedelta(days=1),
+        )
 
         self.access_all_categories()
 
@@ -1342,14 +1326,16 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
         self.access_all_categories()
-        response = self.client.get('%s?list=unread&category=%s' % (self.api_link, self.category_a.pk))
+        response = self.client.get(
+            '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
+        )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
     def test_list_hides_category_cutoff_thread(self):
@@ -1359,12 +1345,11 @@ class UnreadThreadsListTests(ThreadsListTestCase):
 
         test_thread = testutils.post_thread(
             category=self.category_a,
-            started_on=self.user.joined_on - timedelta(days=2)
+            started_on=self.user.joined_on - timedelta(days=2),
         )
 
         threadstracker.make_thread_read_aware(self.user, test_thread)
-        threadstracker.read_thread(
-            self.user, test_thread, test_thread.last_post)
+        threadstracker.read_thread(self.user, test_thread, test_thread.last_post)
 
         testutils.reply_thread(test_thread)
 
@@ -1390,23 +1375,23 @@ class UnreadThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=unread' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
         self.access_all_categories()
-        response = self.client.get('%s?list=unread&category=%s' % (self.api_link, self.category_a.pk))
+        response = self.client.get(
+            '%s?list=unread&category=%s' % (self.api_link, self.category_a.pk)
+        )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
 
 
 class SubscribedThreadsListTests(ThreadsListTestCase):
     def test_list_shows_subscribed_thread(self):
         """list shows subscribed thread"""
-        test_thread = testutils.post_thread(
-            category=self.category_a
-        )
+        test_thread = testutils.post_thread(category=self.category_a)
         self.user.subscription_set.create(
             thread=test_thread,
             category=self.category_a,
@@ -1430,23 +1415,23 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=subscribed' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 1)
         self.assertContains(response, test_thread.get_absolute_url())
 
         self.access_all_categories()
-        response = self.client.get('%s?list=subscribed&category=%s' % (self.api_link, self.category_a.pk))
+        response = self.client.get(
+            '%s?list=subscribed&category=%s' % (self.api_link, self.category_a.pk)
+        )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 1)
         self.assertContains(response, test_thread.get_absolute_url())
 
     def test_list_hides_unsubscribed_thread(self):
         """list shows subscribed thread"""
-        test_thread = testutils.post_thread(
-            category=self.category_a
-        )
+        test_thread = testutils.post_thread(category=self.category_a)
 
         self.access_all_categories()
 
@@ -1465,15 +1450,17 @@ class SubscribedThreadsListTests(ThreadsListTestCase):
         response = self.client.get('%s?list=subscribed' % self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertNotContains(response, test_thread.get_absolute_url())
 
         self.access_all_categories()
-        response = self.client.get('%s?list=subscribed&category=%s' % (self.api_link, self.category_a.pk))
+        response = self.client.get(
+            '%s?list=subscribed&category=%s' % (self.api_link, self.category_a.pk)
+        )
         self.assertEqual(response.status_code, 200)
 
-        response_json = json_loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(len(response_json['results']), 0)
         self.assertNotContains(response, test_thread.get_absolute_url())
 
@@ -1482,8 +1469,7 @@ 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/',
+            '/unapproved/', self.category_a.get_absolute_url() + 'unapproved/',
             '%s?list=unapproved' % self.api_link,
         )
 
@@ -1494,9 +1480,7 @@ class UnapprovedListTests(ThreadsListTestCase):
 
         # approval perm has no influence on visibility
         for test_url in TEST_URLS:
-            self.access_all_categories({
-                'can_approve_content': True
-            })
+            self.access_all_categories({'can_approve_content': True})
 
             self.access_all_categories()
             response = self.client.get(test_url)
@@ -1505,7 +1489,7 @@ class UnapprovedListTests(ThreadsListTestCase):
         # approval perm has no influence on visibility
         for test_url in TEST_URLS:
             self.access_all_categories(base_acl={
-                'can_see_unapproved_content_lists': True
+                'can_see_unapproved_content_lists': True,
             })
 
             self.access_all_categories()
@@ -1525,10 +1509,11 @@ class UnapprovedListTests(ThreadsListTestCase):
         )
 
         self.access_all_categories({
-            'can_approve_content': True
+            'can_approve_content': True,
         }, {
-            'can_see_unapproved_content_lists': True
+            'can_see_unapproved_content_lists': True,
         })
+
         response = self.client.get('/unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, visible_thread.get_absolute_url())
@@ -1537,8 +1522,9 @@ class UnapprovedListTests(ThreadsListTestCase):
         self.access_all_categories({
             'can_approve_content': True
         }, {
-            'can_see_unapproved_content_lists': True
+            'can_see_unapproved_content_lists': True,
         })
+
         response = self.client.get(self.category_a.get_absolute_url() + 'unapproved/')
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, visible_thread.get_absolute_url())
@@ -1548,17 +1534,16 @@ class UnapprovedListTests(ThreadsListTestCase):
         self.access_all_categories({
             'can_approve_content': True
         }, {
-            'can_see_unapproved_content_lists': True
+            'can_see_unapproved_content_lists': True,
         })
+
         response = self.client.get('%s?list=unapproved' % self.api_link)
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, visible_thread.get_absolute_url())
         self.assertNotContains(response, hidden_thread.get_absolute_url())
 
     def test_list_shows_owned_threads_for_unapproving_user(self):
-        """
-        list shows owned threads with unapproved posts for user without perm
-        """
+        """list shows owned threads with unapproved posts for user without perm"""
         visible_thread = testutils.post_thread(
             poster=self.user,
             category=self.category_b,
@@ -1571,7 +1556,7 @@ class UnapprovedListTests(ThreadsListTestCase):
         )
 
         self.access_all_categories(base_acl={
-            'can_see_unapproved_content_lists': True
+            'can_see_unapproved_content_lists': True,
         })
         response = self.client.get('/unapproved/')
         self.assertEqual(response.status_code, 200)
@@ -1579,7 +1564,7 @@ class UnapprovedListTests(ThreadsListTestCase):
         self.assertNotContains(response, hidden_thread.get_absolute_url())
 
         self.access_all_categories(base_acl={
-            'can_see_unapproved_content_lists': True
+            'can_see_unapproved_content_lists': True,
         })
         response = self.client.get(self.category_a.get_absolute_url() + 'unapproved/')
         self.assertEqual(response.status_code, 200)
@@ -1588,7 +1573,7 @@ class UnapprovedListTests(ThreadsListTestCase):
 
         # test api
         self.access_all_categories(base_acl={
-            'can_see_unapproved_content_lists': True
+            'can_see_unapproved_content_lists': True,
         })
         response = self.client.get('%s?list=unapproved' % self.api_link)
         self.assertEqual(response.status_code, 200)
@@ -1604,9 +1589,7 @@ class OwnerOnlyThreadsVisibilityTests(AuthenticatedUserTestCase):
 
     def override_acl(self, user):
         category_acl = user.acl_cache['categories'][self.category.pk].copy()
-        category_acl.update({
-            'can_see_all_threads': 0
-        })
+        category_acl.update({'can_see_all_threads': 0})
         user.acl_cache['categories'][self.category.pk] = category_acl
 
         override_acl(user, user.acl_cache)

+ 39 - 70
misago/threads/tests/test_threadview.py

@@ -3,7 +3,6 @@ from misago.categories.models import Category
 from misago.conf import settings
 from misago.threads import testutils
 from misago.threads.events import record_event
-from misago.threads.models import Post, Thread
 from misago.threads.moderation import threads as threads_moderation
 from misago.threads.moderation import hide_post
 from misago.users.testutils import AuthenticatedUserTestCase
@@ -44,8 +43,8 @@ class ThreadViewTestCase(AuthenticatedUserTestCase):
 
         override_acl(self.user, {
             'categories': {
-                self.category.pk: category_acl
-            }
+                self.category.pk: category_acl,
+            },
         })
 
 
@@ -56,10 +55,8 @@ class ThreadVisibilityTests(ThreadViewTestCase):
         self.assertContains(response, self.thread.title)
 
     def test_view_shows_owner_thread(self):
-        """view handles "owned threads only" """
-        self.override_acl({
-            'can_see_all_threads': 0
-        })
+        """view handles "owned threads" only"""
+        self.override_acl({'can_see_all_threads': 0})
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertEqual(response.status_code, 404)
@@ -67,34 +64,26 @@ class ThreadVisibilityTests(ThreadViewTestCase):
         self.thread.starter = self.user
         self.thread.save()
 
-        self.override_acl({
-            'can_see_all_threads': 0
-        })
+        self.override_acl({'can_see_all_threads': 0})
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, self.thread.title)
 
     def test_view_validates_category_permissions(self):
         """view validates category visiblity"""
-        self.override_acl({
-            'can_see': 0
-        })
+        self.override_acl({'can_see': 0})
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertEqual(response.status_code, 404)
 
-        self.override_acl({
-            'can_browse': 0
-        })
+        self.override_acl({'can_browse': 0})
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertEqual(response.status_code, 404)
 
     def test_view_shows_unapproved_thread(self):
         """view handles unapproved thread"""
-        self.override_acl({
-            'can_approve_content': 0
-        })
+        self.override_acl({'can_approve_content': 0})
 
         self.thread.is_unapproved = True
         self.thread.save()
@@ -103,9 +92,7 @@ class ThreadVisibilityTests(ThreadViewTestCase):
         self.assertEqual(response.status_code, 404)
 
         # grant permission to see unapproved content
-        self.override_acl({
-            'can_approve_content': 1
-        })
+        self.override_acl({'can_approve_content': 1})
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, self.thread.title)
@@ -115,18 +102,14 @@ class ThreadVisibilityTests(ThreadViewTestCase):
         self.thread.starter = self.user
         self.thread.save()
 
-        self.override_acl({
-            'can_approve_content': 0
-        })
+        self.override_acl({'can_approve_content': 0})
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, self.thread.title)
 
     def test_view_shows_hidden_thread(self):
         """view handles hidden thread"""
-        self.override_acl({
-            'can_hide_threads': 0
-        })
+        self.override_acl({'can_hide_threads': 0})
 
         self.thread.is_hidden = True
         self.thread.save()
@@ -142,9 +125,7 @@ class ThreadVisibilityTests(ThreadViewTestCase):
         self.assertEqual(response.status_code, 404)
 
         # grant permission to see hidden content
-        self.override_acl({
-            'can_hide_threads': 1
-        })
+        self.override_acl({'can_hide_threads': 1})
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, self.thread.title)
@@ -190,9 +171,7 @@ class ThreadPostsVisibilityTests(ThreadViewTestCase):
         self.assertNotContains(response, post.parsed)
 
         # permission to hide own posts isn't enought to see post content
-        self.override_acl({
-            'can_hide_own_posts': 1
-        })
+        self.override_acl({'can_hide_own_posts': 1})
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, post.get_absolute_url())
@@ -200,13 +179,13 @@ class ThreadPostsVisibilityTests(ThreadViewTestCase):
         self.assertNotContains(response, post.parsed)
 
         # post's content is displayed after permission to see posts is granted
-        self.override_acl({
-            'can_hide_posts': 1
-        })
+        self.override_acl({'can_hide_posts': 1})
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, post.get_absolute_url())
-        self.assertContains(response, "This post is hidden. Only users with permission may see its contents.")
+        self.assertContains(
+            response, "This post is hidden. Only users with permission may see its contents."
+        )
         self.assertNotContains(response, "This post is hidden. You cannot not see its contents.")
         self.assertContains(response, post.parsed)
 
@@ -219,9 +198,7 @@ class ThreadPostsVisibilityTests(ThreadViewTestCase):
         self.assertNotContains(response, post.get_absolute_url())
 
         # post displays because we have permission to approve unapproved content
-        self.override_acl({
-            'can_approve_content': 1
-        })
+        self.override_acl({'can_approve_content': 1})
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, post.get_absolute_url())
@@ -232,9 +209,7 @@ class ThreadPostsVisibilityTests(ThreadViewTestCase):
         post.poster = self.user
         post.save()
 
-        self.override_acl({
-            'can_approve_content': 0
-        })
+        self.override_acl({'can_approve_content': 0})
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertContains(response, post.get_absolute_url())
@@ -245,7 +220,7 @@ class ThreadPostsVisibilityTests(ThreadViewTestCase):
 class ThreadEventVisibilityTests(ThreadViewTestCase):
     def test_thread_events_render(self):
         """different thread events render"""
-        TEST_ACTIONS = (
+        TEST_ACTIONS = [
             (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."),
@@ -254,16 +229,13 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
             (threads_moderation.open_thread, "Thread has been opened."),
             (threads_moderation.hide_thread, "Thread has been made hidden."),
             (threads_moderation.unhide_thread, "Thread has been revealed."),
-        )
+        ]
 
         self.thread.is_unapproved = True
         self.thread.save()
 
         for action, message in TEST_ACTIONS:
-            self.override_acl({
-                'can_approve_content': 1,
-                'can_hide_threads': 1,
-            })
+            self.override_acl({'can_approve_content': 1, 'can_hide_threads': 1})
 
             self.thread.post_set.filter(is_event=True).delete()
             action(MockRequest(self.user), self.thread)
@@ -277,10 +249,7 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
 
             # hidden events don't render without permission
             hide_post(self.user, event)
-            self.override_acl({
-                'can_approve_content': 1,
-                'can_hide_threads': 1,
-            })
+            self.override_acl({'can_approve_content': 1, 'can_hide_threads': 1})
 
             response = self.client.get(self.thread.get_absolute_url())
             self.assertNotContains(response, event.get_absolute_url())
@@ -317,7 +286,7 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         events_limit = settings.MISAGO_EVENTS_PER_PAGE
         events = []
 
-        for i in range(events_limit + 5):
+        for _ in range(events_limit + 5):
             event = record_event(MockRequest(self.user), self.thread, 'closed')
             events.append(event)
 
@@ -335,12 +304,12 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
         events_limit = settings.MISAGO_EVENTS_PER_PAGE
         events = []
 
-        for i in range(events_limit + 5):
+        for _ in range(events_limit + 5):
             event = record_event(MockRequest(self.user), self.thread, 'closed')
             events.append(event)
 
         posts = []
-        for i in range(posts_limit - 1):
+        for _ in range(posts_limit - 1):
             post = testutils.reply_thread(self.thread)
             posts.append(post)
 
@@ -353,9 +322,9 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
             self.assertContains(response, post.get_absolute_url())
 
         # add second page to thread with more events
-        for i in range(posts_limit):
+        for _ in range(posts_limit):
             post = testutils.reply_thread(self.thread)
-        for i in range(events_limit):
+        for _ in range(events_limit):
             event = record_event(MockRequest(self.user), self.thread, 'closed')
             events.append(event)
 
@@ -376,7 +345,9 @@ class ThreadEventVisibilityTests(ThreadViewTestCase):
 
     def test_changed_thread_title_event_renders(self):
         """changed thread title event renders"""
-        threads_moderation.change_thread_title(MockRequest(self.user), self.thread, "Lorem renamed ipsum!")
+        threads_moderation.change_thread_title(
+            MockRequest(self.user), self.thread, "Lorem renamed ipsum!"
+        )
 
         event = self.thread.post_set.filter(is_event=True)[0]
         self.assertEqual(event.event_type, 'changed_title')
@@ -425,7 +396,7 @@ class ThreadAttachmentsViewTests(ThreadViewTestCase):
             'filetype': 'ZIP',
             'is_image': False,
             'uploaded_on': '2016-10-22T21:17:40.408710Z',
-            'uploader_name': 'BobBoberson'
+            'uploader_name': 'BobBoberson',
         }
 
         json.update(data)
@@ -440,7 +411,7 @@ class ThreadAttachmentsViewTests(ThreadViewTestCase):
                 'url': {
                     'index': '/attachment/loremipsum-123/',
                     'thumb': None,
-                    'uploader': '/user/bobboberson-123/'
+                    'uploader': '/user/bobboberson-123/',
                 },
                 'filename': 'Archiwum-1.zip',
             }),
@@ -448,19 +419,19 @@ class ThreadAttachmentsViewTests(ThreadViewTestCase):
                 'url': {
                     'index': '/attachment/loremipsum-223/',
                     'thumb': '/attachment/thumb/loremipsum-223/',
-                    'uploader': '/user/bobboberson-223/'
+                    'uploader': '/user/bobboberson-223/',
                 },
                 'is_image': True,
-                'filename': 'Archiwum-2.zip'
+                'filename': 'Archiwum-2.zip',
             }),
             self.mock_attachment_cache({
                 'url': {
                     'index': '/attachment/loremipsum-323/',
                     'thumb': None,
-                    'uploader': '/user/bobboberson-323/'
+                    'uploader': '/user/bobboberson-323/',
                 },
-                'filename': 'Archiwum-3.zip'
-            })
+                'filename': 'Archiwum-3.zip',
+            }),
         ]
         post.save()
 
@@ -522,9 +493,7 @@ class ThreadLikedPostsViewTests(ThreadViewTestCase):
         """
         testutils.like_post(self.thread.first_post, self.user)
 
-        self.override_acl({
-            'can_see_posts_likes': 0
-        })
+        self.override_acl({'can_see_posts_likes': 0})
 
         response = self.client.get(self.thread.get_absolute_url())
         self.assertNotContains(response, '"is_liked": true')

+ 15 - 4
misago/threads/tests/test_treesmap.py

@@ -58,7 +58,9 @@ class TreesMapTests(TestCase):
         tree_id = Category.objects.get(special_role='root_category').tree_id
 
         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")
+        self.assertEqual(
+            trees_map.trees[tree_id].root_name, 'root_category', "invalid tree was loaded"
+        )
         self.assertIn('root_category', trees_map.roots, "invalid root was loaded")
 
     def test_get_type_for_tree_id(self):
@@ -69,13 +71,19 @@ class TreesMapTests(TestCase):
         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")
+        self.assertEqual(
+            thread_type.root_name, 'root_category',
+            "returned invalid thread type for given tree id"
+        )
 
         try:
             trees_map.get_type_for_tree_id(tree_id + 1000)
             self.fail("invalid tree id should cause KeyError being raised")
         except KeyError as e:
-            self.assertIn("tree id has no type defined", six.text_type(e), "invalid exception message as given")
+            self.assertIn(
+                "tree id has no type defined",
+                six.text_type(e), "invalid exception message as given"
+            )
 
     def test_get_tree_id_for_root(self):
         """TreesMap().get_tree_id_for_root() returns tree id for valid type name"""
@@ -91,4 +99,7 @@ class TreesMapTests(TestCase):
             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', six.text_type(e), "invalid exception message as given")
+            self.assertIn(
+                '"hurr_durr" root has no tree defined',
+                six.text_type(e), "invalid exception message as given"
+            )

+ 58 - 32
misago/threads/tests/test_utils.py

@@ -6,10 +6,6 @@ from misago.threads.utils import add_categories_to_items, get_thread_id_from_url
 
 class AddCategoriesToItemsTests(MisagoTestCase):
     def setUp(self):
-        super(AddCategoriesToItemsTests, self).setUp()
-
-        self.root = Category.objects.root_category()
-
         """
         Create categories tree for test cases:
 
@@ -23,16 +19,29 @@ class AddCategoriesToItemsTests(MisagoTestCase):
         Category E
           + Subcategory F
         """
+
+        super(AddCategoriesToItemsTests, self).setUp()
+
+        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)
+        ).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)
+        ).insert_at(
+            self.root,
+            position='last-child',
+            save=True,
+        )
 
         self.root = Category.objects.root_category()
 
@@ -41,19 +50,31 @@ class AddCategoriesToItemsTests(MisagoTestCase):
             name='Category B',
             slug='category-b',
             css_class='showing-category-b',
-        ).insert_at(self.category_a, position='last-child', save=True)
+        ).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)
+        ).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)
+        ).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')
@@ -63,7 +84,11 @@ class AddCategoriesToItemsTests(MisagoTestCase):
             name='Category F',
             slug='category-f',
             css_class='showing-category-f',
-        ).insert_at(self.category_e, position='last-child', save=True)
+        ).insert_at(
+            self.category_e,
+            position='last-child',
+            save=True,
+        )
 
         self.clear_state()
 
@@ -77,8 +102,7 @@ class AddCategoriesToItemsTests(MisagoTestCase):
         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))
+        self.categories = list(Category.objects.all_categories(include_root=True))
 
     def test_root_thread_from_root(self):
         """thread in root category is handled"""
@@ -163,101 +187,103 @@ class MockRequest(object):
 class GetThreadIdFromUrlTests(MisagoTestCase):
     def test_get_thread_id_from_valid_urls(self):
         """get_thread_id_from_url extracts thread pk from valid urls"""
-        TEST_CASES = (
+        TEST_CASES = [
             {
                 # perfect match
                 'request': MockRequest('https', 'testforum.com', '/discuss/'),
                 'url': 'https://testforum.com/discuss/t/test-thread/123/',
-                'pk': 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
+                '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
+                '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
+                '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
+                '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
+                '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
+                'pk': 18,
             },
             {
                 # extract thread id from url that lacks scheme and hostname
                 'request': MockRequest('http', 'testforum.com', ''),
                 'url': '/t/test-thread/13/',
-                'pk': 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
+                '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
+                'pk': 13,
             }
-        )
+        ]
 
         for case in TEST_CASES:
             pk = get_thread_id_from_url(case['request'], case['url'])
             self.assertEqual(
-                pk, case['pk'], 'get_thread_id_from_url for {} should return {}'.format(case['url'], case['pk']))
+                pk, case['pk'],
+                'get_thread_id_from_url for {} should return {}'.format(case['url'], case['pk'])
+            )
 
     def test_get_thread_id_from_invalid_urls(self):
-        TEST_CASES = (
+        TEST_CASES = [
             {
                 # invalid wsgi alias
                 'request': MockRequest('https', 'testforum.com'),
-                'url': 'http://testforum.com/discuss/t/test-thread-123/'
+                'url': 'http://testforum.com/discuss/t/test-thread-123/',
             },
             {
                 # invalid hostname
                 'request': MockRequest('http', 'misago-project.org', '/discuss/'),
-                'url': 'https://testforum.com/discuss/t/test-thread-432/post/12321/'
+                '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/'
+                'url': 'https://testforum.com/thread/bobboberson-123/',
             },
             {
                 # dashed thread url
                 'request': MockRequest('http', 'testforum.com'),
-                'url': 'https://testforum.com/t/bobboberson-123/'
+                'url': 'https://testforum.com/t/bobboberson-123/',
             },
             {
                 # non-thread url
                 'request': MockRequest('http', 'testforum.com'),
-                'url': 'https://testforum.com/user/bobboberson-123/'
+                'url': 'https://testforum.com/user/bobboberson-123/',
             },
             {
                 # rubbish url
@@ -274,7 +300,7 @@ class GetThreadIdFromUrlTests(MisagoTestCase):
                 'request': MockRequest('http', 'testforum.com'),
                 'url': ''
             }
-        )
+        ]
 
         for case in TEST_CASES:
             pk = get_thread_id_from_url(case['request'], case['url'])

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

@@ -10,7 +10,7 @@ class ValidatePostTests(TestCase):
         """valid post passes validation"""
         validate_post("Lorem ipsum dolor met sit amet elit.")
 
-    def test_too_short_post(self):
+    def test_empty_post(self):
         """empty post is rejected"""
         with self.assertRaises(ValidationError):
             validate_post("")
@@ -31,11 +31,11 @@ class ValidatePostTests(TestCase):
 class ValidateTitleTests(TestCase):
     def test_valid_titles(self):
         """validate_title is ok with valid titles"""
-        VALID_TITLES = (
+        VALID_TITLES = [
             'Lorem ipsum dolor met',
             '123 456 789 112'
             'Ugabugagagagagaga',
-        )
+        ]
 
         for title in VALID_TITLES:
             validate_title(title)

+ 45 - 30
misago/threads/testutils.py

@@ -12,9 +12,17 @@ from .models import Poll, Post, Thread
 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):
+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
+):
     started_on = started_on or timezone.now()
 
     kwargs = {
@@ -25,7 +33,7 @@ def post_thread(category, title='Test thread', poster='Tester',
         'last_post_on': started_on,
         'is_unapproved': is_unapproved,
         'is_hidden': is_hidden,
-        'is_closed': is_closed
+        'is_closed': is_closed,
     }
 
     if is_global:
@@ -41,7 +49,7 @@ def post_thread(category, title='Test thread', poster='Tester',
             'last_poster': poster,
             'last_poster_name': poster.username,
             'last_poster_slug': poster.slug,
-            })
+        })
     except AttributeError:
         kwargs.update({
             'starter_name': poster,
@@ -62,9 +70,18 @@ def post_thread(category, title='Test thread', poster='Tester',
     return thread
 
 
-def reply_thread(thread, poster="Tester", message="I am test message",
-                 is_unapproved=False, is_hidden=False, is_event=False,
-                 has_reports=False, has_open_reports=False, posted_on=None, poster_ip='127.0.0.1'):
+def reply_thread(
+        thread,
+        poster="Tester",
+        message="I am test message",
+        is_unapproved=False,
+        is_hidden=False,
+        is_event=False,
+        has_reports=False,
+        has_open_reports=False,
+        posted_on=None,
+        poster_ip='127.0.0.1'
+):
     posted_on = posted_on or thread.last_post_on + timedelta(minutes=5)
 
     kwargs = {
@@ -84,7 +101,10 @@ def reply_thread(thread, poster="Tester", message="I am test message",
     }
 
     try:
-        kwargs.update({'poster': poster, 'poster_name': poster.username})
+        kwargs.update({
+            'poster': poster,
+            'poster_name': poster.username,
+        })
     except AttributeError:
         kwargs.update({'poster_name': poster})
 
@@ -130,7 +150,7 @@ def post_poll(thread, poster):
                 'hash': 'dddddddddddd',
                 'label': 'Delta',
                 'votes': 1
-            }
+            },
         ],
         allowed_choices=2,
         votes=4
@@ -140,8 +160,7 @@ def post_poll(thread, poster):
     try:
         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,
@@ -150,7 +169,7 @@ def post_poll(thread, poster):
         voter_name=user.username,
         voter_slug=user.slug,
         voter_ip='127.0.0.1',
-        choice_hash='aaaaaaaaaaaa'
+        choice_hash='aaaaaaaaaaaa',
     )
 
     # test user voted on third and last choices
@@ -161,7 +180,7 @@ def post_poll(thread, poster):
         voter_name=poster.username,
         voter_slug=poster.slug,
         voter_ip='127.0.0.1',
-        choice_hash='gggggggggggg'
+        choice_hash='gggggggggggg',
     )
     poll.pollvote_set.create(
         category=thread.category,
@@ -170,7 +189,7 @@ def post_poll(thread, poster):
         voter_name=poster.username,
         voter_slug=poster.slug,
         voter_ip='127.0.0.1',
-        choice_hash='dddddddddddd'
+        choice_hash='dddddddddddd',
     )
 
     # somebody else voted on third option before being deleted
@@ -180,7 +199,7 @@ def post_poll(thread, poster):
         voter_name='deleted',
         voter_slug='deleted',
         voter_ip='127.0.0.1',
-        choice_hash='gggggggggggg'
+        choice_hash='gggggggggggg',
     )
 
     return poll
@@ -197,30 +216,26 @@ def like_post(post, liker=None, username=None):
             liker=liker,
             liker_name=liker.username,
             liker_slug=liker.slug,
-            liker_ip='127.0.0.1'
+            liker_ip='127.0.0.1',
         )
 
-        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,
             thread=post.thread,
             liker_name=username,
             liker_slug=slugify(username),
-            liker_ip='127.0.0.1'
+            liker_ip='127.0.0.1',
         )
 
-        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()

+ 103 - 62
misago/threads/threadtypes/privatethread.py

@@ -16,98 +16,139 @@ class PrivateThread(ThreadType):
         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
-        })
+        return reverse(
+            'misago:private-thread',
+            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
-        })
+        return reverse(
+            'misago:private-thread-last',
+            kwargs={
+                'slug': category.last_thread_slug,
+                'pk': category.last_thread_id,
+            }
+        )
 
     def get_category_read_api_url(self, category):
         return reverse('misago:api:private-thread-read')
 
     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
-            })
+            return reverse(
+                '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
-            })
+            return reverse(
+                '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            'misago:api:private-thread-post-read',
+            kwargs={
+                'thread_pk': post.thread_id,
+                'pk': post.pk,
+            }
+        )

+ 142 - 92
misago/threads/threadtypes/thread.py

@@ -17,145 +17,195 @@ class Thread(ThreadType):
 
     def get_category_absolute_url(self, category):
         if category.level:
-            return reverse('misago:category', kwargs={
-                'pk': category.pk,
-                'slug': category.slug,
-            })
+            return reverse(
+                'misago:category', kwargs={
+                    'pk': category.pk,
+                    'slug': category.slug,
+                }
+            )
         else:
             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
-        })
+        return reverse(
+            'misago:thread',
+            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
-        })
+        return reverse(
+            'misago:thread-last',
+            kwargs={
+                'slug': category.last_thread_slug,
+                'pk': category.last_thread_id,
+            }
+        )
 
     def get_category_read_api_url(self, category):
-        return '{}?category={}'.format(
-            reverse('misago:api:thread-read'), category.pk)
+        return '{}?category={}'.format(reverse('misago:api:thread-read'), category.pk)
 
     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
-            })
+            return reverse(
+                'misago:thread', kwargs={
+                    'slug': thread.slug,
+                    'pk': thread.pk,
+                    'page': page,
+                }
+            )
         else:
-            return reverse('misago:thread', kwargs={
-                'slug': thread.slug,
-                'pk': thread.pk
-            })
+            return reverse(
+                '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            'misago:thread-new', 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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            '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
-        })
+        return reverse(
+            'misago:api:thread-post-read', kwargs={
+                'thread_pk': post.thread_id,
+                'pk': post.pk,
+            }
+        )

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

@@ -5,6 +5,7 @@ from misago.conf import settings
 
 class TreesMap(object):
     """Object that maps trees to strategies"""
+
     def __init__(self, types_modules):
         self.is_loaded = False
         self.types_modules = types_modules

+ 54 - 58
misago/threads/urls/__init__.py

@@ -5,19 +5,12 @@ from misago.conf import settings
 from misago.threads.views.attachment import attachment_server
 from misago.threads.views.goto import (
     ThreadGotoPostView, ThreadGotoLastView, ThreadGotoNewView, ThreadGotoUnapprovedView,
-    PrivateThreadGotoPostView, PrivateThreadGotoLastView, PrivateThreadGotoNewView)
-from misago.threads.views.list import ForumThreads, CategoryThreads, PrivateThreads
-from misago.threads.views.thread import Thread, PrivateThread
-
-
-LISTS_TYPES = (
-    'all',
-    'my',
-    'new',
-    'unread',
-    'subscribed',
-    'unapproved',
+    PrivateThreadGotoPostView, PrivateThreadGotoLastView, PrivateThreadGotoNewView
 )
+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', )
 
 
 def threads_list_patterns(prefix, view, patterns):
@@ -28,64 +21,63 @@ def threads_list_patterns(prefix, view, patterns):
         else:
             url_name = prefix
 
-        urls.append(url(
-            pattern,
-            view.as_view(),
-            name=url_name,
-            kwargs={'list_type': LISTS_TYPES[i]},
-        ))
+        urls.append(
+            url(
+                pattern,
+                view.as_view(),
+                name=url_name,
+                kwargs={'list_type': LISTS_TYPES[i]},
+            )
+        )
     return urls
 
 
 if settings.MISAGO_THREADS_ON_INDEX:
-    urlpatterns = threads_list_patterns('threads', ForumThreads, (
-        r'^$',
-        r'^my/$',
-        r'^new/$',
-        r'^unread/$',
-        r'^subscribed/$',
-        r'^unapproved/$',
-    ))
+    urlpatterns = threads_list_patterns(
+        'threads', ForumThreadsList,
+        (r'^$', r'^my/$', r'^new/$', r'^unread/$', r'^subscribed/$', r'^unapproved/$', )
+    )
 else:
-    urlpatterns = threads_list_patterns('threads', ForumThreads, (
-        r'^threads/$',
-        r'^threads/my/$',
-        r'^threads/new/$',
-        r'^threads/unread/$',
-        r'^threads/subscribed/$',
-        r'^threads/unapproved/$',
-    ))
-
-
-urlpatterns += threads_list_patterns('category', CategoryThreads, (
-    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', PrivateThreads, (
-    r'^private-threads/$',
-    r'^private-threads/my/$',
-    r'^private-threads/new/$',
-    r'^private-threads/unread/$',
-    r'^private-threads/subscribed/$',
-))
+    urlpatterns = threads_list_patterns(
+        '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/$',
+    )
+)
+
+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/$',
+    )
+)
 
 
 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], view.as_view(), 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', Thread)
-urlpatterns += thread_view_patterns('private-thread', PrivateThread)
+urlpatterns += thread_view_patterns('thread', ThreadView)
+urlpatterns += thread_view_patterns('private-thread', PrivateThreadView)
 
 
 def goto_patterns(prefix, **views):
@@ -120,8 +112,12 @@ urlpatterns += goto_patterns(
     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+)/', attachment_server, name='attachment-thumbnail', kwargs={'thumbnail': True}),
+    url(
+        r'^a/thumb/(?P<secret>[-a-zA-Z0-9]+)/(?P<pk>\d+)/',
+        attachment_server,
+        name='attachment-thumbnail',
+        kwargs={'thumbnail': True}
+    ),
 ]

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

@@ -10,10 +10,16 @@ router = MisagoApiRouter()
 router.register(r'attachments', AttachmentViewSet, base_name='attachment')
 
 router.register(r'threads', ThreadViewSet, base_name='thread')
-router.register(r'threads/(?P<thread_pk>[^/.]+)/posts', ThreadPostsViewSet, base_name='thread-post')
+router.register(
+    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'private-threads', PrivateThreadViewSet, base_name='private-thread')
-router.register(r'private-threads/(?P<thread_pk>[^/.]+)/posts', PrivateThreadPostsViewSet, base_name='private-thread-post')
+router.register(
+    r'private-threads/(?P<thread_pk>[^/.]+)/posts',
+    PrivateThreadPostsViewSet,
+    base_name='private-thread-post'
+)
 
 urlpatterns = router.urls

+ 4 - 7
misago/threads/utils.py

@@ -25,16 +25,14 @@ def add_categories_to_items(root_category, categories, items):
         elif root_category.has_child(item.category):
             # item in subcategory resolution
             for category in categories:
-                if (category.parent_id == root_category.pk and
-                        category.has_child(item.category)):
+                if (category.parent_id == root_category.pk and category.has_child(item.category)):
                     top_categories_map[item.category_id] = category
                     item.top_category = category
         else:
             # item from other category's scope
             for category in categories:
-                if category.level == 1 and (
-                        category == item.category or
-                        category.has_child(item.category)):
+                category_is_parent = category.has_child(item.category)
+                if category.level == 1 and (category == item.category or category_is_parent):
                     top_categories_map[item.category_id] = category
                     item.top_category = category
 
@@ -48,8 +46,7 @@ def add_likes_to_posts(user, posts):
         posts_map[post.id] = post
         post.is_liked = False
 
-    queryset = PostLike.objects.filter(
-        liker=user, post_id__in=posts_map.keys())
+    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

+ 32 - 20
misago/threads/validators.py

@@ -43,21 +43,27 @@ def validate_post(post):
         message = ungettext(
             "Posted message should be at least %(limit_value)s character long (it has %(show_value)s).",
             "Posted message should be at least %(limit_value)s characters long (it has %(show_value)s).",
-            settings.post_length_min)
-        raise ValidationError(message % {
-            'limit_value': settings.post_length_min,
-            'show_value': post_len
-        })
+            settings.post_length_min,
+        )
+        raise ValidationError(
+            message % {
+                'limit_value': settings.post_length_min,
+                'show_value': post_len,
+            }
+        )
 
     if settings.post_length_max and post_len > settings.post_length_max:
         message = ungettext(
             "Posted message cannot be longer than %(limit_value)s character (it has %(show_value)s).",
             "Posted message cannot be longer than %(limit_value)s characters (it has %(show_value)s).",
-            settings.post_length_max)
-        raise ValidationError(message % {
-            'limit_value': settings.post_length_max,
-            'show_value': post_len
-        })
+            settings.post_length_max,
+        )
+        raise ValidationError(
+            message % {
+                'limit_value': settings.post_length_max,
+                'show_value': post_len,
+            }
+        )
 
 
 def validate_title(title):
@@ -70,21 +76,27 @@ def validate_title(title):
         message = ungettext(
             "Thread title should be at least %(limit_value)s character long (it has %(show_value)s).",
             "Thread title should be at least %(limit_value)s characters long (it has %(show_value)s).",
-            settings.thread_title_length_min)
-        raise ValidationError(message % {
-            'limit_value': settings.thread_title_length_min,
-            'show_value': title_len
-        })
+            settings.thread_title_length_min,
+        )
+        raise ValidationError(
+            message % {
+                'limit_value': settings.thread_title_length_min,
+                'show_value': title_len,
+            }
+        )
 
     if title_len > settings.thread_title_length_max:
         message = ungettext(
             "Thread title cannot be longer than %(limit_value)s character (it has %(show_value)s).",
             "Thread title cannot be longer than %(limit_value)s characters (it has %(show_value)s).",
-            settings.thread_title_length_max)
-        raise ValidationError(message % {
-            'limit_value': settings.thread_title_length_max,
-            'show_value': title_len
-        })
+            settings.thread_title_length_max,
+        )
+        raise ValidationError(
+            message % {
+                'limit_value': settings.thread_title_length_max,
+                'show_value': title_len,
+            }
+        )
 
     error_not_sluggable = _("Thread title should contain alpha-numeric characters.")
     error_slug_too_long = _("Thread title is too long.")

+ 18 - 11
misago/threads/viewmodels/category.py

@@ -41,23 +41,19 @@ class ViewModel(BaseViewModel):
         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_cache['browseable_categories']
-            ).select_related('parent'))
+                id__in=request.user.acl_cache['browseable_categories'],
+            ).select_related('parent')
+        )
 
 
 class ThreadsCategory(ThreadsRootCategory):
@@ -91,5 +87,16 @@ class PrivateThreadsCategory(ViewModel):
 
 
 BasicCategorySerializer = CategorySerializer.subset_fields(
-    'id', 'parent', 'name', 'description', 'is_closed', 'css_class',
-    'absolute_url', 'api_url', 'level', 'lft', 'rght', 'is_read')
+    'id',
+    'parent',
+    'name',
+    'description',
+    'is_closed',
+    'css_class',
+    'absolute_url',
+    'api_url',
+    'level',
+    'lft',
+    'rght',
+    'is_read',
+)

+ 2 - 7
misago/threads/viewmodels/post.py

@@ -26,11 +26,7 @@ class ViewModel(BaseViewModel):
         if select_for_update:
             queryset = queryset.select_for_update()
         else:
-            queryset = queryset.select_related(
-                'poster',
-                'poster__rank',
-                'poster__ban_cache'
-            )
+            queryset = queryset.select_related('poster', 'poster__rank', 'poster__ban_cache')
 
         post = get_object_or_404(queryset, pk=pk)
 
@@ -40,8 +36,7 @@ class ViewModel(BaseViewModel):
         return post
 
     def get_queryset(self, request, thread):
-        return exclude_invisible_posts(
-            request.user, thread.category, thread.post_set)
+        return exclude_invisible_posts(request.user, thread.category, thread.post_set)
 
 
 class ThreadPost(ViewModel):

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

@@ -23,8 +23,9 @@ class ViewModel(object):
 
         posts_limit = settings.MISAGO_POSTS_PER_PAGE
         posts_orphans = settings.MISAGO_POSTS_TAIL
-        list_page = paginate(posts_queryset, page, posts_limit, posts_orphans,
-                             paginator=PostsPaginator)
+        list_page = paginate(
+            posts_queryset, page, posts_limit, posts_orphans, paginator=PostsPaginator
+        )
         paginator = pagination_dict(list_page)
 
         posts = list(list_page.object_list)
@@ -53,7 +54,8 @@ class ViewModel(object):
 
             events_limit = settings.MISAGO_EVENTS_PER_PAGE
             posts += self.get_events_queryset(
-                request, thread_model, events_limit, first_post, last_post)
+                request, thread_model, events_limit, first_post, last_post
+            )
 
             # sort both by pk
             posts.sort(key=lambda p: p.pk)
@@ -72,7 +74,7 @@ class ViewModel(object):
             'poster',
             'poster__rank',
             'poster__ban_cache',
-            'poster__online_tracker'
+            'poster__online_tracker',
         ).filter(is_event=False).order_by('id')
         return exclude_invisible_posts(request.user, thread.category, queryset)
 
@@ -99,7 +101,7 @@ class ViewModel(object):
     def get_template_context(self):
         return {
             'posts': self.posts,
-            'paginator': self.paginator
+            'paginator': self.paginator,
         }
 
 

+ 20 - 13
misago/threads/viewmodels/thread.py

@@ -18,20 +18,27 @@ from misago.threads.threadtypes import trees_map
 
 __all__ = ['ForumThread', 'PrivateThread']
 
-
-BASE_RELATIONS = (
+BASE_RELATIONS = [
     'category',
     'poll',
     'starter',
     'starter__rank',
     'starter__ban_cache',
-    'starter__online_tracker'
-)
+    'starter__online_tracker',
+]
 
 
 class ViewModel(BaseViewModel):
-    def __init__(self, request, pk, slug=None, read_aware=False,
-            subscription_aware=False, poll_votes_aware=False, select_for_update=False):
+    def __init__(
+            self,
+            request,
+            pk,
+            slug=None,
+            read_aware=False,
+            subscription_aware=False,
+            poll_votes_aware=False,
+            select_for_update=False
+    ):
         model = self.get_thread(request, pk, slug, select_for_update)
 
         model.path = self.get_thread_path(model.category)
@@ -60,16 +67,16 @@ class ViewModel(BaseViewModel):
         return self._poll
 
     def get_thread(self, request, pk, slug=None, select_for_update=False):
-        raise NotImplementedError('Thread view model has to implement get_thread(request, pk, slug=None)')
+        raise NotImplementedError(
+            'Thread view model has to implement get_thread(request, pk, slug=None)'
+        )
 
     def get_thread_path(self, category):
         thread_path = []
 
         if category.level:
             categories = Category.objects.filter(
-                tree_id=category.tree_id,
-                lft__lte=category.lft,
-                rght__gte=category.rght
+                tree_id=category.tree_id, lft__lte=category.lft, rght__gte=category.rght
             ).order_by('level')
             thread_path = list(categories)
         else:
@@ -89,7 +96,7 @@ class ViewModel(BaseViewModel):
             'thread': self._model,
             'poll': self._poll,
             'category': self._model.category,
-            'breadcrumbs': self._model.path
+            'breadcrumbs': self._model.path,
         }
 
 
@@ -103,7 +110,7 @@ class ForumThread(ViewModel):
         thread = get_object_or_404(
             queryset,
             pk=pk,
-            category__tree_id=trees_map.get_tree_id_for_root(THREADS_ROOT_NAME)
+            category__tree_id=trees_map.get_tree_id_for_root(THREADS_ROOT_NAME),
         )
 
         allow_see_thread(request.user, thread)
@@ -130,7 +137,7 @@ class PrivateThread(ViewModel):
         thread = get_object_or_404(
             queryset,
             pk=pk,
-            category__tree_id=trees_map.get_tree_id_for_root(PRIVATE_THREADS_ROOT_NAME)
+            category__tree_id=trees_map.get_tree_id_for_root(PRIVATE_THREADS_ROOT_NAME),
         )
 
         make_participants_aware(request.user, thread)

+ 23 - 24
misago/threads/viewmodels/threads.py

@@ -21,7 +21,6 @@ from misago.threads.utils import add_categories_to_items
 
 __all__ = ['ForumThreads', 'PrivateThreads', 'filter_read_threads_queryset']
 
-
 LISTS_NAMES = {
     'all': None,
     'my': ugettext_lazy("Your threads"),
@@ -49,15 +48,21 @@ class ViewModel(object):
         base_queryset = self.get_base_queryset(request, category.categories, list_type)
         threads_categories = [category_model] + category.subcategories
 
-        threads_queryset = self.get_remaining_threads_queryset(base_queryset, category_model, threads_categories)
+        threads_queryset = self.get_remaining_threads_queryset(
+            base_queryset, category_model, threads_categories
+        )
 
-        list_page = paginate(threads_queryset, page, settings.MISAGO_THREADS_PER_PAGE, settings.MISAGO_THREADS_TAIL)
+        list_page = paginate(
+            threads_queryset, page, settings.MISAGO_THREADS_PER_PAGE, settings.MISAGO_THREADS_TAIL
+        )
         paginator = pagination_dict(list_page)
 
         if list_page.number > 1:
             threads = list(list_page.object_list)
         else:
-            pinned_threads = list(self.get_pinned_threads(base_queryset, category_model, threads_categories))
+            pinned_threads = list(
+                self.get_pinned_threads(base_queryset, category_model, threads_categories)
+            )
             threads = list(pinned_threads) + list(list_page.object_list)
 
         if list_type in ('new', 'unread'):
@@ -90,13 +95,15 @@ class ViewModel(object):
             has_permission = request.user.acl_cache['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."))
+                    _("You don't have permission to see unapproved content lists.")
+                )
 
     def get_list_name(self, list_type):
         return LISTS_NAMES[list_type]
 
     def get_base_queryset(self, request, threads_categories, list_type):
-        return get_threads_queryset(request.user, threads_categories, list_type).order_by('-last_post_id')
+        return get_threads_queryset(request.user, threads_categories,
+                                    list_type).order_by('-last_post_id')
 
     def get_pinned_threads(self, queryset, category, threads_categories):
         return []
@@ -105,13 +112,13 @@ class ViewModel(object):
         return []
 
     def filter_threads(self, request, threads):
-        pass # hook for custom thread types to add features to extend threads
+        pass  # hook for custom thread types to add features to extend threads
 
     def get_frontend_context(self):
         context = {
             'THREADS': {
                 'results': ThreadsListSerializer(self.threads, many=True).data,
-                'subcategories': [c.pk for c in self.category.children]
+                'subcategories': [c.pk for c in self.category.children],
             },
         }
 
@@ -122,19 +129,16 @@ class ViewModel(object):
         return {
             'list_name': self.get_list_name(self.list_type),
             'list_type': self.list_type,
-
             'threads': self.threads,
-            'paginator': self.paginator
+            '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)
 
@@ -153,14 +157,14 @@ class ForumThreads(ViewModel):
 
 class PrivateThreads(ViewModel):
     def get_base_queryset(self, request, threads_categories, list_type):
-        queryset = super(PrivateThreads, self).get_base_queryset(request, threads_categories, list_type)
+        queryset = super(PrivateThreads,
+                         self).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')
 
         if request.user.acl_cache['can_moderate_private_threads']:
-            queryset = queryset.filter(
-                Q(id__in=participated_threads) | Q(has_reported_posts=True))
+            queryset = queryset.filter(Q(id__in=participated_threads) | Q(has_reported_posts=True))
         else:
             queryset = queryset.filter(id__in=participated_threads)
 
@@ -173,9 +177,6 @@ class PrivateThreads(ViewModel):
         make_participants_aware(request.user, threads)
 
 
-"""
-Thread queryset utils
-"""
 def get_threads_queryset(user, categories, list_type):
     queryset = exclude_invisible_threads(user, categories, Thread.objects)
 
@@ -214,9 +215,7 @@ def filter_read_threads_queryset(user, categories, list_type, queryset):
     if list_type == 'new':
         # new threads have no entry in reads table
         # AND were started after cutoff date
-        read_threads = user.threadread_set.filter(
-            category__in=categories
-        ).values('thread_id')
+        read_threads = user.threadread_set.filter(category__in=categories).values('thread_id')
 
         condition = Q(last_post_on__lte=cutoff_date)
         condition = condition | Q(id__in=read_threads)
@@ -235,7 +234,7 @@ def filter_read_threads_queryset(user, categories, list_type, queryset):
         read_threads = user.threadread_set.filter(
             category__in=categories,
             thread__last_post_on__gt=cutoff_date,
-            last_read_on__lt=F('thread__last_post_on')
+            last_read_on__lt=F('thread__last_post_on'),
         ).values('thread_id')
 
         queryset = queryset.filter(id__in=read_threads)

+ 6 - 9
misago/threads/views/admin/attachments.py

@@ -1,8 +1,5 @@
 from django.contrib import messages
 from django.db import transaction
-from django.db.models import Count
-from django.shortcuts import redirect
-from django.urls import reverse
 from django.utils.translation import ugettext_lazy as _
 
 from misago.admin.views import generic
@@ -23,14 +20,14 @@ class AttachmentAdmin(generic.AdminBaseMixin):
 
 class AttachmentsList(AttachmentAdmin, generic.ListView):
     items_per_page = 20
-    ordering = (
+    ordering = [
         ('-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')
     mass_actions = [
@@ -39,8 +36,8 @@ class AttachmentsList(AttachmentAdmin, generic.ListView):
             'name': _("Delete attachments"),
             'icon': 'fa fa-times-circle',
             'confirmation': _("Are you sure you want to delete selected attachments?"),
-            'is_atomic': False
-        }
+            'is_atomic': False,
+        },
     ]
 
     def get_search_form(self, request):
@@ -68,7 +65,7 @@ class AttachmentsList(AttachmentAdmin, generic.ListView):
 
     def delete_from_cache(self, post, attachments):
         if not post.attachments_cache:
-            return # admin action may be taken due to desynced state
+            return  # admin action may be taken due to desynced state
 
         clean_cache = []
         for a in post.attachments_cache:
@@ -89,7 +86,7 @@ class DeleteAttachment(AttachmentAdmin, generic.ButtonView):
 
     def delete_from_cache(self, attachment):
         if not attachment.post.attachments_cache:
-            return # admin action may be taken due to desynced state
+            return  # admin action may be taken due to desynced state
 
         clean_cache = []
         for a in attachment.post.attachments_cache:

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

@@ -1,7 +1,5 @@
 from django.contrib import messages
 from django.db.models import Count
-from django.shortcuts import redirect
-from django.urls import reverse
 from django.utils.translation import ugettext_lazy as _
 
 from misago.admin.views import generic
@@ -27,7 +25,7 @@ class AttachmentTypeAdmin(generic.AdminBaseMixin):
 
 
 class AttachmentTypesList(AttachmentTypeAdmin, generic.ListView):
-    ordering = (('name', None),)
+    ordering = (('name', None), )
 
     def get_queryset(self):
         queryset = super(AttachmentTypesList, self).get_queryset()
@@ -45,7 +43,9 @@ class EditAttachmentType(AttachmentTypeAdmin, generic.ModelFormView):
 class DeleteAttachmentType(AttachmentTypeAdmin, generic.ButtonView):
     def check_permissions(self, request, target):
         if target.attachment_set.exists():
-            message = _('Attachment type "%(name)s" has associated attachments and can\'t be deleted.')
+            message = _(
+                'Attachment type "%(name)s" has associated attachments and can\'t be deleted.'
+            )
             return message % {'name': target.name}
 
     def button_action(self, request, target):

+ 0 - 3
misago/threads/views/attachment.py

@@ -1,9 +1,6 @@
 from __future__ import unicode_literals
 
-import os
-
 from django.core.exceptions import PermissionDenied
-from django.db.models import F
 from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect
 

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

@@ -12,7 +12,7 @@ from misago.threads.viewmodels import ForumThread, PrivateThread
 
 class GotoView(View):
     thread = None
-    read_aware=False
+    read_aware = False
 
     def get(self, request, pk, slug, **kwargs):
         thread = self.get_thread(request, pk, slug).unwrap()
@@ -40,7 +40,7 @@ class GotoView(View):
 
         thread_len = posts_queryset.count()
         if thread_len <= settings.MISAGO_POSTS_PER_PAGE + settings.MISAGO_POSTS_TAIL:
-            return 1 # no chance for post to be on other page than only page
+            return 1  # no chance for post to be on other page than only page
 
         # compute total count of thread pages
         hits = max(1, thread_len - settings.MISAGO_POSTS_TAIL)
@@ -89,7 +89,9 @@ class ThreadGotoNewView(GotoView):
 
     def get_target_post(self, thread, posts_queryset, **kwargs):
         if thread.is_new:
-            return posts_queryset.filter(posted_on__gt=thread.last_read_on).order_by('id').first()
+            return posts_queryset.filter(
+                posted_on__gt=thread.last_read_on,
+            ).order_by('id').first()
         else:
             return posts_queryset.order_by('id').last()
 
@@ -100,10 +102,16 @@ class ThreadGotoUnapprovedView(GotoView):
     def test_permissions(self, request, thread):
         if not thread.acl['can_approve']:
             raise PermissionDenied(
-                _("You need permission to approve content to be able to go to first unapproved post."))
+                _(
+                    "You need permission to approve content to "
+                    "be able to go to first unapproved post."
+                )
+            )
 
     def get_target_post(self, 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:
@@ -130,6 +138,8 @@ class PrivateThreadGotoNewView(GotoView):
 
     def get_target_post(self, thread, posts_queryset, **kwargs):
         if thread.is_new:
-            return posts_queryset.filter(posted_on__gt=thread.last_read_on).order_by('id').first()
+            return posts_queryset.filter(
+                posted_on__gt=thread.last_read_on,
+            ).order_by('id').first()
         else:
             return posts_queryset.order_by('id').last()

+ 6 - 7
misago/threads/views/list.py

@@ -1,4 +1,3 @@
-from django.conf import settings
 from django.http import Http404
 from django.shortcuts import render
 from django.urls import reverse
@@ -9,7 +8,7 @@ from misago.threads.viewmodels import (
     ForumThreads, PrivateThreads, PrivateThreadsCategory, ThreadsCategory, ThreadsRootCategory)
 
 
-class ListBase(View):
+class ThreadsList(View):
     category = None
     threads = None
 
@@ -56,7 +55,7 @@ class ListBase(View):
         return {}
 
 
-class ForumThreads(ListBase):
+class ForumThreadsList(ThreadsList):
     category = ThreadsRootCategory
     threads = ForumThreads
 
@@ -68,19 +67,19 @@ class ForumThreads(ListBase):
         }
 
 
-class CategoryThreads(ForumThreads):
+class CategoryThreadsList(ForumThreadsList):
     category = ThreadsCategory
 
     template_name = 'misago/threadslist/category.html'
 
     def get_category(self, request, **kwargs):
-        category = super(CategoryThreads, self).get_category(request, **kwargs)
+        category = super(CategoryThreadsList, self).get_category(request, **kwargs)
         if not category.level:
-            raise Http404() # disallow root category access
+            raise Http404()  # disallow root category access
         return category
 
 
-class PrivateThreads(ListBase):
+class PrivateThreadsList(ThreadsList):
     category = PrivateThreadsCategory
     threads = PrivateThreads
 

+ 7 - 8
misago/threads/views/thread.py

@@ -23,10 +23,7 @@ class ThreadBase(View):
 
     def get_thread(self, request, pk, slug):
         return self.thread(
-            request, pk, slug,
-            read_aware=True,
-            subscription_aware=True,
-            poll_votes_aware=True
+            request, pk, slug, read_aware=True, subscription_aware=True, poll_votes_aware=True
         )
 
     def get_posts(self, request, thread, page):
@@ -47,7 +44,9 @@ class ThreadBase(View):
 
     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())
@@ -56,16 +55,16 @@ class ThreadBase(View):
         return context
 
 
-class Thread(ThreadBase):
+class ThreadView(ThreadBase):
     thread = ForumThread
     template_name = 'misago/thread/thread.html'
 
     def get_default_frontend_context(self):
         return {
-            'THREADS_API': reverse('misago:api:thread-list')
+            'THREADS_API': reverse('misago:api:thread-list'),
         }
 
 
-class PrivateThread(ThreadBase):
+class PrivateThreadView(ThreadBase):
     thread = PrivateThread
     template_name = 'misago/thread/private_thread.html'

+ 4 - 7
misago/urls.py

@@ -14,10 +14,10 @@ urlpatterns = [
     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'
-    )),
+    url(
+        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
@@ -26,7 +26,6 @@ urlpatterns = [
     url(r'^$', forum_index, name='index'),
 ]
 
-
 # Register API
 apipatterns = [
     url(r'^', include('misago.categories.urls.api')),
@@ -40,7 +39,6 @@ urlpatterns += [
     url(r'^api/', include(apipatterns, namespace='api')),
 ]
 
-
 # Register Misago ACP
 if settings.MISAGO_ADMIN_PATH:
     # Admin patterns recognised by Misago
@@ -53,7 +51,6 @@ if settings.MISAGO_ADMIN_PATH:
         url(admin_prefix, include(adminpatterns, namespace='admin')),
     ]
 
-
 # Make error pages accessible casually in DEBUG
 if settings.DEBUG:
     from misago.core import errorpages

+ 3 - 8
misago/users/activepostersranking.py

@@ -38,15 +38,10 @@ def build_active_posters_ranking():
         ranked_categories.append(category.pk)
 
     queryset = UserModel.objects.filter(
-        is_active=True,
-        posts__gt=0
+        is_active=True, posts__gt=0
     ).filter(
-        post__posted_on__gte=tracked_since,
-        post__category__in=ranked_categories
+        post__posted_on__gte=tracked_since, post__category__in=ranked_categories
     ).annotate(score=Count('post'))
 
     for ranking in queryset[:settings.MISAGO_RANKING_SIZE].iterator():
-        ActivityRanking.objects.create(
-            user=ranking,
-            score=ranking.score
-        )
+        ActivityRanking.objects.create(user=ranking, score=ranking.score)

+ 16 - 5
misago/users/admin.py

@@ -21,19 +21,29 @@ class MisagoAdminExtension(object):
 
         # Accounts
         urlpatterns.namespace(r'^accounts/', 'accounts', 'users')
-        urlpatterns.patterns('users:accounts',
+        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'),
-            url(r'^delete-threads/(?P<pk>\d+)/$', DeleteThreadsStep.as_view(), name='delete-threads'),
+            url(
+                r'^delete-threads/(?P<pk>\d+)/$',
+                DeleteThreadsStep.as_view(),
+                name='delete-threads'
+            ),
             url(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'),
+            url(
+                r'^delete-account/(?P<pk>\d+)/$',
+                DeleteAccountStep.as_view(),
+                name='delete-account'
+            ),
         )
 
         # Ranks
         urlpatterns.namespace(r'^ranks/', 'ranks', 'users')
-        urlpatterns.patterns('users:ranks',
+        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'),
@@ -46,7 +56,8 @@ class MisagoAdminExtension(object):
 
         # Bans
         urlpatterns.namespace(r'^bans/', 'bans', 'users')
-        urlpatterns.patterns('users:bans',
+        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'),

+ 78 - 64
misago/users/api/auth.py

@@ -29,28 +29,30 @@ def gateway(request):
         return session_user(request)
 
 
-"""
-POST /auth/ with CSRF, username and password
-will attempt to authenticate new user
-"""
 @api_view(['POST'])
-@permission_classes((UnbannedAnonOnly,))
+@permission_classes((UnbannedAnonOnly, ))
 @csrf_protect
 def login(request):
+    """
+    POST /auth/ with CSRF, username and password
+    will attempt to authenticate new user
+    """
     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,
+        )
 
 
-"""
-GET /auth/ will return current auth user, either User or AnonymousUser
-"""
 @api_view()
 def session_user(request):
+    """GET /auth/ will return current auth user, either User or AnonymousUser"""
     if request.user.is_authenticated:
         UserSerializer = AuthenticatedUserSerializer
     else:
@@ -59,11 +61,9 @@ def session_user(request):
     return Response(UserSerializer(request.user).data)
 
 
-"""
-GET /auth/criteria/ will return password and username criteria for accounts
-"""
 @api_view(['GET'])
 def get_criteria(request):
+    """GET /auth/criteria/ will return password and username criteria for accounts"""
     criteria = {
         'username': {
             'min_length': settings.username_length_min,
@@ -73,9 +73,7 @@ def get_criteria(request):
     }
 
     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', {}))
 
@@ -84,84 +82,96 @@ def get_criteria(request):
     return Response(criteria)
 
 
-"""
-POST /auth/send-activation/ with CSRF token and email
-will mail account activation link to requester
-"""
 @api_view(['POST'])
-@permission_classes((UnbannedAnonOnly,))
+@permission_classes((UnbannedAnonOnly, ))
 @csrf_protect
 def send_activation(request):
+    """
+    POST /auth/send-activation/ with CSRF token and email
+    will mail account activation link to requester
+    """
     form = ResendActivationForm(request.data)
     if form.is_valid():
         requesting_user = form.user_cache
 
-        mail_subject = _("Activate %(user)s account on %(forum_name)s forums")
-        subject_formats = {
+        mail_subject = _("Activate %(user)s account on %(forum_name)s forums") % {
             'user': requesting_user.username,
             'forum_name': settings.forum_name,
         }
-        mail_subject = mail_subject % subject_formats
 
-        mail_user(request, requesting_user, mail_subject,
-                  'misago/emails/activation/by_user',
-                  {'activation_token': make_activation_token(requesting_user)})
+        mail_user(
+            request,
+            requesting_user,
+            mail_subject,
+            'misago/emails/activation/by_user',
+            {
+                'activation_token': make_activation_token(requesting_user),
+            },
+        )
 
         return Response({
-                'username': form.user_cache.username,
-                'email': form.user_cache.email
-            })
+            'username': form.user_cache.username,
+            'email': form.user_cache.email,
+        })
     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,
+        )
 
 
-"""
-POST /auth/send-password-form/ with CSRF token and email
-will mail change password form link to requester
-"""
 @api_view(['POST'])
-@permission_classes((UnbannedOnly,))
+@permission_classes((UnbannedOnly, ))
 @csrf_protect
 def send_password_form(request):
+    """
+    POST /auth/send-password-form/ with CSRF token and email
+    will mail change password form link to requester
+    """
     form = ResetPasswordForm(request.data)
     if form.is_valid():
         requesting_user = form.user_cache
 
-        mail_subject = _("Change %(user)s password on %(forum_name)s forums")
-        subject_formats = {
+        mail_subject = _("Change %(user)s password on %(forum_name)s forums") % {
             'user': requesting_user.username,
             'forum_name': settings.forum_name,
         }
-        mail_subject = mail_subject % subject_formats
 
         confirmation_token = make_password_change_token(requesting_user)
 
-        mail_user(request, requesting_user, mail_subject,
-                  'misago/emails/change_password_form_link',
-                  {'confirmation_token': confirmation_token})
+        mail_user(
+            request,
+            requesting_user,
+            mail_subject,
+            'misago/emails/change_password_form_link',
+            {
+                'confirmation_token': confirmation_token,
+            },
+        )
 
         return Response({
-                'username': form.user_cache.username,
-                'email': form.user_cache.email
-            })
+            'username': form.user_cache.username,
+            'email': form.user_cache.email,
+        })
     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,
+        )
 
 
-"""
-POST /auth/change-password/user/token/ with CSRF and new password
-will change forgotten password
-"""
 class PasswordChangeFailed(Exception):
     pass
 
 
 @api_view(['POST'])
-@permission_classes((UnbannedOnly,))
+@permission_classes((UnbannedOnly, ))
 @csrf_protect
 def change_forgotten_password(request, pk, token):
+    """
+    POST /auth/change-password/user/token/ with CSRF and new password
+    will change forgotten password
+    """
     invalid_message = _("Form link is invalid. Please try again.")
     expired_message = _("Your link has expired. Please request new one.")
 
@@ -181,9 +191,12 @@ def change_forgotten_password(request, pk, token):
         if get_user_ban(user):
             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', '').strip()
@@ -191,10 +204,11 @@ def change_forgotten_password(request, pk, token):
         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({
-            'username': user.username
-        })
+        return Response(
+            {
+                'detail': e.messages[0],
+            },
+            status=status.HTTP_400_BAD_REQUEST,
+        )
+
+    return Response({'username': user.username})

+ 2 - 1
misago/users/api/rest_permissions.py

@@ -15,7 +15,8 @@ class UnbannedOnly(BasePermission):
             hydrated_ban = Ban(
                 check_type=Ban.IP,
                 user_message=ban['message'],
-                expires_on=ban['expires_on'])
+                expires_on=ban['expires_on'],
+            )
             raise Banned(hydrated_ban)
 
     def has_permission(self, request, view):

+ 38 - 30
misago/users/api/userendpoints/avatar.py

@@ -3,7 +3,7 @@ import json
 from rest_framework import status
 from rest_framework.response import Response
 
-from django.core.exceptions import PermissionDenied, ValidationError
+from django.core.exceptions import ValidationError
 from django.utils.translation import ugettext as _
 
 from misago.conf import settings
@@ -16,15 +16,17 @@ from misago.users.serializers import ModerateAvatarSerializer
 def avatar_endpoint(request, pk=None):
     if request.user.is_avatar_locked:
         if request.user.avatar_lock_user_message:
-            reason = format_plaintext_for_html(
-                request.user.avatar_lock_user_message)
+            reason = format_plaintext_for_html(request.user.avatar_lock_user_message)
         else:
             reason = None
 
-        return Response({
-            'detail': _("Your avatar is locked. You can't change it."),
-            'reason': reason
-        }, status=status.HTTP_403_FORBIDDEN)
+        return Response(
+            {
+                'detail': _("Your avatar is locked. You can't change it."),
+                'reason': reason,
+            },
+            status=status.HTTP_403_FORBIDDEN,
+        )
 
     avatar_options = get_avatar_options(request.user)
     if request.method == 'POST':
@@ -41,7 +43,7 @@ def get_avatar_options(user):
         'crop_src': False,
         'crop_tmp': False,
         'upload': False,
-        'galleries': False
+        'galleries': False,
     }
 
     # Allow existing galleries
@@ -52,11 +54,11 @@ def get_avatar_options(user):
             for image in gallery['images']:
                 gallery_images.append({
                     'id': image.id,
-                    'url': image.url
+                    'url': image.url,
                 })
             options['galleries'].append({
                 'name': gallery['name'],
-                'images': gallery_images
+                'images': gallery_images,
             })
 
     # Can't have custom avatar?
@@ -72,7 +74,7 @@ def get_avatar_options(user):
             options['crop_src'] = {
                 'url': user.avatar_src.url,
                 'crop': json.loads(user.avatar_crop),
-                'size': max(settings.MISAGO_AVATARS_SIZES)
+                'size': max(settings.MISAGO_AVATARS_SIZES),
             }
         except (TypeError, ValueError):
             pass
@@ -81,7 +83,7 @@ def get_avatar_options(user):
     if avatars.uploaded.has_temporary_avatar(user):
         options['crop_tmp'] = {
             'url': user.avatar_tmp.url,
-            'size': max(settings.MISAGO_AVATARS_SIZES)
+            'size': max(settings.MISAGO_AVATARS_SIZES),
         }
 
     # Allow upload conditions
@@ -102,22 +104,31 @@ def avatar_post(options, user, data):
     try:
         type_options = options[data.get('avatar', 'nope')]
         if not type_options:
-            return Response({
-                'detail': _("This avatar type is not allowed.")
-            }, status=status.HTTP_400_BAD_REQUEST)
+            return Response(
+                {
+                    'detail': _("This avatar type is not allowed."),
+                },
+                status=status.HTTP_400_BAD_REQUEST,
+            )
 
         rpc_handler = AVATAR_TYPES[data.get('avatar', 'nope')]
     except KeyError:
-        return Response({
-            'detail': _("Unknown avatar type.")
-        }, status=status.HTTP_400_BAD_REQUEST)
+        return Response(
+            {
+                'detail': _("Unknown avatar type."),
+            },
+            status=status.HTTP_400_BAD_REQUEST,
+        )
 
     try:
         response_dict = {'detail': rpc_handler(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()
 
@@ -125,9 +136,6 @@ def avatar_post(options, user, data):
     return Response(response_dict)
 
 
-"""
-Avatar rpc handlers
-"""
 def avatar_generate(user, data):
     avatars.dynamic.set_avatar(user)
     return _("New avatar based on your account was set.")
@@ -138,8 +146,7 @@ def avatar_gravatar(user, data):
         avatars.gravatar.set_avatar(user)
         return _("Gravatar was downloaded and set as new avatar.")
     except avatars.gravatar.NoGravatarAvailable:
-        raise AvatarError(
-            _("No Gravatar is associated with your e-mail address."))
+        raise AvatarError(_("No Gravatar is associated with your e-mail address."))
     except avatars.gravatar.GravatarError:
         raise AvatarError(_("Failed to connect to Gravatar servers."))
 
@@ -182,8 +189,7 @@ def avatar_crop_tmp(user, data):
 
 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])
@@ -194,7 +200,6 @@ AVATAR_TYPES = {
     'gravatar': avatar_gravatar,
     'galleries': avatar_gallery,
     'upload': avatar_upload,
-
     'crop_src': avatar_crop_src,
     'crop_tmp': avatar_crop_tmp,
 }
@@ -216,7 +221,10 @@ def moderate_avatar_endpoint(request, profile):
                 'avatar_lock_staff_message': profile.avatar_lock_staff_message,
             })
         else:
-            return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+            return Response(
+                serializer.errors,
+                status=status.HTTP_400_BAD_REQUEST,
+            )
     else:
         return Response({
             'is_avatar_locked': int(profile.is_avatar_locked),

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

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

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

@@ -10,25 +10,22 @@ from misago.users.serializers import ChangePasswordSerializer
 
 
 def change_password_endpoint(request, pk=None):
-    serializer = ChangePasswordSerializer(
-        data=request.data,
-        context={
-            'user': request.user
-        }
-    )
+    serializer = ChangePasswordSerializer(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': settings.forum_name}
 
-        mail_user(request, request.user, mail_subject,
-                  'misago/emails/change_password',
-                  {'token': token})
+        mail_user(
+            request, request.user, mail_subject, 'misago/emails/change_password', {'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)

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

@@ -10,7 +10,6 @@ from misago.conf import settings
 from misago.core.mail import mail_user
 from misago.users import captcha
 from misago.users.forms.register import RegisterForm
-from misago.users.serializers import AuthenticatedUserSerializer
 from misago.users.tokens import make_activation_token
 
 
@@ -34,13 +33,9 @@ def create_endpoint(request):
 
     activation_kwargs = {}
     if settings.account_activation == 'user':
-        activation_kwargs = {
-            'requires_activation': UserModel.ACTIVATION_USER
-        }
+        activation_kwargs = {'requires_activation': UserModel.ACTIVATION_USER}
     elif settings.account_activation == 'admin':
-        activation_kwargs = {
-            'requires_activation': UserModel.ACTIVATION_ADMIN
-        }
+        activation_kwargs = {'requires_activation': UserModel.ACTIVATION_ADMIN}
 
     new_user = UserModel.objects.create_user(
         form.cleaned_data['username'],
@@ -56,12 +51,11 @@ def create_endpoint(request):
 
     if settings.account_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)
 
-        mail_user(request, new_user, mail_subject,
-                  'misago/emails/register/complete')
+        mail_user(request, new_user, mail_subject, 'misago/emails/register/complete')
 
         return Response({
             'activation': 'active',
@@ -75,13 +69,12 @@ def create_endpoint(request):
         activation_by_user = new_user.requires_activation_by_user
 
         mail_user(
-            request, new_user, mail_subject,
-            'misago/emails/register/inactive',
-            {
+            request, new_user, mail_subject, 'misago/emails/register/inactive', {
                 'activation_token': activation_token,
                 'activation_by_admin': activation_by_admin,
                 'activation_by_user': activation_by_user,
-            })
+            }
+        )
 
         if activation_by_admin:
             activation_method = 'admin'

+ 2 - 10
misago/users/api/userendpoints/list.py

@@ -1,18 +1,10 @@
-from datetime import timedelta
-
 from rest_framework.response import Response
 
 from django.contrib.auth import get_user_model
-from django.db.models import Count
-from django.http import Http404
 from django.shortcuts import get_object_or_404
-from django.utils import timezone
 
-from misago.conf import settings
-from misago.core.cache import cache
-from misago.core.shortcuts import get_int_or_404, paginate
+from misago.core.shortcuts import get_int_or_404
 from misago.users.models import Rank
-from misago.users.online.utils import make_users_status_aware
 from misago.users.serializers import UserCardSerializer
 from misago.users.viewmodels import ActivePosters, RankUsers
 
@@ -31,7 +23,7 @@ def rank_users(request):
 
     page = get_int_or_404(request.GET.get('page', 0))
     if page == 1:
-        page = 0 # api allows explicit first page
+        page = 0  # api allows explicit first page
 
     users = RankUsers(request, rank, page)
     return Response(users.get_frontend_context())

+ 11 - 15
misago/users/api/userendpoints/signature.py

@@ -6,29 +6,27 @@ from django.utils.translation import ugettext as _
 
 from misago.conf import settings
 from misago.core.utils import format_plaintext_for_html
-from misago.users.signatures import is_user_signature_valid, set_user_signature
 from misago.users.serializers import EditSignatureSerializer
+from misago.users.signatures import is_user_signature_valid, set_user_signature
 
 
 def signature_endpoint(request):
     user = request.user
 
     if not user.acl_cache['can_have_signature']:
-        raise PermissionDenied(
-            _("You don't have permission to change signature."))
+        raise PermissionDenied(_("You don't have permission to change signature."))
 
     if user.is_signature_locked:
         if user.signature_lock_user_message:
-            reason = format_plaintext_for_html(
-                user.signature_lock_user_message)
+            reason = format_plaintext_for_html(user.signature_lock_user_message)
         else:
             reason = None
 
         return Response({
-                'detail': _("Your signature is locked. You can't change it."),
-                'reason': reason
-            },
-            status=status.HTTP_403_FORBIDDEN)
+            'detail': _("Your signature is locked. You can't change it."),
+            'reason': reason
+        },
+                        status=status.HTTP_403_FORBIDDEN)
 
     if request.method == 'POST':
         return edit_signature(request, user)
@@ -57,13 +55,11 @@ def get_signature_options(user):
 def edit_signature(request, user):
     serializer = EditSignatureSerializer(user, data=request.data)
     if serializer.is_valid():
-        set_user_signature(
-                request, user, serializer.validated_data['signature'])
-        user.save(update_fields=[
-            'signature', 'signature_parsed', 'signature_checksum'
-        ])
+        set_user_signature(request, user, serializer.validated_data['signature'])
+        user.save(update_fields=['signature', 'signature_parsed', 'signature_checksum'])
         return get_signature_options(user)
     else:
         return Response({
             'detail': serializer.errors['non_field_errors'][0]
-        }, status=status.HTTP_400_BAD_REQUEST)
+        },
+                        status=status.HTTP_400_BAD_REQUEST)

+ 11 - 20
misago/users/api/userendpoints/username.py

@@ -5,8 +5,8 @@ from django.db import IntegrityError
 from django.utils.translation import ugettext as _
 
 from misago.conf import settings
-from misago.users.serializers import ChangeUsernameSerializer
 from misago.users.namechanges import UsernameChanges
+from misago.users.serializers import ChangeUsernameSerializer
 
 
 def username_endpoint(request):
@@ -39,14 +39,9 @@ def change_username(request):
             'detail': _("You can't change your username now."),
             'options': options
         },
-        status=status.HTTP_400_BAD_REQUEST)
+                        status=status.HTTP_400_BAD_REQUEST)
 
-    serializer = ChangeUsernameSerializer(
-        data=request.data,
-        context={
-            'user': request.user
-        }
-    )
+    serializer = ChangeUsernameSerializer(data=request.data, context={'user': request.user})
 
     if serializer.is_valid():
         try:
@@ -59,23 +54,18 @@ def change_username(request):
         except IntegrityError:
             return Response({
                 'detail': _("Error changing username. Please try again."),
-                'options': options
             },
-            status=status.HTTP_400_BAD_REQUEST)
+                            status=status.HTTP_400_BAD_REQUEST)
     else:
         return Response({
             'detail': serializer.errors['non_field_errors'][0]
-        }, status=status.HTTP_400_BAD_REQUEST)
+        },
+                        status=status.HTTP_400_BAD_REQUEST)
 
 
 def moderate_username_endpoint(request, profile):
     if request.method == 'POST':
-        serializer = ChangeUsernameSerializer(
-            data=request.data,
-            context={
-                'user': profile
-            }
-        )
+        serializer = ChangeUsernameSerializer(data=request.data, context={'user': profile})
 
         if serializer.is_valid():
             try:
@@ -87,12 +77,13 @@ def moderate_username_endpoint(request, profile):
             except IntegrityError:
                 return Response({
                     'detail': _("Error changing username. Please try again."),
-                    'options': options
-                }, status=status.HTTP_400_BAD_REQUEST)
+                },
+                                status=status.HTTP_400_BAD_REQUEST)
         else:
             return Response({
                 'detail': serializer.errors['non_field_errors'][0]
-            }, status=status.HTTP_400_BAD_REQUEST)
+            },
+                            status=status.HTTP_400_BAD_REQUEST)
     else:
         return Response({
             'length_min': settings.username_length_min,

+ 9 - 11
misago/users/api/usernamechanges.py

@@ -1,4 +1,4 @@
-from rest_framework import mixins, status, viewsets
+from rest_framework import viewsets
 from rest_framework.response import Response
 
 from django.contrib.auth import get_user_model
@@ -27,13 +27,12 @@ class UsernameChangesViewSetPermission(BasePermission):
         if user_pk == request.user.pk:
             return True
         elif not request.user.acl_cache.get('can_see_users_name_history'):
-            raise PermissionDenied(
-                _("You don't have permission to see other users name history."))
+            raise PermissionDenied(_("You don't have permission to see other users name history."))
         return True
 
 
 class UsernameChangesViewSet(viewsets.GenericViewSet):
-    permission_classes = (UsernameChangesViewSetPermission,)
+    permission_classes = (UsernameChangesViewSetPermission, )
     serializer_class = UsernameChangeSerializer
 
     def get_queryset(self):
@@ -41,16 +40,15 @@ class UsernameChangesViewSet(viewsets.GenericViewSet):
 
         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
+            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 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')
@@ -58,7 +56,7 @@ class UsernameChangesViewSet(viewsets.GenericViewSet):
     def list(self, request):
         page = get_int_or_404(request.GET.get('page', 0))
         if page == 1:
-            page = 0 # api allows explicit first page
+            page = 0  # api allows explicit first page
 
         queryset = self.get_queryset()
 
@@ -66,7 +64,7 @@ class UsernameChangesViewSet(viewsets.GenericViewSet):
 
         data = pagination_dict(list_page)
         data.update({
-            'results': UsernameChangeSerializer(list_page.object_list, many=True).data
+            'results': UsernameChangeSerializer(list_page.object_list, many=True).data,
         })
 
         return Response(data)

+ 35 - 22
misago/users/api/users.py

@@ -1,9 +1,8 @@
-from rest_framework import mixins, status, viewsets
-from rest_framework.decorators import detail_route, list_route
+from rest_framework import status, viewsets
+from rest_framework.decorators import detail_route
 from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
 from rest_framework.response import Response
 
-from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.core.exceptions import PermissionDenied
 from django.db import transaction
@@ -14,7 +13,6 @@ from django.utils.translation import ugettext as _
 
 from misago.acl import add_acl
 from misago.categories.models import Category
-from misago.core.cache import cache
 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
@@ -56,8 +54,8 @@ def allow_self_only(user, pk, message):
 
 
 class UserViewSet(viewsets.GenericViewSet):
-    permission_classes = (UserViewSetPermission,)
-    parser_classes=(FormParser, JSONParser, MultiPartParser)
+    permission_classes = (UserViewSetPermission, )
+    parser_classes = (FormParser, JSONParser, MultiPartParser)
     queryset = UserModel.objects
 
     def get_queryset(self):
@@ -106,9 +104,7 @@ 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)
 
@@ -166,10 +162,7 @@ class UserViewSet(viewsets.GenericViewSet):
             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):
@@ -215,7 +208,9 @@ class UserViewSet(viewsets.GenericViewSet):
                         categories_to_sync.add(thread.category_id)
                         hide_thread(request, thread)
 
-                    posts = profile.post_set.select_related('category', 'thread', 'thread__category')
+                    posts = profile.post_set.select_related(
+                        'category', 'thread', 'thread__category'
+                    )
                     for post in posts.filter(is_hidden=False).iterator():
                         categories_to_sync.add(post.category_id)
                         hide_post(request.user, post)
@@ -237,7 +232,7 @@ class UserViewSet(viewsets.GenericViewSet):
 
         page = get_int_or_404(request.query_params.get('page', 0))
         if page == 1:
-            page = 0 # api allows explicit first page
+            page = 0  # api allows explicit first page
 
         search = request.query_params.get('search')
 
@@ -251,7 +246,7 @@ class UserViewSet(viewsets.GenericViewSet):
 
         page = get_int_or_404(request.query_params.get('page', 0))
         if page == 1:
-            page = 0 # api allows explicit first page
+            page = 0  # api allows explicit first page
 
         search = request.query_params.get('search')
 
@@ -265,7 +260,7 @@ class UserViewSet(viewsets.GenericViewSet):
 
         page = get_int_or_404(request.query_params.get('page', 0))
         if page == 1:
-            page = 0 # api allows explicit first page
+            page = 0  # api allows explicit first page
 
         feed = UserThreads(request, profile, page)
 
@@ -277,7 +272,7 @@ class UserViewSet(viewsets.GenericViewSet):
 
         page = get_int_or_404(request.query_params.get('page', 0))
         if page == 1:
-            page = 0 # api allows explicit first page
+            page = 0  # api allows explicit first page
 
         feed = UserPosts(request, profile, page)
 
@@ -285,7 +280,25 @@ 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', 'status', 'absolute_url',
-    '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',
+    'status',
+    'absolute_url',
+    'api_url',
+)

+ 3 - 5
misago/users/apps.py

@@ -10,16 +10,13 @@ class MisagoUsersConfig(AppConfig):
     verbose_name = "Misago Auth"
 
     def ready(self):
-        from . import signals
+        from . import signals as _
 
         self.register_default_usercp_pages()
         self.register_default_users_list_pages()
         self.register_default_user_profile_pages()
 
     def register_default_usercp_pages(self):
-        def show_signature_cp(request):
-            return request.user.acl_cache['can_have_signature']
-
         usercp.add_section(
             link='misago:usercp-change-forum-options',
             name=_('Forum options'),
@@ -43,7 +40,8 @@ class MisagoUsersConfig(AppConfig):
         users_list.add_section(
             link='misago:users-active-posters',
             component='active-posters',
-            name=_('Active posters'))
+            name=_('Active posters')
+        )
 
     def register_default_user_profile_pages(self):
         def can_see_names_history(request, profile):

+ 1 - 3
misago/users/avatars/__init__.py

@@ -2,14 +2,12 @@ from misago.conf import settings
 
 from . import store, 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
+    'gallery': gallery.set_random_avatar,
 }
 
 

+ 12 - 31
misago/users/avatars/dynamic.py

@@ -1,14 +1,22 @@
-import math
 import os
 from importlib import import_module
 
-from PIL import Image, ImageColor, ImageDraw, ImageFilter, ImageFont
+from PIL import Image, ImageColor, ImageDraw, ImageFont
 
 from misago.conf import settings
 
 from . import store
 
 
+COLOR_WHEEL = (
+    '#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')
+
+
 def set_avatar(user):
     name_bits = settings.MISAGO_DYNAMIC_AVATAR_DRAWER.split('.')
 
@@ -20,10 +28,8 @@ def set_avatar(user):
     store.store_new_avatar(user, image)
 
 
-"""
-Default drawer
-"""
 def draw_default(user):
+    """default avatar drawer that draws username's first letter on color"""
     image_size = max(settings.MISAGO_AVATARS_SIZES)
 
     image = Image.new("RGBA", (image_size, image_size), 0)
@@ -33,14 +39,6 @@ def draw_default(user):
     return image
 
 
-COLOR_WHEEL = ('#d32f2f', '#c2185b', '#7b1fa2', '#512da8',
-               '#303f9f', '#1976d2', '#0288D1', '#0288d1',
-               '#0097a7', '#00796b', '#388e3c', '#689f38',
-               '#afb42b', '#fbc02d', '#ffa000', '#f57c00',
-               '#e64a19')
-COLOR_WHEEL_LEN = len(COLOR_WHEEL)
-
-
 def draw_avatar_bg(user, image):
     image_size = image.size
 
@@ -55,9 +53,6 @@ def draw_avatar_bg(user, image):
     return image
 
 
-FONT_FILE = os.path.join(os.path.dirname(__file__), 'font.ttf')
-
-
 def draw_avatar_flavour(user, image):
     string = user.username[0]
 
@@ -67,23 +62,9 @@ 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)
 
     return image
-
-
-"""
-Some utils for drawring avatar programmatically
-"""
-CHARS = 'qwertyuiopasdfghjklzxcvbnm1234567890'
-
-
-def string_to_int(string):
-    value = 0
-    for p, c in enumerate(string.lower()):
-        value += p * (CHARS.find(c))
-    return value

+ 5 - 8
misago/users/avatars/gallery.py

@@ -30,10 +30,7 @@ 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])
 
@@ -61,10 +58,10 @@ def load_avatar_galleries():
 
         for image in images:
             with open(image, 'rb') as image_file:
-                galleries.append(AvatarGallery.objects.create(
-                    gallery=gallery_name,
-                    image=ContentFile(image_file.read(), 'image')
-                ))
+                galleries.append(
+                    AvatarGallery.objects.
+                    create(gallery=gallery_name, image=ContentFile(image_file.read(), 'image'))
+                )
     return galleries
 
 

+ 8 - 7
misago/users/avatars/store.py

@@ -39,11 +39,13 @@ def store_avatar(user, image):
         image = image.resize((size, size), Image.ANTIALIAS)
         image.save(image_stream, "PNG")
 
-        avatars.append(Avatar.objects.create(
-            user=user,
-            size=size,
-            image=ContentFile(image_stream.getvalue(), 'avatar')
-        ))
+        avatars.append(
+            Avatar.objects.create(
+                user=user,
+                size=size,
+                image=ContentFile(image_stream.getvalue(), 'avatar'),
+            )
+        )
 
     user.avatars = [{'size': a.size, 'url': a.url} for a in avatars]
     user.save(update_fields=['avatars'])
@@ -80,5 +82,4 @@ def upload_to(instance, filename):
     secret = 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)

+ 2 - 8
misago/users/avatars/uploaded.py

@@ -1,6 +1,3 @@
-from hashlib import sha256
-from math import floor
-
 from path import Path
 from PIL import Image
 
@@ -41,8 +38,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.")
+        message = _("Uploaded image should be at least %(size)s pixels tall and wide.")
         raise ValidationError(message % {'size': min_size})
 
     if image.size[0] * image.size[1] > 2000 * 3000:
@@ -85,7 +81,6 @@ def clean_crop(image, crop):
         crop_dict = {
             'x': float(crop['offset']['x']),
             'y': float(crop['offset']['y']),
-
             'zoom': float(crop['zoom']),
         }
     except (KeyError, TypeError, ValueError):
@@ -134,8 +129,7 @@ def crop_source_image(user, source, crop):
     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'] * 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)),
         ))

+ 11 - 25
misago/users/bans.py

@@ -1,4 +1,3 @@
-
 """
 API for checking values for bans
 
@@ -67,10 +66,7 @@ def _set_user_ban_cache(user):
     ban_cache.bans_version = cachebuster.get_version(VERSION_KEY)
 
     try:
-        user_ban = Ban.objects.get_ban(
-            username=user.username,
-            email=user.email
-        )
+        user_ban = Ban.objects.get_ban(username=user.username, email=user.email)
 
         ban_cache.ban = user_ban
         ban_cache.expires_on = user_ban.expires_on
@@ -86,13 +82,13 @@ def _set_user_ban_cache(user):
     return ban_cache
 
 
-"""
-Utility for checking if request came from banned IP
-
-This check may be performed frequently, which is why there is extra
-boilerplate that caches ban check result in session
-"""
 def get_request_ip_ban(request):
+    """
+    Utility for checking if request came from banned IP
+
+    This check may be performed frequently, which is why there is extra
+    boilerplate that caches ban check result in session
+    """
     session_ban_cache = _get_session_bancache(request)
     if session_ban_cache:
         if session_ban_cache['is_banned']:
@@ -113,10 +109,7 @@ def get_request_ip_ban(request):
         else:
             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:
@@ -134,9 +127,6 @@ def _get_session_bancache(request):
         if not cachebuster.is_valid(VERSION_KEY, ban_cache['version']):
             return None
         if ban_cache.get('expires_on'):
-            """
-            Hydrate ban date
-            """
             if ban_cache['expires_on'] < timezone.today():
                 return None
         return ban_cache
@@ -153,11 +143,8 @@ def _hydrate_session_cache(ban_cache):
     return hydrated
 
 
-"""
-Utilities for front-end based bans
-"""
-def ban_user(user, user_message=None, staff_message=None, length=None,
-             expires_on=None):
+# Utilities for front-end based bans
+def ban_user(user, user_message=None, staff_message=None, length=None, expires_on=None):
     if not expires_on and length:
         expires_on = timezone.now() + timedelta(**length)
 
@@ -171,8 +158,7 @@ def ban_user(user, user_message=None, staff_message=None, length=None,
     return ban
 
 
-def ban_ip(ip, user_message=None, staff_message=None, length=None,
-           expires_on=None):
+def ban_ip(ip, user_message=None, staff_message=None, length=None, expires_on=None):
     if not expires_on and length:
         expires_on = timezone.now() + timedelta(**length)
 

+ 10 - 6
misago/users/captcha.py

@@ -7,11 +7,14 @@ from misago.conf import settings
 
 
 def recaptcha_test(request):
-    r = requests.post('https://www.google.com/recaptcha/api/siteverify', data={
-        'secret': settings.recaptcha_secret_key,
-        'response': request.data.get('captcha'),
-        'remoteip': request.user_ip
-    })
+    r = requests.post(
+        'https://www.google.com/recaptcha/api/siteverify',
+        data={
+            'secret': settings.recaptcha_secret_key,
+            'response': request.data.get('captcha'),
+            'remoteip': request.user_ip
+        }
+    )
 
     if r.status_code == 200:
         response_json = r.json()
@@ -32,7 +35,7 @@ def qacaptcha_test(request):
 
 
 def nocaptcha_test(request):
-    return # no captcha means no validation
+    return  # no captcha means no validation
 
 
 CAPTCHA_TESTS = {
@@ -41,5 +44,6 @@ CAPTCHA_TESTS = {
     'no': nocaptcha_test,
 }
 
+
 def test_request(request):
     CAPTCHA_TESTS[settings.captcha_type](request)

+ 2 - 10
misago/users/context_processors.py

@@ -9,16 +9,12 @@ def user_links(request):
         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'),
         })
@@ -39,12 +35,8 @@ def preload_user_json(request):
     })
 
     if request.user.is_authenticated:
-        request.frontend_context.update({
-            'user': AuthenticatedUserSerializer(request.user).data
-        })
+        request.frontend_context.update({'user': AuthenticatedUserSerializer(request.user).data})
     else:
-        request.frontend_context.update({
-            'user': AnonymousUserSerializer(request.user).data
-        })
+        request.frontend_context.update({'user': AnonymousUserSerializer(request.user).data})
 
     return {}

+ 3 - 8
misago/users/credentialchange.py

@@ -44,13 +44,8 @@ def read_new_credential(request, credential_type, link_token):
 
 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,
-        six.text_type(token_type)
+        user.pk, user.email, user.password, user.last_login.replace(microsecond=0, tzinfo=None),
+        settings.SECRET_KEY, six.text_type(token_type)
     )
 
-    return sha256(
-        force_bytes('+'.join([six.text_type(s) for s in seeds]))).hexdigest()
+    return sha256(force_bytes('+'.join([six.text_type(s) for s in seeds]))).hexdigest()

+ 6 - 8
misago/users/decorators.py

@@ -1,5 +1,4 @@
 from django.core.exceptions import PermissionDenied
-from django.shortcuts import redirect
 from django.utils.translation import gettext as _
 
 from misago.core.exceptions import Banned
@@ -11,20 +10,20 @@ from .models import Ban
 def deny_authenticated(f):
     def decorator(request, *args, **kwargs):
         if request.user.is_authenticated:
-            raise PermissionDenied(
-                _("This page is not available to signed in users."))
+            raise PermissionDenied(_("This page is not available to signed in users."))
         else:
             return f(request, *args, **kwargs)
+
     return decorator
 
 
 def deny_guests(f):
     def decorator(request, *args, **kwargs):
         if request.user.is_anonymous:
-            raise PermissionDenied(
-                _("You have to sign in to access this page."))
+            raise PermissionDenied(_("You have to sign in to access this page."))
         else:
             return f(request, *args, **kwargs)
+
     return decorator
 
 
@@ -33,11 +32,10 @@ def deny_banned_ips(f):
         ban = get_request_ip_ban(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 Banned(hydrated_ban)
         else:
             return f(request, *args, **kwargs)
+
     return decorator

+ 23 - 51
misago/users/djangoadmin.py

@@ -30,33 +30,21 @@ class UserAdminForm(forms.ModelForm):
     it is kind of overkill - overwrite the whole template just to add one
     button - isn't it?
     """
-    #: pseudo-field
     edit_from_misago_link = forms.Field()
 
     def __init__(self, *args, **kwargs):
-        # noinspection PyArgumentList
         super(UserAdminForm, self).__init__(*args, **kwargs)
         self.init_edit_from_misago_link_field()
 
     def init_edit_from_misago_link_field(self):
-        """
-        Init for the pseudo-field, and replace it's widget `render`.
-        """
+        """init for the pseudo-field, and replace it's widget `render`"""
         field = self.fields['edit_from_misago_link']
         field.required = False
         field.label = ''
         field.widget.render = self.render_edit_from_misago_link
 
-    # noinspection PyUnusedLocal
     def render_edit_from_misago_link(self, *args, **kwargs):
-        """
-        Composes an html hyperlink for the pseudo-field render.
-
-        `*args` and `**kwargs` arguments need to mimic widget `render()`
-        signature.
-
-        :rtype: str
-        """
+        """composes an html hyperlink for the pseudo-field render"""
         text = _('Edit this user in Misago admin panel')
         link_html_template = ('<a href="{}" target="blank">' + text + '</a>')
         link_url = reverse(
@@ -79,48 +67,32 @@ class UserAdmin(admin.ModelAdmin):
     that).
     Replaces default form with custom `UserAdminForm`.
     """
-    list_display = (
-        'username',
-        'email',
-        'is_staff',
-        'is_superuser',
-    )
-    search_fields = ('username', 'email')
-    list_filter = ('groups', 'is_staff', 'is_superuser')
+    list_display = ['username', 'email', 'is_staff', 'is_superuser']
+    search_fields = ['username', 'email']
+    list_filter = ['groups', 'is_staff', 'is_superuser']
 
     form = UserAdminForm
     actions = None
-    readonly_fields = (
-        'username',
-        'email',
-        'rank',
-        'last_login',
-        'joined_on',
-        'is_staff',
-        'is_superuser',
-    )
-    fieldsets = (
-        (
+    readonly_fields = [
+        'username', 'email', 'rank', 'last_login', 'joined_on', 'is_staff', 'is_superuser'
+    ]
+    fieldsets = [
+        [
             _('Misago user data'),
-            {'fields': (
-                'username',
-                'email',
-                'rank',
-                'last_login',
-                'joined_on',
-                'is_staff',
-                'is_superuser',
-                'edit_from_misago_link',
-            )},
-        ),
-        (
+            {
+                'fields': (
+                    'username', 'email', 'rank', 'last_login', 'joined_on', 'is_staff',
+                    'is_superuser', 'edit_from_misago_link',
+                )
+            },
+        ],
+        [
             _('Edit permissions and groups'),
-            {'fields': (
-                'groups',
-                'user_permissions',
-            )},
-        ),
-    )
+            {
+                'fields': ('groups', 'user_permissions', )
+            },
+        ],
+    ]
 
     def has_add_permission(self, request):
         return False

+ 46 - 87
misago/users/forms/admin.py

@@ -16,9 +16,6 @@ from misago.users.validators import validate_email, validate_username
 UserModel = get_user_model()
 
 
-"""
-Users
-"""
 class UserBaseForm(forms.ModelForm):
     username = forms.CharField(label=_("Username"))
     title = forms.CharField(label=_("Custom title"), required=False)
@@ -58,10 +55,7 @@ class UserBaseForm(forms.ModelForm):
 
 
 class NewUserForm(UserBaseForm):
-    new_password = forms.CharField(
-        label=_("Password"),
-        widget=forms.PasswordInput
-    )
+    new_password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
 
     class Meta:
         model = UserModel
@@ -90,8 +84,8 @@ class EditUserForm(UserBaseForm):
         "Turning this off is non-destructible way to remove user accounts."
     )
 
-    IS_ACTIVE_STAFF_MESSAGE_LABEL=_("Staff message")
-    IS_ACTIVE_STAFF_MESSAGE_HELP_TEXT=_(
+    IS_ACTIVE_STAFF_MESSAGE_LABEL = _("Staff message")
+    IS_ACTIVE_STAFF_MESSAGE_HELP_TEXT = _(
         "Optional message for forum team members explaining "
         "why user's account has been disabled."
     )
@@ -99,7 +93,7 @@ class EditUserForm(UserBaseForm):
     new_password = forms.CharField(
         label=_("Change password to"),
         widget=forms.PasswordInput,
-        required=False
+        required=False,
     )
 
     is_avatar_locked = YesNoSwitch(
@@ -132,7 +126,7 @@ class EditUserForm(UserBaseForm):
     signature = forms.CharField(
         label=_("Signature contents"),
         widget=forms.Textarea(attrs={'rows': 3}),
-        required=False
+        required=False,
     )
     is_signature_locked = YesNoSwitch(
         label=_("Lock signature"),
@@ -143,17 +137,13 @@ class EditUserForm(UserBaseForm):
     )
     signature_lock_user_message = forms.CharField(
         label=_("User message"),
-        help_text=_(
-            "Optional message to user explaining why his/hers signature is locked."
-        ),
+        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."
-        ),
+        help_text=_("Optional message to team members explaining why user signature is locked."),
         widget=forms.Textarea(attrs={'rows': 3}),
         required=False
     )
@@ -167,14 +157,10 @@ class EditUserForm(UserBaseForm):
     )
 
     subscribe_to_started_threads = forms.TypedChoiceField(
-        label=_("Started threads"),
-        coerce=int,
-        choices=UserModel.SUBSCRIBE_CHOICES
+        label=_("Started threads"), coerce=int, choices=UserModel.SUBSCRIBE_CHOICES
     )
     subscribe_to_replied_threads = forms.TypedChoiceField(
-        label=_("Replid threads"),
-        coerce=int,
-        choices=UserModel.SUBSCRIBE_CHOICES
+        label=_("Replid threads"), coerce=int, choices=UserModel.SUBSCRIBE_CHOICES
     )
 
     class Meta:
@@ -201,11 +187,13 @@ class EditUserForm(UserBaseForm):
 
         length_limit = settings.signature_length_max
         if len(data) > length_limit:
-            raise forms.ValidationError(ungettext(
-                "Signature can't be longer than %(limit)s character.",
-                "Signature can't be longer than %(limit)s characters.",
-                length_limit
-            ) % {'limit': length_limit})
+            raise forms.ValidationError(
+                ungettext(
+                    "Signature can't be longer than %(limit)s character.",
+                    "Signature can't be longer than %(limit)s characters.",
+                    length_limit,
+                ) % {'limit': length_limit}
+            )
 
         return data
 
@@ -227,15 +215,13 @@ def UserFormFactory(FormType, instance):
 
     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
     )
 
-    return type('UserFormFinal', (FormType,), extra_fields)
+    return type('UserFormFinal', (FormType, ), extra_fields)
 
 
 def StaffFlagUserFormFactory(FormType, instance):
@@ -252,7 +238,7 @@ def StaffFlagUserFormFactory(FormType, instance):
         ),
     }
 
-    return type('StaffUserForm', (FormType,), staff_fields)
+    return type('StaffUserForm', (FormType, ), staff_fields)
 
 
 def UserIsActiveFormFactory(FormType, instance):
@@ -271,11 +257,10 @@ def UserIsActiveFormFactory(FormType, instance):
         ),
     }
 
-    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:
@@ -296,12 +281,10 @@ class SearchUsersFormBase(forms.Form):
 
     def filter_queryset(self, criteria, queryset):
         if criteria.get('username'):
-            queryset = queryset.filter(
-                slug__startswith=criteria.get('username').lower())
+            queryset = queryset.filter(slug__startswith=criteria.get('username').lower())
 
         if criteria.get('email'):
-            queryset = queryset.filter(
-                email__istartswith=criteria.get('email'))
+            queryset = queryset.filter(email__istartswith=criteria.get('email'))
 
         if criteria.get('rank'):
             queryset = queryset.filter(rank_id=criteria.get('rank'))
@@ -346,24 +329,20 @@ def SearchUsersForm(*args, **kwargs):
             label=_("Has rank"),
             coerce=int,
             required=False,
-            choices=ranks_choices
+            choices=ranks_choices,
         ),
         'role': forms.TypedChoiceField(
             label=_("Has role"),
             coerce=int,
             required=False,
-            choices=roles_choices
+            choices=roles_choices,
         )
     }
 
-    FinalForm = type(
-        'SearchUsersFormFinal', (SearchUsersFormBase,), extra_fields)
+    FinalForm = type('SearchUsersFormFinal', (SearchUsersFormBase, ), extra_fields)
     return FinalForm(*args, **kwargs)
 
 
-"""
-Ranks
-"""
 class RankForm(forms.ModelForm):
     name = forms.CharField(
         label=_("Name"),
@@ -401,17 +380,14 @@ class RankForm(forms.ModelForm):
     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"),
         required=False,
         help_text=_(
-            "Selecting this option will make users with this rank "
-            "easily discoverable by others trough dedicated page on "
-            "forum users list."
+            "Selecting this option will make users with this rank easily discoverable "
+            "by others through dedicated page on forum users list."
         )
     )
 
@@ -435,27 +411,23 @@ class RankForm(forms.ModelForm):
             unique_qs = unique_qs.exclude(pk=self.instance.pk)
 
         if unique_qs.exists():
-            raise forms.ValidationError(
-                _("This name collides with other rank."))
+            raise forms.ValidationError(_("This name collides with other rank."))
 
         return data
 
 
-"""
-Bans
-"""
 class BanUsersForm(forms.Form):
     ban_type = forms.MultipleChoiceField(
         label=_("Values to ban"),
         widget=forms.CheckboxSelectMultiple,
-        choices=(
+        choices=[
             ('usernames', _('Usernames')),
             ('emails', _('E-mails')),
             ('domains', _('E-mail domains')),
             ('ip', _('IP addresses')),
             ('ip_first', _('First segment of IP addresses')),
-            ('ip_two', _('First two segments of IP addresses'))
-        )
+            ('ip_two', _('First two segments of IP addresses')),
+        ]
     )
     user_message = forms.CharField(
         label=_("User message"),
@@ -464,7 +436,7 @@ class BanUsersForm(forms.Form):
         help_text=_("Optional message displayed to users instead of default one."),
         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(
@@ -474,7 +446,7 @@ class BanUsersForm(forms.Form):
         help_text=_("Optional ban message for moderators and administrators."),
         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(
@@ -485,11 +457,7 @@ class BanUsersForm(forms.Form):
 
 
 class BanForm(forms.ModelForm):
-    check_type = forms.TypedChoiceField(
-        label=_("Check type"),
-        coerce=int,
-        choices=Ban.CHOICES
-    )
+    check_type = forms.TypedChoiceField(label=_("Check type"), coerce=int, choices=Ban.CHOICES)
     banned_value = forms.CharField(
         label=_("Banned value"),
         max_length=250,
@@ -499,8 +467,7 @@ class BanForm(forms.ModelForm):
             '"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(
@@ -510,7 +477,7 @@ class BanForm(forms.ModelForm):
         help_text=_("Optional message displayed to user instead of default one."),
         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(
@@ -520,7 +487,7 @@ class BanForm(forms.ModelForm):
         help_text=_("Optional ban message for moderators and administrators."),
         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(
@@ -551,30 +518,23 @@ class BanForm(forms.ModelForm):
 
 
 class SearchBansForm(forms.Form):
-    SARCH_CHOICES = (
+    SARCH_CHOICES = [
         ('', _('All bans')),
         ('names', _('Usernames')),
         ('emails', _('E-mails')),
         ('ips', _('IPs')),
-    )
+    ]
 
-    check_type = forms.ChoiceField(
-        label=_("Type"),
-        required=False,
-        choices=SARCH_CHOICES
-    )
-    value = forms.CharField(
-        label=_("Banned value begins with"),
-        required=False
-    )
+    check_type = forms.ChoiceField(label=_("Type"), required=False, choices=SARCH_CHOICES)
+    value = forms.CharField(label=_("Banned value begins with"), required=False)
     state = forms.ChoiceField(
         label=_("State"),
         required=False,
-        choices=(
+        choices=[
             ('', _('Any')),
             ('used', _('Active')),
             ('unused', _('Expired')),
-        )
+        ]
     )
 
     def filter_queryset(self, search_criteria, queryset):
@@ -589,8 +549,7 @@ class SearchBansForm(forms.Form):
             queryset = queryset.filter(check_type=2)
 
         if criteria.get('value'):
-            queryset = queryset.filter(
-                banned_value__startswith=criteria.get('value').lower())
+            queryset = queryset.filter(banned_value__startswith=criteria.get('value').lower())
 
         if criteria.get('state') == 'used':
             queryset = queryset.filter(is_checked=True)

+ 27 - 47
misago/users/forms/auth.py

@@ -15,21 +15,18 @@ 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': _("Your account has to be activated by "
-                            "Administrator before you will be able "
-                            "to sign in."),
+        '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 Administrator before you will be able to sign in."
+        ),
     }
 
     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:
@@ -44,10 +41,7 @@ class MisagoAuthMixin(object):
         else:
             error.message = error.messages[0]
 
-        return {
-            'detail': error.message,
-            'code': error.code
-        }
+        return {'detail': error.message, 'code': error.code}
 
 
 class AuthenticationForm(MisagoAuthMixin, BaseAuthenticationForm):
@@ -55,33 +49,22 @@ 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
-    )
-    password = forms.CharField(
-        label=_("Password"),
-        required=False,
-        widget=forms.PasswordInput
-    )
+    username = forms.CharField(label=_("Username or e-mail"), required=False, max_length=254)
+    password = forms.CharField(label=_("Password"), required=False, widget=forms.PasswordInput)
 
     def clean(self):
         username = self.cleaned_data.get('username')
         password = self.cleaned_data.get('password')
 
         if username and password:
-            self.user_cache = authenticate(
-                username=username, password=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
 
@@ -95,15 +78,14 @@ class AdminAuthenticationForm(AuthenticationForm):
 
     def __init__(self, *args, **kwargs):
         self.error_messages.update({
-            'not_staff': _("Your account does not have admin privileges.")
+            'not_staff': _("Your account does not have admin privileges."),
         })
 
         super(AdminAuthenticationForm, self).__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):
@@ -114,14 +96,12 @@ class GetUserForm(MisagoAuthMixin, forms.Form):
 
         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'])
@@ -129,8 +109,7 @@ class GetUserForm(MisagoAuthMixin, forms.Form):
                 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)
 
@@ -146,22 +125,23 @@ class ResendActivationForm(GetUserForm):
 
         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': _("You have to activate your account before "
-                           "you will be able to request new password."),
-        'inactive_admin': _("Administrator has to activate your account "
-                            "before you will be able to request "
-                            "new password."),
+        'inactive_user': _(
+            "You have to activate your account before "
+            "you will be able to request new password."
+        ),
+        'inactive_admin': _(
+            "Administrator has to activate your account before "
+            "you will be able to request new password."
+        ),
     }
 
     def confirm_allowed(self, user):

+ 1 - 1
misago/users/forms/register.py

@@ -27,7 +27,7 @@ class RegisterForm(forms.Form):
                 user=UserModel(
                     username=cleaned_data.get('username'),
                     email=cleaned_data.get('email'),
-                )
+                ),
             )
 
     def clean(self):

+ 0 - 1
misago/users/management/commands/buildactivepostersranking.py

@@ -1,6 +1,5 @@
 from django.core.management.base import BaseCommand
 
-from misago.core.management.progressbar import show_progress
 from misago.users.activepostersranking import build_active_posters_ranking
 
 

+ 45 - 25
misago/users/management/commands/createsuperuser.py

@@ -27,24 +27,45 @@ class Command(BaseCommand):
     help = 'Used to create a superuser.'
 
     def add_arguments(self, parser):
-        parser.add_argument('--username', dest='username', default=None,
-                    help='Specifies the username for the superuser.')
-        parser.add_argument('--email', dest='email', default=None,
-                    help='Specifies the username for the superuser.')
-        parser.add_argument('--password', dest='password', default=None,
-                    help='Specifies the username for the superuser.')
-        parser.add_argument('--noinput', action='store_false', dest='interactive',
-                    default=True,
-                    help=('Tells Misago to NOT prompt the user for input '
-                          'of any kind. You must use --username with '
-                          '--noinput, along with an option for any other '
-                          'required field. Superusers created with '
-                          '--noinput will  not be able to log in until '
-                          'they\'re given a valid password.'))
-        parser.add_argument('--database', action='store', dest='database',
-                    default=DEFAULT_DB_ALIAS,
-                    help=('Specifies the database to use. '
-                          'Default is "default".'))
+        parser.add_argument(
+            '--username',
+            dest='username',
+            default=None,
+            help="Specifies the username for the superuser.",
+        )
+        parser.add_argument(
+            '--email',
+            dest='email',
+            default=None,
+            help="Specifies the username for the superuser.",
+        )
+        parser.add_argument(
+            '--password',
+            dest='password',
+            default=None,
+            help="Specifies the username for the superuser.",
+        )
+        parser.add_argument(
+            '--noinput',
+            action='store_false',
+            dest='interactive',
+            default=True,
+            help=(
+                "Tells Misago to NOT prompt the user for input "
+                "of any kind. You must use --username with "
+                "--noinput, along with an option for any other "
+                "required field. Superusers created with "
+                "--noinput will  not be able to log in until "
+                "they're given a valid password."
+            ),
+        )
+        parser.add_argument(
+            '--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
@@ -114,15 +135,13 @@ class Command(BaseCommand):
                 while not password:
                     try:
                         raw_value = getpass("Enter password: ").strip()
-                        validate_password(raw_value, user=UserModel(
-                            username=username,
-                            email=email
-                        ))
+                        validate_password(
+                            raw_value, user=UserModel(username=username, email=email)
+                        )
 
                         repeat_raw_value = getpass("Repeat password: ").strip()
                         if raw_value != repeat_raw_value:
-                            raise ValidationError(
-                                "Entered passwords are different.")
+                            raise ValidationError("Entered passwords are different.")
                         password = raw_value
                     except ValidationError as e:
                         self.stderr.write(e.messages[0])
@@ -143,7 +162,8 @@ class Command(BaseCommand):
     def create_superuser(self, username, email, password, verbosity):
         try:
             user = UserModel.objects.create_superuser(
-                username, email, password, set_default_avatar=True)
+                username, email, password, set_default_avatar=True
+            )
 
             if verbosity >= 1:
                 message = "Superuser #%(pk)s has been created successfully."

+ 5 - 2
misago/users/management/commands/synchronizeusers.py

@@ -35,15 +35,18 @@ class Command(BaseCommand):
             user.threads = user.thread_set.filter(
                 category__in=categories,
                 is_hidden=False,
-                is_unapproved=False
+                is_unapproved=False,
             ).count()
+
             user.posts = user.post_set.filter(
                 category__in=categories,
                 is_event=False,
-                is_unapproved=False
+                is_unapproved=False,
             ).count()
+
             user.followers = user.followed_by.count()
             user.following = user.follows.count()
+
             user.save()
 
             synchronized_count += 1

+ 0 - 3
misago/users/middleware.py

@@ -1,9 +1,6 @@
 from django.contrib.auth import logout
-from django.contrib.auth.models import AnonymousUser as DjAnonymousUser
 from django.utils.deprecation import MiddlewareMixin
 
-import pytz
-
 from .bans import get_request_ip_ban, get_user_ban
 from .models import AnonymousUser, Online
 from .online import tracker

+ 173 - 42
misago/users/migrations/0001_initial.py

@@ -22,34 +22,94 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             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')),
-                ('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)),
-                ('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)),
-                ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into admin sites.', verbose_name='staff status')),
-                ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+                (
+                    'is_staff', models.BooleanField(
+                        default=False,
+                        help_text='Designates whether the user can log into admin sites.',
+                        verbose_name='staff status'
+                    )
+                ),
+                (
+                    'is_superuser', models.BooleanField(
+                        default=False,
+                        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)),
-                ('is_active', models.BooleanField(
-                    db_index=True, default=True, verbose_name='active', help_text=(
-                        'Designates whether this user should be treated as active. Unselect this instead of deleting '
-                        'accounts.'
+                (
+                    'is_active', models.BooleanField(
+                        db_index=True,
+                        default=True,
+                        verbose_name='active',
+                        help_text=(
+                            '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)),
-                ('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')),
+                (
+                    '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'
+                    )
+                ),
                 ('roles', models.ManyToManyField(to='misago_acl.Role')),
-                ('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')),
-                ('avatar_tmp', models.ImageField(max_length=255, upload_to=misago.users.avatars.store.upload_to, null=True, blank=True)),
-                ('avatar_src', models.ImageField(max_length=255, upload_to=misago.users.avatars.store.upload_to, null=True, blank=True)),
+                (
+                    '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'
+                    )
+                ),
+                (
+                    'avatar_tmp', models.ImageField(
+                        max_length=255,
+                        upload_to=misago.users.avatars.store.upload_to,
+                        null=True,
+                        blank=True
+                    )
+                ),
+                (
+                    '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)),
@@ -75,7 +135,7 @@ class Migration(migrations.Migration):
             options={
                 'abstract': False,
             },
-            bases=(models.Model,),
+            bases=(models.Model, ),
         ),
         CreatePartialIndex(
             field='User.is_staff',
@@ -92,32 +152,57 @@ class Migration(migrations.Migration):
             fields=[
                 ('current_ip', models.GenericIPAddressField()),
                 ('last_click', models.DateTimeField(default=django.utils.timezone.now)),
-                ('user', models.OneToOneField(related_name='online_tracker', primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
+                (
+                    'user', models.OneToOneField(
+                        related_name='online_tracker',
+                        primary_key=True,
+                        serialize=False,
+                        to=settings.AUTH_USER_MODEL
+                    )
+                ),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
         migrations.CreateModel(
             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', models.ForeignKey(related_name='user_renames', on_delete=django.db.models.deletion.SET_NULL, blank=True, to=settings.AUTH_USER_MODEL, null=True)),
-                ('user', models.ForeignKey(related_name='namechanges', to=settings.AUTH_USER_MODEL)),
+                (
+                    '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
+                    )
+                ),
+                (
+                    'user',
+                    models.ForeignKey(related_name='namechanges', to=settings.AUTH_USER_MODEL)
+                ),
             ],
             options={
                 'get_latest_by': b'changed_on',
             },
-            bases=(models.Model,),
+            bases=(models.Model, ),
         ),
         migrations.CreateModel(
             name='Rank',
             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)),
                 ('slug', models.CharField(unique=True, max_length=255)),
                 ('description', models.TextField(null=True, blank=True)),
@@ -131,12 +216,18 @@ class Migration(migrations.Migration):
             options={
                 'get_latest_by': b'order',
             },
-            bases=(models.Model,),
+            bases=(models.Model, ),
         ),
         migrations.AddField(
             model_name='user',
             name='rank',
-            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to_field='id', blank=True, to='misago_users.Rank', null=True),
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.PROTECT,
+                to_field='id',
+                blank=True,
+                to='misago_users.Rank',
+                null=True
+            ),
             preserve_default=True,
         ),
         migrations.AddField(
@@ -154,29 +245,52 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             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='+', to=settings.AUTH_USER_MODEL)),
                 ('score', models.PositiveIntegerField(default=0, db_index=True)),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
         migrations.CreateModel(
             name='Avatar',
             fields=[
-                ('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)),
+                (
+                    '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
+                    )
+                ),
                 ('size', models.PositiveIntegerField(default=0)),
-                ('image', models.ImageField(max_length=255, upload_to=misago.users.avatars.store.upload_to)),
+                (
+                    'image', models.ImageField(
+                        max_length=255, upload_to=misago.users.avatars.store.upload_to
+                    )
+                ),
             ],
         ),
         migrations.CreateModel(
             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)),
-                ('image', models.ImageField(max_length=255, upload_to=misago.users.avatars.store.upload_to)),
+                (
+                    'image', models.ImageField(
+                        max_length=255, upload_to=misago.users.avatars.store.upload_to
+                    )
+                ),
             ],
             options={
                 'ordering': ['gallery', 'pk'],
@@ -185,7 +299,11 @@ class Migration(migrations.Migration):
         migrations.CreateModel(
             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)),
@@ -193,7 +311,7 @@ class Migration(migrations.Migration):
                 ('expires_on', models.DateTimeField(null=True, blank=True, db_index=True)),
                 ('is_checked', models.BooleanField(default=True, db_index=True)),
             ],
-            bases=(models.Model,),
+            bases=(models.Model, ),
         ),
         migrations.CreateModel(
             name='BanCache',
@@ -202,11 +320,24 @@ class Migration(migrations.Migration):
                 ('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(on_delete=django.db.models.deletion.SET_NULL, blank=True, to='misago_users.Ban', null=True)),
-                ('user', models.OneToOneField(related_name='ban_cache', primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)),
+                (
+                    'ban', models.ForeignKey(
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        blank=True,
+                        to='misago_users.Ban',
+                        null=True
+                    )
+                ),
+                (
+                    'user', models.OneToOneField(
+                        related_name='ban_cache',
+                        primary_key=True,
+                        serialize=False,
+                        to=settings.AUTH_USER_MODEL
+                    )
+                ),
             ],
-            options={
-            },
-            bases=(models.Model,),
+            options={},
+            bases=(models.Model, ),
         ),
     ]

+ 228 - 214
misago/users/migrations/0002_users_settings.py

@@ -10,239 +10,253 @@ _ = lambda x: x
 
 
 def create_users_settings_group(apps, schema_editor):
-    migrate_settings_group(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"))
-                    )
+    migrate_settings_group(
+        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,
                 },
-                '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,
+                {
+                    '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,
                 },
-                '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,
+                {
+                    '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,
                 },
-                '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,
+                {
+                    '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,
                 },
-                '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',
-            },
-            {
-                'setting': 'default_avatar',
-                'name': _("Default avatar"),
-                'value': 'gravatar',
-                'form_field': 'select',
-                'field_extra': {
-                    'choices': (
-                        ('dynamic', _("Individual")),
-                        ('gravatar', _("Gravatar")),
-                        ('gallery', _("Random avatar from gallery")),
+                {
+                    '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',
                 },
-            },
-            {
-                '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")),
+                {
+                    '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': _(
+                        "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")),
+                        ],
+                    },
                 },
-            },
-            {
-                '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,
+                {
+                    '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,
                 },
-                '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,
+                {
+                    '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,
                 },
-                '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', _("Put on watched threads "
-                                          "list and e-mail user when "
-                                          "somebody replies")),
-                    ),
+                {
+                    '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', _(
+                                    "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")),
-                        ('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")),
+                            (
+                                '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': (
-            {
-                '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")),
-                    ),
+    migrate_settings_group(
+        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")),
+                        ],
+                    },
+                    'is_public': True,
                 },
-                'is_public': True,
-            },
-            {
-                'setting': 'recaptcha_site_key',
-                'name': _("Site key"),
-                'legend': _("reCAPTCHA"),
-                'value': '',
-                'field_extra': {
-                    'required': False,
-                    'max_length': 100,
+                {
+                    'setting': 'recaptcha_site_key',
+                    'name': _("Site key"),
+                    'legend': _("reCAPTCHA"),
+                    'value': '',
+                    'field_extra': {
+                        'required': False,
+                        'max_length': 100,
+                    },
+                    'is_public': True,
                 },
-                '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):

+ 1 - 1
misago/users/migrations/0003_bans_version_tracker.py

@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-from django.db import migrations, models
+from django.db import migrations
 
 from misago.core.migrationutils import cachebuster_register_cache
 from misago.users.constants import BANS_CACHEBUSTER

+ 2 - 5
misago/users/migrations/0004_default_ranks.py

@@ -1,7 +1,7 @@
 # -*- coding: utf-8 -*-
 from __future__ import unicode_literals
 
-from django.db import migrations, models
+from django.db import migrations
 from django.utils.translation import ugettext as _
 
 from misago.core.utils import slugify
@@ -20,10 +20,7 @@ def create_default_ranks(apps, schema_editor):
     )
 
     member = Rank.objects.create(
-        name=_("Members"),
-        slug=slugify(_("Members")),
-        is_default=True,
-        order=1
+        name=_("Members"), slug=slugify(_("Members")), is_default=True, order=1
     )
 
     Role = apps.get_model('misago_acl', 'Role')

+ 8 - 1
misago/users/migrations/0005_dj_19_update.py

@@ -28,6 +28,13 @@ class Migration(migrations.Migration):
         migrations.AlterField(
             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'),
+            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'
+            ),
         ),
     ]

+ 145 - 131
misago/users/migrations/0006_update_settings.py

@@ -11,148 +11,162 @@ _ = lambda x: x
 
 
 def update_users_settings(apps, schema_editor):
-    migrate_settings_group(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"))
-                    )
+    migrate_settings_group(
+        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,
                 },
-                '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': _("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',
-            },
-            {
-                'setting': 'default_avatar',
-                'name': _("Default avatar"),
-                'value': 'gravatar',
-                'form_field': 'select',
-                'field_extra': {
-                    'choices': (
-                        ('dynamic', _("Individual")),
-                        ('gravatar', _("Gravatar")),
-                        ('gallery', _("Random avatar from gallery")),
+                {
+                    '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',
                 },
-            },
-            {
-                '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")),
+                {
+                    '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': _(
+                        "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")),
+                        ],
+                    },
                 },
-            },
-            {
-                '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,
+                {
+                    '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,
                 },
-                '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,
+                {
+                    '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,
                 },
-                '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', _("Put on watched threads "
-                                          "list and e-mail user when "
-                                          "somebody replies")),
-                    ),
+                {
+                    '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', _(
+                                    "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")),
-                        ('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")),
+                            (
+                                'watch_email', _(
+                                    "Put on watched threads "
+                                    "list and e-mail user when "
+                                    "somebody replies"
+                                )
+                            ),
+                        ],
+                    },
                 },
-            },
-        )
-    })
+            ],
+        }
+    )
 
     delete_settings_cache()
 

+ 9 - 3
misago/users/migrations/0007_auto_20170219_1639.py

@@ -15,16 +15,22 @@ class Migration(migrations.Migration):
         migrations.AlterField(
             model_name='user',
             name='limits_private_thread_invites_to',
-            field=models.PositiveIntegerField(choices=[(0, 'Everybody'), (1, 'Users I follow'), (2, 'Nobody')], default=0),
+            field=models.PositiveIntegerField(
+                choices=[(0, 'Everybody'), (1, 'Users I follow'), (2, 'Nobody')], default=0
+            ),
         ),
         migrations.AlterField(
             model_name='user',
             name='subscribe_to_replied_threads',
-            field=models.PositiveIntegerField(choices=[(0, 'No'), (1, 'Notify'), (2, 'Notify with e-mail')], default=0),
+            field=models.PositiveIntegerField(
+                choices=[(0, 'No'), (1, 'Notify'), (2, 'Notify with e-mail')], default=0
+            ),
         ),
         migrations.AlterField(
             model_name='user',
             name='subscribe_to_started_threads',
-            field=models.PositiveIntegerField(choices=[(0, 'No'), (1, 'Notify'), (2, 'Notify with e-mail')], default=0),
+            field=models.PositiveIntegerField(
+                choices=[(0, 'No'), (1, 'Notify'), (2, 'Notify with e-mail')], default=0
+            ),
         ),
     ]

+ 1 - 4
misago/users/models/avatar.py

@@ -5,10 +5,7 @@ from misago.users.avatars import store
 
 
 class Avatar(models.Model):
-    user = models.ForeignKey(
-        settings.AUTH_USER_MODEL,
-        on_delete=models.CASCADE
-    )
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
     size = models.PositiveIntegerField(default=0)
     image = models.ImageField(max_length=255, upload_to=store.upload_to)
 

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

@@ -43,11 +43,9 @@ class BansManager(models.Manager):
         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
@@ -60,11 +58,11 @@ class Ban(models.Model):
     EMAIL = 1
     IP = 2
 
-    CHOICES = (
+    CHOICES = [
         (USERNAME, _('Username')),
         (EMAIL, _('E-mail address')),
         (IP, _('IP address')),
-    )
+    ]
 
     check_type = models.PositiveIntegerField(default=USERNAME, db_index=True)
     banned_value = models.CharField(max_length=255, db_index=True)
@@ -112,7 +110,11 @@ class Ban(models.Model):
 
 
 class BanCache(models.Model):
-    user = models.OneToOneField(settings.AUTH_USER_MODEL, primary_key=True, related_name='ban_cache')
+    user = models.OneToOneField(
+        settings.AUTH_USER_MODEL,
+        primary_key=True,
+        related_name='ban_cache',
+    )
     ban = models.ForeignKey(Ban, null=True, blank=True, on_delete=models.SET_NULL)
     bans_version = models.PositiveIntegerField(default=0)
     user_message = models.TextField(null=True, blank=True)
@@ -123,7 +125,7 @@ class BanCache(models.Model):
         try:
             super(BanCache, self).save(*args, **kwargs)
         except IntegrityError:
-            pass # first come is first serve with ban cache
+            pass  # first come is first serve with ban cache
 
     def get_serialized_message(self):
         from misago.users.serializers import BanMessageSerializer
@@ -132,7 +134,7 @@ class BanCache(models.Model):
             check_type=Ban.USERNAME,
             user_message=self.user_message,
             staff_message=self.staff_message,
-            expires_on=self.expires_on
+            expires_on=self.expires_on,
         )
         return BanMessageSerializer(temp_ban).data
 

+ 58 - 64
misago/users/models/user.py

@@ -7,7 +7,6 @@ from django.contrib.auth.password_validation import validate_password
 from django.contrib.postgres.fields import JSONField
 from django.core.mail import send_mail
 from django.db import IntegrityError, models, transaction
-from django.dispatch import receiver
 from django.urls import reverse
 from django.utils import timezone
 from django.utils.translation import ugettext_lazy as _
@@ -17,7 +16,7 @@ from misago.acl.models import Role
 from misago.conf import settings
 from misago.core.utils import slugify
 from misago.users import avatars
-from misago.users.signatures import is_user_signature_valid, make_signature_checksum
+from misago.users.signatures import is_user_signature_valid
 from misago.users.utils import hash_email
 
 from .rank import Rank
@@ -25,7 +24,9 @@ from .rank import Rank
 
 class UserManager(BaseUserManager):
     @transaction.atomic
-    def create_user(self, username, email, password=None, set_default_avatar=False, **extra_fields):
+    def create_user(
+            self, username, email, password=None, set_default_avatar=False, **extra_fields
+    ):
         from misago.users.validators import validate_email, validate_username
 
         email = self.normalize_email(email)
@@ -40,9 +41,9 @@ class UserManager(BaseUserManager):
             extra_fields['joined_from_ip'] = '127.0.0.1'
 
         WATCH_DICT = {
-            'no':  self.model.SUBSCRIBE_NONE,
-            'watch':  self.model.SUBSCRIBE_NOTIFY,
-            'watch_email':  self.model.SUBSCRIBE_ALL,
+            'no': self.model.SUBSCRIBE_NONE,
+            'watch': self.model.SUBSCRIBE_NOTIFY,
+            'watch_email': self.model.SUBSCRIBE_ALL,
         }
 
         if not 'subscribe_to_started_threads' in extra_fields:
@@ -53,17 +54,10 @@ class UserManager(BaseUserManager):
             new_value = WATCH_DICT[settings.subscribe_reply]
             extra_fields['subscribe_to_replied_threads'] = new_value
 
-        extra_fields.update({
-            'is_staff': False,
-            'is_superuser': False
-        })
+        extra_fields.update({'is_staff': False, 'is_superuser': False})
 
         now = timezone.now()
-        user = self.model(
-            last_login=now,
-            joined_on=now,
-            **extra_fields
-        )
+        user = self.model(last_login=now, joined_on=now, **extra_fields)
 
         user.set_username(username)
         user.set_email(email)
@@ -79,8 +73,9 @@ class UserManager(BaseUserManager):
         user.save(using=self._db)
 
         if set_default_avatar:
-            avatars.set_default_avatar(user, settings.default_avatar,
-                                       settings.default_gravatar_fallback)
+            avatars.set_default_avatar(
+                user, settings.default_avatar, settings.default_gravatar_fallback
+            )
         else:
             # just for test purposes
             user.avatars = [{'size': 400, 'url': '/placekitten.com/400/400'}]
@@ -102,9 +97,10 @@ class UserManager(BaseUserManager):
         return user
 
     @transaction.atomic
-    def create_superuser(self, username, email, password,
-                         set_default_avatar=False):
-        user = self.create_user(username, email,
+    def create_superuser(self, username, email, password, set_default_avatar=False):
+        user = self.create_user(
+            username,
+            email,
             password=password,
             set_default_avatar=set_default_avatar,
         )
@@ -143,39 +139,36 @@ class User(AbstractBaseUser, PermissionsMixin):
     SUBSCRIBE_NOTIFY = 1
     SUBSCRIBE_ALL = 2
 
-    SUBSCRIBE_CHOICES = (
+    SUBSCRIBE_CHOICES = [
         (SUBSCRIBE_NONE, _("No")),
         (SUBSCRIBE_NOTIFY, _("Notify")),
-        (SUBSCRIBE_ALL, _("Notify with e-mail"))
-    )
+        (SUBSCRIBE_ALL, _("Notify with e-mail")),
+    ]
 
     LIMIT_INVITES_TO_NONE = 0
     LIMIT_INVITES_TO_FOLLOWED = 1
     LIMIT_INVITES_TO_NOBODY = 2
 
-    LIMIT_INVITES_TO_CHOICES = (
+    LIMIT_INVITES_TO_CHOICES = [
         (LIMIT_INVITES_TO_NONE, _("Everybody")),
         (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
-    differences in case handling.
-    """
+    ]
+    # 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
+    # differences in case handling.
     username = models.CharField(max_length=30)
     slug = models.CharField(max_length=30, unique=True)
-    """
-    Misago stores user email in two fields:
-    "email" holds normalized email address
-    "email_hash" is lowercase hash of email address used to identify account
-    as well as enforcing on database level that no more than one user can be
-    using one email address
-    """
+
+    # Misago stores user email in two fields:
+    # "email" holds normalized email address
+    # "email_hash" is lowercase hash of email address used to identify account
+    # as well as enforcing on database level that no more than one user can be
+    # using one email address
     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_from_ip = models.GenericIPAddressField()
     last_ip = models.GenericIPAddressField(null=True, blank=True)
@@ -185,7 +178,8 @@ class User(AbstractBaseUser, PermissionsMixin):
     title = models.CharField(max_length=255, null=True, blank=True)
     requires_activation = models.PositiveIntegerField(default=ACTIVATION_NONE)
 
-    is_staff = models.BooleanField(_('staff status'),
+    is_staff = models.BooleanField(
+        _('staff status'),
         default=False,
         help_text=_('Designates whether the user can log into admin sites.'),
     )
@@ -208,13 +202,13 @@ class User(AbstractBaseUser, PermissionsMixin):
         max_length=255,
         upload_to=avatars.store.upload_to,
         null=True,
-        blank=True
+        blank=True,
     )
     avatar_src = models.ImageField(
         max_length=255,
         upload_to=avatars.store.upload_to,
         null=True,
-        blank=True
+        blank=True,
     )
     avatar_crop = models.CharField(max_length=255, null=True, blank=True)
     avatars = JSONField(null=True, blank=True)
@@ -232,11 +226,13 @@ class User(AbstractBaseUser, PermissionsMixin):
     followers = models.PositiveIntegerField(default=0)
     following = models.PositiveIntegerField(default=0)
 
-    follows = models.ManyToManyField('self',
+    follows = models.ManyToManyField(
+        'self',
         related_name='followed_by',
         symmetrical=False,
     )
-    blocks = models.ManyToManyField('self',
+    blocks = models.ManyToManyField(
+        'self',
         related_name='blocked_by',
         symmetrical=False,
     )
@@ -250,11 +246,11 @@ class User(AbstractBaseUser, PermissionsMixin):
 
     subscribe_to_started_threads = models.PositiveIntegerField(
         default=SUBSCRIBE_NONE,
-        choices=SUBSCRIBE_CHOICES
+        choices=SUBSCRIBE_CHOICES,
     )
     subscribe_to_replied_threads = models.PositiveIntegerField(
         default=SUBSCRIBE_NONE,
-        choices=SUBSCRIBE_CHOICES
+        choices=SUBSCRIBE_CHOICES,
     )
 
     threads = models.PositiveIntegerField(default=0)
@@ -272,7 +268,7 @@ class User(AbstractBaseUser, PermissionsMixin):
         self.email = self.__class__.objects.normalize_email(self.email)
 
     def lock(self):
-        """Locks user in DB"""
+        """locks user in DB, shortcut for locking user model in views"""
         return User.objects.select_for_update().get(pk=self.pk)
 
     def delete(self, *args, **kwargs):
@@ -331,15 +327,15 @@ 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
-        """
+        """dirty hack: return real username instead of normalized slug"""
         return self.username
 
     def get_full_name(self):
@@ -357,8 +353,7 @@ class User(AbstractBaseUser, PermissionsMixin):
 
             if self.pk:
                 changed_by = changed_by or self
-                self.record_name_change(
-                    changed_by, new_username, old_username)
+                self.record_name_change(changed_by, new_username, old_username)
 
                 from misago.users.signals import username_changed
                 username_changed.send(sender=self)
@@ -407,9 +402,7 @@ class User(AbstractBaseUser, PermissionsMixin):
         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.
-        """
+        """sends an email to this user (for compat with Django)"""
         send_mail(subject, message, from_email, [self.email], **kwargs)
 
     def is_following(self, user):
@@ -440,14 +433,15 @@ class Online(models.Model):
         try:
             super(Online, self).save(*args, **kwargs)
         except IntegrityError:
-            pass # first come is first serve in online tracker
+            pass  # first come is first serve in online tracker
 
 
 class UsernameChange(models.Model):
-    user = models.ForeignKey(settings.AUTH_USER_MODEL,
-        related_name='namechanges')
-    changed_by = models.ForeignKey(settings.AUTH_USER_MODEL,
-        null=True, blank=True,
+    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='namechanges')
+    changed_by = models.ForeignKey(
+        settings.AUTH_USER_MODEL,
+        null=True,
+        blank=True,
         related_name='user_renames',
         on_delete=models.SET_NULL,
     )

+ 0 - 1
misago/users/online/utils.py

@@ -17,7 +17,6 @@ def get_user_status(viewer, user):
         'is_offline_hidden': False,
         'is_online': False,
         'is_offline': False,
-
         'banned_until': None,
         'last_click': user.last_login or user.joined_on,
     }

+ 14 - 15
misago/users/permissions/account.py

@@ -6,22 +6,19 @@ from misago.acl.models import Role
 from misago.core.forms import YesNoSwitch
 
 
-"""
-Admin Permissions Form
-"""
 class PermissionsForm(forms.Form):
     legend = _("Account settings")
 
     name_changes_allowed = forms.IntegerField(
-        label=_("Allowed username changes number"),
-        min_value=0,
-        initial=1
+        label=_("Allowed username changes number"), min_value=0, initial=1
     )
     name_changes_expire = forms.IntegerField(
         label=_("Don't count username changes older than"),
-        help_text=_("Number of days since name change that makes "
-                    "that change no longer count to limit. Enter "
-                    "zero to make all changes count."),
+        help_text=_(
+            "Number of days since name change that makes "
+            "that change no longer count to limit. Enter "
+            "zero to make all changes count."
+        ),
         min_value=0,
         initial=0
     )
@@ -30,8 +27,10 @@ class PermissionsForm(forms.Form):
     allow_signature_images = YesNoSwitch(label=_("Can put images in signature"))
     allow_signature_blocks = YesNoSwitch(
         label=_("Can use text blocks in signature"),
-        help_text=_("Controls whether or not users can put quote, code, "
-                    "spoiler blocks and horizontal lines in signatures.")
+        help_text=_(
+            "Controls whether or not users can put quote, code, "
+            "spoiler blocks and horizontal lines in signatures."
+        )
     )
 
 
@@ -42,9 +41,6 @@ def change_permissions_form(role):
         return None
 
 
-"""
-ACL Builder
-"""
 def build_acl(acl, roles, key_name):
     new_acl = {
         'name_changes_allowed': 0,
@@ -56,7 +52,10 @@ def build_acl(acl, roles, key_name):
     }
     new_acl.update(acl)
 
-    return algebra.sum_acls(new_acl, roles=roles, key=key_name,
+    return algebra.sum_acls(
+        new_acl,
+        roles=roles,
+        key=key_name,
         name_changes_allowed=algebra.greater,
         name_changes_expire=algebra.lower_non_zero,
         can_have_signature=algebra.greater,

+ 2 - 0
misago/users/permissions/decorators.py

@@ -15,6 +15,7 @@ def authenticated_only(f):
         else:
             messsage = _("You have to sig in to perform this action.")
             raise PermissionDenied(messsage)
+
     return perm_decorator
 
 
@@ -25,4 +26,5 @@ def anonymous_only(f):
         else:
             messsage = _("Only guests can perform this action.")
             raise PermissionDenied(messsage)
+
     return perm_decorator

+ 18 - 24
misago/users/permissions/delete.py

@@ -18,9 +18,6 @@ __all__ = [
 ]
 
 
-"""
-Admin Permissions Form
-"""
 class PermissionsForm(forms.Form):
     legend = _("Deleting users")
 
@@ -28,13 +25,13 @@ class PermissionsForm(forms.Form):
         label=_("Maximum age of deleted account (in days)"),
         help_text=_("Enter zero to disable this check."),
         min_value=0,
-        initial=0
+        initial=0,
     )
     can_delete_users_with_less_posts_than = forms.IntegerField(
         label=_("Maximum number of posts on deleted account"),
         help_text=_("Enter zero to disable this check."),
         min_value=0,
-        initial=0
+        initial=0,
     )
 
 
@@ -45,9 +42,6 @@ def change_permissions_form(role):
         return None
 
 
-"""
-ACL Builder
-"""
 def build_acl(acl, roles, key_name):
     new_acl = {
         'can_delete_users_newer_than': 0,
@@ -55,15 +49,15 @@ def build_acl(acl, roles, key_name):
     }
     new_acl.update(acl)
 
-    return algebra.sum_acls(new_acl, roles=roles, key=key_name,
+    return algebra.sum_acls(
+        new_acl,
+        roles=roles,
+        key=key_name,
         can_delete_users_newer_than=algebra.greater,
-        can_delete_users_with_less_posts_than=algebra.greater
+        can_delete_users_with_less_posts_than=algebra.greater,
     )
 
 
-"""
-ACL's for targets
-"""
 def add_acl_to_user(user, target):
     target.acl['can_delete'] = can_delete_user(user, target)
     if target.acl['can_delete']:
@@ -74,9 +68,6 @@ def register_with(registry):
     registry.acl_annotator(get_user_model(), add_acl_to_user)
 
 
-"""
-ACL tests
-"""
 def allow_delete_user(user, target):
     newer_than = user.acl_cache['can_delete_users_newer_than']
     less_posts_than = user.acl_cache['can_delete_users_with_less_posts_than']
@@ -90,17 +81,20 @@ def allow_delete_user(user, target):
 
     if newer_than:
         if target.joined_on < timezone.now() - timedelta(days=newer_than):
-            message = ungettext("You can't delete users that are "
-                                "members for more than %(days)s day.",
-                                "You can't delete users that are "
-                                "members for more than %(days)s days.",
-                                newer_than) % {'days': newer_than}
-            raise PermissionDenied(message)
+            message = ungettext(
+                "You can't delete users that are members for more than %(days)s day.",
+                "You can't delete users that are members for more than %(days)s days.",
+                newer_than,
+            )
+            raise PermissionDenied(message % {'days': newer_than})
     if less_posts_than:
         if target.posts > less_posts_than:
             message = ungettext(
                 "You can't delete users that made more than %(posts)s post.",
                 "You can't delete users that made more than %(posts)s posts.",
-                less_posts_than) % {'posts': less_posts_than}
-            raise PermissionDenied(message)
+                less_posts_than,
+            )
+            raise PermissionDenied(message % {'posts': less_posts_than})
+
+
 can_delete_user = return_boolean(allow_delete_user)

+ 21 - 22
misago/users/permissions/moderation.py

@@ -28,9 +28,6 @@ __all__ = [
 ]
 
 
-"""
-Admin Permissions Form
-"""
 class PermissionsForm(forms.Form):
     legend = _("Users moderation")
 
@@ -42,14 +39,14 @@ class PermissionsForm(forms.Form):
         label=_("Max length, in days, of imposed ban"),
         help_text=_("Enter zero to let moderators impose permanent bans."),
         min_value=0,
-        initial=0
+        initial=0,
     )
     can_lift_bans = YesNoSwitch(label=_("Can lift bans"))
     max_lifted_ban_length = forms.IntegerField(
         label=_("Max length, in days, of lifted ban"),
         help_text=_("Enter zero to let moderators lift permanent bans."),
         min_value=0,
-        initial=0
+        initial=0,
     )
 
 
@@ -60,9 +57,6 @@ def change_permissions_form(role):
         return None
 
 
-"""
-ACL Builder
-"""
 def build_acl(acl, roles, key_name):
     new_acl = {
         'can_rename_users': 0,
@@ -75,20 +69,20 @@ def build_acl(acl, roles, key_name):
     }
     new_acl.update(acl)
 
-    return algebra.sum_acls(new_acl, roles=roles, key=key_name,
+    return algebra.sum_acls(
+        new_acl,
+        roles=roles,
+        key=key_name,
         can_rename_users=algebra.greater,
         can_moderate_avatars=algebra.greater,
         can_moderate_signatures=algebra.greater,
         can_ban_users=algebra.greater,
         max_ban_length=algebra.greater_or_zero,
         can_lift_bans=algebra.greater,
-        max_lifted_ban_length=algebra.greater_or_zero
+        max_lifted_ban_length=algebra.greater_or_zero,
     )
 
 
-"""
-ACL's for targets
-"""
 def add_acl_to_user(user, target):
     target.acl['can_rename'] = can_rename_user(user, target)
     target.acl['can_moderate_avatar'] = can_moderate_avatar(user, target)
@@ -97,12 +91,12 @@ def add_acl_to_user(user, target):
     target.acl['max_ban_length'] = user.acl_cache['max_ban_length']
     target.acl['can_lift_ban'] = can_lift_ban(user, target)
 
-    mod_permissions = (
+    mod_permissions = [
         'can_rename',
         'can_moderate_avatar',
         'can_moderate_signature',
         'can_ban',
-    )
+    ]
 
     for permission in mod_permissions:
         if target.acl[permission]:
@@ -114,14 +108,13 @@ def register_with(registry):
     registry.acl_annotator(get_user_model(), add_acl_to_user)
 
 
-"""
-ACL tests
-"""
 def allow_rename_user(user, target):
     if not user.acl_cache['can_rename_users']:
         raise PermissionDenied(_("You can't rename users."))
     if not user.is_superuser and (target.is_staff or target.is_superuser):
         raise PermissionDenied(_("You can't rename administrators."))
+
+
 can_rename_user = return_boolean(allow_rename_user)
 
 
@@ -130,6 +123,8 @@ def allow_moderate_avatar(user, target):
         raise PermissionDenied(_("You can't moderate avatars."))
     if not user.is_superuser and (target.is_staff or target.is_superuser):
         raise PermissionDenied(_("You can't moderate administrators avatars."))
+
+
 can_moderate_avatar = return_boolean(allow_moderate_avatar)
 
 
@@ -139,6 +134,8 @@ def allow_moderate_signature(user, target):
     if not user.is_superuser and (target.is_staff or target.is_superuser):
         message = _("You can't moderate administrators signatures.")
         raise PermissionDenied(message)
+
+
 can_moderate_signature = return_boolean(allow_moderate_signature)
 
 
@@ -147,6 +144,8 @@ def allow_ban_user(user, target):
         raise PermissionDenied(_("You can't ban users."))
     if target.is_staff or target.is_superuser:
         raise PermissionDenied(_("You can't ban administrators."))
+
+
 can_ban_user = return_boolean(allow_ban_user)
 
 
@@ -162,8 +161,8 @@ def allow_lift_ban(user, target):
         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.")
-            message = message % {'expiration': format_date(lift_cutoff)}
-            raise PermissionDenied(message)
+            message = _("You can't lift bans that expire after %(expiration)s.")
+            raise PermissionDenied(message % {'expiration': format_date(lift_cutoff)})
+
+
 can_lift_ban = return_boolean(allow_lift_ban)

+ 22 - 48
misago/users/permissions/profiles.py

@@ -22,21 +22,9 @@ __all__ = [
     'can_see_ban_details',
 ]
 
-
-"""
-Admin Permissions Form
-"""
-CAN_BROWSE_USERS_LIST = YesNoSwitch(
-    label=_("Can browse users list"),
-    initial=1
-)
-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_BROWSE_USERS_LIST = YesNoSwitch(label=_("Can browse users list"), initial=1)
+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.")
@@ -55,25 +43,13 @@ class LimitedPermissionsForm(forms.Form):
 class PermissionsForm(LimitedPermissionsForm):
     can_browse_users_list = CAN_BROWSE_USERS_LIST
     can_search_users = CAN_SEARCH_USERS
-    can_follow_users = YesNoSwitch(
-        label=_("Can follow other users"),
-        initial=1
-    )
-    can_be_blocked = YesNoSwitch(
-        label=_("Can be blocked by other users"),
-        initial=0
-    )
+    can_follow_users = YesNoSwitch(label=_("Can follow other users"), initial=1)
+    can_be_blocked = YesNoSwitch(label=_("Can be blocked by other users"), initial=0)
     can_see_users_name_history = CAN_SEE_USER_NAME_HISTORY
     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_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"))
 
 
 def change_permissions_form(role):
@@ -86,9 +62,6 @@ def change_permissions_form(role):
         return None
 
 
-"""
-ACL Builder
-"""
 def build_acl(acl, roles, key_name):
     new_acl = {
         'can_browse_users_list': 0,
@@ -103,7 +76,10 @@ def build_acl(acl, roles, key_name):
     }
     new_acl.update(acl)
 
-    return algebra.sum_acls(new_acl, roles=roles, key=key_name,
+    return algebra.sum_acls(
+        new_acl,
+        roles=roles,
+        key=key_name,
         can_browse_users_list=algebra.greater,
         can_search_users=algebra.greater,
         can_follow_users=algebra.greater,
@@ -112,23 +88,16 @@ def build_acl(acl, roles, key_name):
         can_see_ban_details=algebra.greater,
         can_see_users_emails=algebra.greater,
         can_see_users_ips=algebra.greater,
-        can_see_hidden_users=algebra.greater
+        can_see_hidden_users=algebra.greater,
     )
 
 
-"""
-ACL's for targets
-"""
 def add_acl_to_user(user, target):
     target.acl['can_have_attitude'] = False
     target.acl['can_follow'] = can_follow_user(user, target)
     target.acl['can_block'] = can_block_user(user, target)
 
-    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]:
@@ -140,12 +109,11 @@ def register_with(registry):
     registry.acl_annotator(get_user_model(), add_acl_to_user)
 
 
-"""
-ACL tests
-"""
 def allow_browse_users_list(user):
     if not user.acl_cache['can_browse_users_list']:
         raise PermissionDenied(_("You can't browse users list."))
+
+
 can_browse_users_list = return_boolean(allow_browse_users_list)
 
 
@@ -155,6 +123,8 @@ def allow_follow_user(user, target):
         raise PermissionDenied(_("You can't follow other users."))
     if user.pk == target.pk:
         raise PermissionDenied(_("You can't add yourself to followed."))
+
+
 can_follow_user = return_boolean(allow_follow_user)
 
 
@@ -167,6 +137,8 @@ def allow_block_user(user, target):
     if not target.acl_cache['can_be_blocked'] or target.is_superuser:
         message = _("%(user)s can't be blocked.") % {'user': target.username}
         raise PermissionDenied(message)
+
+
 can_block_user = return_boolean(allow_block_user)
 
 
@@ -174,4 +146,6 @@ can_block_user = return_boolean(allow_block_user)
 def allow_see_ban_details(user, target):
     if not user.acl_cache['can_see_ban_details']:
         raise PermissionDenied(_("You can't see users bans details."))
+
+
 can_see_ban_details = return_boolean(allow_see_ban_details)

+ 9 - 14
misago/users/search.py

@@ -20,27 +20,21 @@ class SearchUsers(SearchProvider):
 
     def allow_search(self):
         if not self.request.user.acl_cache['can_search_users']:
-            raise PermissionDenied(
-                _("You don't have permission to search users."))
+            raise PermissionDenied(_("You don't have permission to search users."))
 
     def search(self, query, page=1):
         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)
-        }
+        return {'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')
+        'rank', 'ban_cache', 'online_tracker'
+    )
 
     if not filters.get('search_disabled', False):
         queryset = queryset.filter(is_active=True)
@@ -51,8 +45,9 @@ def search_users(**filters):
 
     # lets grab head and tail results:
     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])[:TAIL_RESULTS])
+    results += list(
+        queryset.filter(slug__contains=username).exclude(pk__in=[r.pk
+                                                                 for r in results])[:TAIL_RESULTS]
+    )
 
     return results

+ 15 - 15
misago/users/serializers/auth.py

@@ -31,15 +31,14 @@ class AuthenticatedUserSerializer(UserSerializer, AuthFlags):
 
     class Meta:
         model = UserModel
-        fields = UserSerializer.Meta.fields + (
+        fields = UserSerializer.Meta.fields + [
             'is_hiding_presence',
             'limits_private_thread_invites_to',
             'subscribe_to_started_threads',
             'subscribe_to_replied_threads',
-
             'is_authenticated',
             'is_anonymous',
-        )
+        ]
 
     def get_acl(self, obj):
         return serialize_acl(obj)
@@ -49,21 +48,22 @@ class AuthenticatedUserSerializer(UserSerializer, AuthFlags):
 
     def get_api_url(self, obj):
         return {
-            'avatar': reverse(
-                'misago:api:user-avatar', kwargs={'pk': obj.pk}),
-            'options': reverse(
-                'misago:api:user-forum-options', kwargs={'pk': obj.pk}),
-            'username': reverse(
-                'misago:api:user-username', 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}),
+            'avatar': reverse('misago:api:user-avatar', kwargs={'pk': obj.pk}),
+            'options': reverse('misago:api:user-forum-options', kwargs={'pk': obj.pk}),
+            'username': reverse('misago:api:user-username', 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}),
         }
 
+
 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',
 )
 
 

+ 10 - 3
misago/users/serializers/ban.py

@@ -16,7 +16,7 @@ def serialize_message(message):
     if message:
         return {
             'plain': message,
-            'html': format_plaintext_for_html(message)
+            'html': format_plaintext_for_html(message),
         }
     else:
         return None
@@ -27,7 +27,10 @@ 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:
@@ -46,7 +49,11 @@ 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)

+ 9 - 5
misago/users/serializers/moderation.py

@@ -13,6 +13,7 @@ __all__ = [
     'ModerateSignatureSerializer',
 ]
 
+
 class ModerateAvatarSerializer(serializers.ModelSerializer):
     class Meta:
         model = UserModel
@@ -30,15 +31,18 @@ class ModerateSignatureSerializer(serializers.ModelSerializer):
             'signature',
             'is_signature_locked',
             'signature_lock_user_message',
-            'signature_lock_staff_message'
+            'signature_lock_staff_message',
         ]
 
     def validate_signature(self, value):
         length_limit = settings.signature_length_max
         if len(value) > length_limit:
-            raise forms.ValidationError(ungettext(
-                "Signature can't be longer than %(limit)s character.",
-                "Signature can't be longer than %(limit)s characters.",
-                length_limit) % {'limit': length_limit})
+            raise serializers.ValidationError(
+                ungettext(
+                    "Signature can't be longer than %(limit)s character.",
+                    "Signature can't be longer than %(limit)s characters.",
+                    length_limit,
+                ) % {'limit': length_limit}
+            )
 
         return value

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

@@ -23,10 +23,8 @@ 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': {
@@ -63,16 +61,14 @@ class ChangeUsernameSerializer(serializers.Serializer):
             raise serializers.ValidationError(_("Enter new username."))
 
         if username == self.context['user'].username:
-            raise serializers.ValidationError(
-                _("New username is same as current one."))
+            raise serializers.ValidationError(_("New username is same as current one."))
 
         validate_username(username)
 
         return data
 
     def change_username(self, changed_by):
-        self.context['user'].set_username(
-            self.validated_data['username'], changed_by=changed_by)
+        self.context['user'].set_username(self.validated_data['username'], changed_by=changed_by)
         self.context['user'].save(update_fields=['username', 'slug'])
 
 

+ 2 - 2
misago/users/serializers/rank.py

@@ -13,7 +13,7 @@ class RankSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = Rank
-        fields = (
+        fields = [
             'id',
             'name',
             'slug',
@@ -23,7 +23,7 @@ class RankSerializer(serializers.ModelSerializer):
             'is_default',
             'is_tab',
             'absolute_url',
-        )
+        ]
 
     def get_description(self, obj):
         if obj.description:

+ 17 - 12
misago/users/serializers/user.py

@@ -41,7 +41,7 @@ class UserSerializer(serializers.ModelSerializer, MutableFields):
 
     class Meta:
         model = UserModel
-        fields = (
+        fields = [
             'id',
             'username',
             'slug',
@@ -57,23 +57,20 @@ class UserSerializer(serializers.ModelSerializer, MutableFields):
             'following',
             'threads',
             'posts',
-
             'acl',
             'is_followed',
             'is_blocked',
             'meta',
             'status',
-
             'absolute_url',
             'api_url',
-        )
+        ]
 
     def get_acl(self, obj):
         return obj.acl
 
     def get_email(self, obj):
-        if (obj == self.context['user'] or
-                self.context['user'].acl_cache['can_see_users_emails']):
+        if (obj == self.context['user'] or self.context['user'].acl_cache['can_see_users_emails']):
             return obj.email
         else:
             return None
@@ -113,10 +110,8 @@ class UserSerializer(serializers.ModelSerializer, MutableFields):
             'root': 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}),
-            'moderate_avatar': reverse(
-                'misago:api:user-moderate-avatar', kwargs={'pk': obj.pk}),
-            'moderate_username': reverse(
-                'misago:api:user-moderate-username', 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}),
@@ -126,5 +121,15 @@ class UserSerializer(serializers.ModelSerializer, MutableFields):
 
 
 UserCardSerializer = UserSerializer.subset_fields(
-    'id', 'username', 'joined_on', 'rank', 'title', 'avatars', 'followers',
-    'threads', 'posts', 'status', 'absolute_url')
+    'id',
+    'username',
+    'joined_on',
+    'rank',
+    'title',
+    'avatars',
+    'followers',
+    'threads',
+    'posts',
+    'status',
+    'absolute_url',
+)

+ 4 - 6
misago/users/serializers/usernamechange.py

@@ -7,9 +7,7 @@ from .user import UserSerializer as BaseUserSerializer
 
 __all__ = ['UsernameChangeSerializer']
 
-
-UserSerializer = BaseUserSerializer.subset_fields(
-    'id', 'username', 'avatars', 'absolute_url')
+UserSerializer = BaseUserSerializer.subset_fields('id', 'username', 'avatars', 'absolute_url')
 
 
 class UsernameChangeSerializer(serializers.ModelSerializer):
@@ -18,12 +16,12 @@ class UsernameChangeSerializer(serializers.ModelSerializer):
 
     class Meta:
         model = UsernameChange
-        fields = (
+        fields = [
             'id',
             'user',
             'changed_by',
             'changed_by_username',
             'changed_on',
             'new_username',
-            'old_username'
-        )
+            'old_username',
+        ]

+ 1 - 6
misago/users/signals.py

@@ -5,11 +5,6 @@ delete_user_content = Signal()
 username_changed = Signal()
 
 
-"""
-Signal handlers
-"""
 @receiver(username_changed)
 def handle_name_change(sender, **kwargs):
-    sender.user_renames.update(
-        changed_by_username=sender.username
-    )
+    sender.user_renames.update(changed_by_username=sender.username)

+ 1 - 1
misago/users/signatures.py

@@ -6,7 +6,7 @@ def set_user_signature(request, user, signature):
 
     if signature:
         user.signature_parsed = signature_flavour(request, user, signature)
-        user.signature_checksum = make_signature_checksum( user.signature_parsed, user)
+        user.signature_checksum = make_signature_checksum(user.signature_parsed, user)
     else:
         user.signature_parsed = ''
         user.signature_checksum = ''

+ 0 - 3
misago/users/templatetags/misago_avatars.py

@@ -1,7 +1,4 @@
 from django import template
-from django.urls import reverse
-
-from misago.conf import settings
 
 
 register = template.Library()

+ 55 - 28
misago/users/tests/test_activation_views.py

@@ -19,7 +19,8 @@ class ActivationViewsTests(TestCase):
     def test_view_activate_banned(self):
         """activate banned user shows error"""
         test_user = UserModel.objects.create_user(
-            'Bob', 'bob@test.com', 'Pass.123', requires_activation=1)
+            'Bob', 'bob@test.com', 'Pass.123', requires_activation=1
+        )
         Ban.objects.create(
             check_type=Ban.USERNAME,
             banned_value='bob',
@@ -28,12 +29,16 @@ class ActivationViewsTests(TestCase):
 
         activation_token = make_activation_token(test_user)
 
-        response = self.client.get(reverse('misago:activate-by-token', kwargs={
-            'pk': test_user.pk,
-            'token': activation_token,
-        }))
-        self.assertContains(
-            response, encode_json_html("<p>Nope!</p>"), status_code=403)
+        response = self.client.get(
+            reverse(
+                'misago:activate-by-token',
+                kwargs={
+                    'pk': test_user.pk,
+                    'token': activation_token,
+                }
+            )
+        )
+        self.assertContains(response, encode_json_html("<p>Nope!</p>"), status_code=403)
 
         test_user = UserModel.objects.get(pk=test_user.pk)
         self.assertEqual(test_user.requires_activation, 1)
@@ -41,14 +46,20 @@ class ActivationViewsTests(TestCase):
     def test_view_activate_invalid_token(self):
         """activate with invalid token shows error"""
         test_user = UserModel.objects.create_user(
-            'Bob', 'bob@test.com', 'Pass.123', requires_activation=1)
+            'Bob', 'bob@test.com', 'Pass.123', 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',
-        }))
+        response = self.client.get(
+            reverse(
+                'misago:activate-by-token',
+                kwargs={
+                    'pk': test_user.pk,
+                    'token': activation_token + 'acd',
+                }
+            )
+        )
         self.assertEqual(response.status_code, 400)
 
         test_user = UserModel.objects.get(pk=test_user.pk)
@@ -57,27 +68,37 @@ class ActivationViewsTests(TestCase):
     def test_view_activate_disabled(self):
         """activate disabled user shows error"""
         test_user = UserModel.objects.create_user(
-            'Bob', 'bob@test.com', 'Pass.123', is_active=False)
+            'Bob', 'bob@test.com', 'Pass.123', 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,
-        }))
+        response = self.client.get(
+            reverse(
+                '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 = UserModel.objects.create_user(
-            'Bob', 'bob@test.com', 'Pass.123')
+        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
 
         activation_token = make_activation_token(test_user)
 
-        response = self.client.get(reverse('misago:activate-by-token', kwargs={
-            'pk': test_user.pk,
-            'token': activation_token,
-        }))
+        response = self.client.get(
+            reverse(
+                'misago:activate-by-token',
+                kwargs={
+                    'pk': test_user.pk,
+                    'token': activation_token,
+                }
+            )
+        )
         self.assertEqual(response.status_code, 200)
 
         test_user = UserModel.objects.get(pk=test_user.pk)
@@ -86,14 +107,20 @@ class ActivationViewsTests(TestCase):
     def test_view_activate_inactive(self):
         """activate inactive user passess"""
         test_user = UserModel.objects.create_user(
-            'Bob', 'bob@test.com', 'Pass.123', requires_activation=1)
+            'Bob', 'bob@test.com', 'Pass.123', 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,
-        }))
+        response = self.client.get(
+            reverse(
+                'misago:activate-by-token',
+                kwargs={
+                    'pk': test_user.pk,
+                    'token': activation_token,
+                }
+            )
+        )
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, "your account has been activated!")
 

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

@@ -36,8 +36,7 @@ class TestActivePostersRanking(AuthenticatedUserTestCase):
         self.assertEqual(empty_ranking['users_count'], 0)
 
         # other user
-        other_user = UserModel.objects.create_user(
-            "OtherUser", "other@user.com", "pass123")
+        other_user = UserModel.objects.create_user("OtherUser", "other@user.com", "pass123")
 
         other_user.posts = 1
         other_user.save()
@@ -67,8 +66,7 @@ class TestActivePostersRanking(AuthenticatedUserTestCase):
         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()

+ 154 - 78
misago/users/tests/test_auth_api.py

@@ -13,8 +13,11 @@ 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.assertContains(response, "Login or password is incorrect.", status_code=400)
 
@@ -28,10 +31,13 @@ class GatewayTests(TestCase):
         """api signs user in"""
         user = UserModel.objects.create_user('Bob', 'bob@test.com', 'Pass.123')
 
-        response = self.client.post('/api/auth/', data={
-            'username': 'Bob',
-            'password': 'Pass.123',
-        })
+        response = self.client.post(
+            '/api/auth/',
+            data={
+                'username': 'Bob',
+                'password': 'Pass.123',
+            },
+        )
 
         self.assertEqual(response.status_code, 200)
 
@@ -57,18 +63,21 @@ class GatewayTests(TestCase):
             user_message='You are tragically banned.',
         )
 
-        response = self.client.post('/api/auth/', data={
-            'username': 'Bob',
-            'password': 'Pass.123',
-        })
+        response = self.client.post(
+            '/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['detail']['message']['html'],
-                         '<p>%s</p>' % ban.user_message)
+        self.assertEqual(response_json['detail']['message']['plain'], ban.user_message)
+        self.assertEqual(
+            response_json['detail']['message']['html'], '<p>%s</p>' % ban.user_message
+        )
 
         response = self.client.get('/api/auth/')
         self.assertEqual(response.status_code, 200)
@@ -83,16 +92,19 @@ class GatewayTests(TestCase):
         user.is_staff = True
         user.save()
 
-        ban = Ban.objects.create(
+        Ban.objects.create(
             check_type=Ban.USERNAME,
             banned_value='bob',
             user_message='You are tragically banned.',
         )
 
-        response = self.client.post('/api/auth/', data={
-            'username': 'Bob',
-            'password': 'Pass.123',
-        })
+        response = self.client.post(
+            '/api/auth/',
+            data={
+                'username': 'Bob',
+                'password': 'Pass.123',
+            },
+        )
         self.assertEqual(response.status_code, 200)
 
         response = self.client.get('/api/auth/')
@@ -104,13 +116,15 @@ class GatewayTests(TestCase):
 
     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',
-        })
+        response = self.client.post(
+            '/api/auth/',
+            data={
+                'username': 'Bob',
+                'password': 'Pass.123',
+            },
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -124,13 +138,15 @@ class GatewayTests(TestCase):
 
     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',
-        })
+        response = self.client.post(
+            '/api/auth/',
+            data={
+                'username': 'Bob',
+                'password': 'Pass.123',
+            },
+        )
         self.assertEqual(response.status_code, 400)
 
         response_json = response.json()
@@ -144,16 +160,18 @@ class GatewayTests(TestCase):
 
     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',
-        })
+        response = self.client.post(
+            '/api/auth/',
+            data={
+                'username': 'Bob',
+                'password': 'Pass.123',
+            },
+        )
         self.assertContains(response, "Login or password is incorrect.", status_code=400)
 
         response = self.client.get('/api/auth/')
@@ -180,7 +198,12 @@ class SendActivationAPITests(TestCase):
 
     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)
@@ -193,7 +216,12 @@ class SendActivationAPITests(TestCase):
             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)
@@ -203,7 +231,12 @@ class SendActivationAPITests(TestCase):
         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.assertContains(response, 'not_found', status_code=400)
 
         self.assertTrue(not mail.outbox)
@@ -217,7 +250,12 @@ class SendActivationAPITests(TestCase):
 
     def test_submit_invalid(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.assertContains(response, 'not_found', status_code=400)
 
         self.assertTrue(not mail.outbox)
@@ -227,7 +265,12 @@ 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.assertContains(response, 'Bob, your account is already active.', status_code=400)
 
     def test_submit_inactive_user(self):
@@ -235,7 +278,12 @@ class SendActivationAPITests(TestCase):
         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.assertContains(response, 'inactive_admin', status_code=400)
 
         self.assertTrue(not mail.outbox)
@@ -244,7 +292,11 @@ 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)
@@ -258,7 +310,12 @@ class SendPasswordFormAPITests(TestCase):
 
     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)
@@ -271,7 +328,12 @@ class SendPasswordFormAPITests(TestCase):
             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)
@@ -281,7 +343,12 @@ class SendPasswordFormAPITests(TestCase):
         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.assertContains(response, 'not_found', status_code=400)
 
         self.assertTrue(not mail.outbox)
@@ -295,7 +362,12 @@ class SendPasswordFormAPITests(TestCase):
 
     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.assertContains(response, 'not_found', status_code=400)
 
         self.assertTrue(not mail.outbox)
@@ -305,13 +377,23 @@ class SendPasswordFormAPITests(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.assertContains(response, 'inactive_user', status_code=400)
 
         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.assertContains(response, 'inactive_admin', status_code=400)
 
         self.assertTrue(not mail.outbox)
@@ -325,10 +407,12 @@ class ChangePasswordAPITests(TestCase):
 
     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!'})
+        response = self.client.post(
+            self.link % (self.user.pk, make_password_change_token(self.user)),
+            data={
+                'password': 'n3wp4ss!',
+            },
+        )
         self.assertEqual(response.status_code, 200)
 
         user = UserModel.objects.get(id=self.user.pk)
@@ -336,10 +420,7 @@ class ChangePasswordAPITests(TestCase):
 
     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.assertContains(response, "Form link is invalid.", status_code=400)
 
@@ -351,10 +432,9 @@ class ChangePasswordAPITests(TestCase):
             user_message='Nope!',
         )
 
-        response = self.client.post(self.link % (
-            self.user.pk,
-            make_password_change_token(self.user)
-        ))
+        response = self.client.post(
+            self.link % (self.user.pk, make_password_change_token(self.user))
+        )
         self.assertContains(response, "Your link has expired.", status_code=400)
 
     def test_inactive_user(self):
@@ -362,19 +442,17 @@ class ChangePasswordAPITests(TestCase):
         self.user.requires_activation = 1
         self.user.save()
 
-        response = self.client.post(self.link % (
-            self.user.pk,
-            make_password_change_token(self.user)
-        ))
+        response = self.client.post(
+            self.link % (self.user.pk, make_password_change_token(self.user))
+        )
         self.assertContains(response, "Your link has expired.", status_code=400)
 
         self.user.requires_activation = 2
         self.user.save()
 
-        response = self.client.post(self.link % (
-            self.user.pk,
-            make_password_change_token(self.user)
-        ))
+        response = self.client.post(
+            self.link % (self.user.pk, make_password_change_token(self.user))
+        )
         self.assertContains(response, "Your link has expired.", status_code=400)
 
     def test_disabled_user(self):
@@ -382,16 +460,14 @@ class ChangePasswordAPITests(TestCase):
         self.user.is_active = False
         self.user.save()
 
-        response = self.client.post(self.link % (
-            self.user.pk,
-            make_password_change_token(self.user)
-        ))
+        response = self.client.post(
+            self.link % (self.user.pk, make_password_change_token(self.user))
+        )
         self.assertContains(response, "Form link is invalid.", status_code=400)
 
     def test_submit_empty(self):
         """change password api errors for empty body"""
-        response = self.client.post(self.link % (
-            self.user.pk,
-            make_password_change_token(self.user)
-        ))
+        response = self.client.post(
+            self.link % (self.user.pk, make_password_change_token(self.user))
+        )
         self.assertContains(response, "This password is too shor", status_code=400)

+ 6 - 7
misago/users/tests/test_auth_backend.py

@@ -12,14 +12,13 @@ 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.user = UserModel.objects.create_user('BobBoberson', 'bob@test.com', self.password)
 
     def test_authenticate_username(self):
         """auth authenticates with username"""
         user = backend.authenticate(
             username=self.user.username,
-            password=self.password
+            password=self.password,
         )
 
         self.assertEqual(user, self.user)
@@ -28,7 +27,7 @@ class MisagoBackendTests(TestCase):
         """auth authenticates with email instead of username"""
         user = backend.authenticate(
             username=self.user.email,
-            password=self.password
+            password=self.password,
         )
 
         self.assertEqual(user, self.user)
@@ -37,7 +36,7 @@ class MisagoBackendTests(TestCase):
         """auth handles invalid credentials"""
         user = backend.authenticate(
             username='InvalidCredential',
-            password=self.password
+            password=self.password,
         )
 
         self.assertIsNone(user)
@@ -46,7 +45,7 @@ class MisagoBackendTests(TestCase):
         """auth validates password"""
         user = backend.authenticate(
             username=self.user.email,
-            password='Invalid'
+            password='Invalid',
         )
 
         self.assertIsNone(user)
@@ -58,7 +57,7 @@ class MisagoBackendTests(TestCase):
 
         user = backend.authenticate(
             username=self.user.email,
-            password=self.password
+            password=self.password,
         )
 
         self.assertIsNone(user)

+ 20 - 13
misago/users/tests/test_auth_views.py

@@ -1,9 +1,5 @@
-import json
-
-from django.contrib.auth import get_user_model
 from django.test import TestCase
 from django.urls import reverse
-from django.utils.encoding import smart_str
 
 
 class AuthViewsTests(TestCase):
@@ -24,17 +20,23 @@ class AuthViewsTests(TestCase):
     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/'
-        })
+        response = self.client.post(
+            reverse('misago:login'),
+            data={
+                'redirect_to': '/redirect/',
+            },
+        )
 
         self.assertEqual(response.status_code, 302)
         self.assertEqual(response['location'], '/redirect/')
 
         # invalid redirect (redirects to other site)
-        response = self.client.post(reverse('misago:login'), data={
-            'redirect_to': 'http://somewhereelse.com/page.html'
-        })
+        response = self.client.post(
+            reverse('misago:login'),
+            data={
+                'redirect_to': 'http://somewhereelse.com/page.html',
+            },
+        )
 
         self.assertEqual(response.status_code, 302)
         self.assertEqual(response['location'], '/')
@@ -42,14 +44,19 @@ class AuthViewsTests(TestCase):
     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)
 
         response = self.client.get('/api/auth/')
         self.assertEqual(response.status_code, 200)
 
-        user_json = json.loads(smart_str(response.content))
+        user_json = response.json()
         self.assertIsNone(user_json['id'])
 
         response = self.client.post(reverse('misago:logout'))
@@ -58,5 +65,5 @@ class AuthViewsTests(TestCase):
         response = self.client.get('/api/auth/')
         self.assertEqual(response.status_code, 200)
 
-        user_json = json.loads(smart_str(response.content))
+        user_json = response.json()
         self.assertIsNone(user_json['id'])

+ 19 - 22
misago/users/tests/test_avatars.py

@@ -3,7 +3,7 @@ from PIL import Image
 
 from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
-from django.test import TestCase, override_settings
+from django.test import TestCase
 from django.utils.crypto import get_random_string
 
 from misago.conf import settings
@@ -17,14 +17,13 @@ UserModel = get_user_model()
 class AvatarsStoreTests(TestCase):
     def test_store(self):
         """store successfully stores and deletes avatar"""
-        user = UserModel.objects.create_user(
-            'Bob', 'bob@bob.com', 'pass123')
+        user = UserModel.objects.create_user('Bob', 'bob@bob.com', 'pass123')
 
         test_image = Image.new("RGBA", (100, 100), 0)
         store.store_new_avatar(user, test_image)
 
         # reload user
-        test_user = UserModel.objects.get(pk=user.pk)
+        UserModel.objects.get(pk=user.pk)
 
         # assert that avatars were stored in media
         avatars_dict = {}
@@ -85,8 +84,7 @@ class AvatarsStoreTests(TestCase):
 
 class AvatarSetterTests(TestCase):
     def setUp(self):
-        self.user = UserModel.objects.create_user(
-            'Bob', 'kontakt@rpiton.com', 'pass123')
+        self.user = UserModel.objects.create_user('Bob', 'kontakt@rpiton.com', 'pass123')
 
         self.user.avatars = None
         self.user.save()
@@ -158,7 +156,9 @@ class AvatarSetterTests(TestCase):
     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))
+            get_random_string(6), get_random_string(6), get_random_string(3)
+        )
+
         self.user.set_email(gibberish_email)
         self.user.save()
 
@@ -169,7 +169,8 @@ class AvatarSetterTests(TestCase):
     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))
+            get_random_string(6), get_random_string(6), get_random_string(3)
+        )
         self.user.set_email(gibberish_email)
         self.user.save()
 
@@ -198,22 +199,18 @@ class UploadedAvatarTests(TestCase):
             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"""

+ 26 - 17
misago/users/tests/test_avatarserver_views.py

@@ -15,15 +15,15 @@ class AvatarServerTests(TestCase):
         self.user.avatars = [
             {
                 'size': 200,
-                'url': '/media/avatars/avatar-200.png'
+                'url': '/media/avatars/avatar-200.png',
             },
             {
                 'size': 100,
-                'url': '/media/avatars/avatar-100.png'
+                'url': '/media/avatars/avatar-100.png',
             },
             {
                 'size': 50,
-                'url': '/media/avatars/avatar-50.png'
+                'url': '/media/avatars/avatar-50.png',
             },
         ]
 
@@ -31,34 +31,43 @@ 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,
-        })
+        avatar_url = reverse(
+            '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,
-        })
+        avatar_url = reverse(
+            '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,
-        })
+        avatar_url = reverse(
+            'misago:user-avatar',
+            kwargs={
+                'pk': self.user.pk + 1,
+                'size': 150,
+            },
+        )
         response = self.client.get(avatar_url)
 
         self.assertEqual(response.status_code, 302)

+ 3 - 12
misago/users/tests/test_ban_model.py

@@ -7,18 +7,9 @@ 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(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):

+ 67 - 40
misago/users/tests/test_banadmin_views.py

@@ -1,7 +1,6 @@
-from datetime import date, datetime, timedelta
+from datetime import datetime, timedelta
 
 from django.urls import reverse
-from django.utils.six.moves import range
 
 from misago.admin.testutils import AdminTestCase
 from misago.users.models import Ban
@@ -28,13 +27,16 @@ class BanAdminViewsTests(AdminTestCase):
         test_date = datetime.now() + timedelta(days=180)
 
         for i in range(10):
-            response = self.client.post(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(),
-            })
+            response = self.client.post(
+                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(),
+                },
+            )
             self.assertEqual(response.status_code, 302)
 
         self.assertEqual(Ban.objects.count(), 10)
@@ -43,10 +45,13 @@ class BanAdminViewsTests(AdminTestCase):
         for ban in Ban.objects.iterator():
             bans_pks.append(ban.pk)
 
-        response = self.client.post(reverse('misago:admin:users:bans:index'), data={
-            'action': 'delete',
-            'selected_items': bans_pks
-        })
+        response = self.client.post(
+            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)
 
@@ -57,13 +62,16 @@ class BanAdminViewsTests(AdminTestCase):
 
         test_date = datetime.now() + timedelta(days=180)
 
-        response = self.client.post(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(),
-        })
+        response = self.client.post(
+            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(),
+            },
+        )
         self.assertEqual(response.status_code, 302)
 
         response = self.client.get(reverse('misago:admin:users:bans:index'))
@@ -73,21 +81,32 @@ class BanAdminViewsTests(AdminTestCase):
 
     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',
-        })
+        self.client.post(
+            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})
-
-        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': '',
-        })
+        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': '',
+            },
+        )
         self.assertEqual(response.status_code, 302)
 
         response = self.client.get(reverse('misago:admin:users:bans:index'))
@@ -97,16 +116,24 @@ class BanAdminViewsTests(AdminTestCase):
 
     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',
-        })
+        self.client.post(
+            reverse('misago:admin:users:bans:new'),
+            data={
+                'check_type': '0',
+                '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
-        }))
+        response = self.client.post(
+            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'))

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

@@ -20,7 +20,7 @@ class GetBanTests(TestCase):
 
         Ban.objects.create(
             banned_value='expired',
-            expires_on=timezone.now() - timedelta(days=7)
+            expires_on=timezone.now() - timedelta(days=7),
         )
 
         expired_ban = get_username_ban('expired')
@@ -28,7 +28,7 @@ class GetBanTests(TestCase):
 
         Ban.objects.create(
             banned_value='wrongtype',
-            check_type=Ban.EMAIL
+            check_type=Ban.EMAIL,
         )
 
         wrong_type_ban = get_username_ban('wrongtype')
@@ -36,7 +36,7 @@ class GetBanTests(TestCase):
 
         valid_ban = Ban.objects.create(
             banned_value='admi*',
-            expires_on=timezone.now() + timedelta(days=7)
+            expires_on=timezone.now() + timedelta(days=7),
         )
         self.assertEqual(get_username_ban('admiral').pk, valid_ban.pk)
 
@@ -48,7 +48,7 @@ class GetBanTests(TestCase):
         Ban.objects.create(
             banned_value='ex@pired.com',
             check_type=Ban.EMAIL,
-            expires_on=timezone.now() - timedelta(days=7)
+            expires_on=timezone.now() - timedelta(days=7),
         )
 
         expired_ban = get_email_ban('ex@pired.com')
@@ -56,7 +56,7 @@ class GetBanTests(TestCase):
 
         Ban.objects.create(
             banned_value='wrong@type.com',
-            check_type=Ban.IP
+            check_type=Ban.IP,
         )
 
         wrong_type_ban = get_email_ban('wrong@type.com')
@@ -65,7 +65,7 @@ class GetBanTests(TestCase):
         valid_ban = Ban.objects.create(
             banned_value='*.ru',
             check_type=Ban.EMAIL,
-            expires_on=timezone.now() + timedelta(days=7)
+            expires_on=timezone.now() + timedelta(days=7),
         )
         self.assertEqual(get_email_ban('banned@mail.ru').pk, valid_ban.pk)
 
@@ -77,7 +77,7 @@ class GetBanTests(TestCase):
         Ban.objects.create(
             banned_value='124.0.0.1',
             check_type=Ban.IP,
-            expires_on=timezone.now() - timedelta(days=7)
+            expires_on=timezone.now() - timedelta(days=7),
         )
 
         expired_ban = get_ip_ban('124.0.0.1')
@@ -85,7 +85,7 @@ class GetBanTests(TestCase):
 
         Ban.objects.create(
             banned_value='wrongtype',
-            check_type=Ban.EMAIL
+            check_type=Ban.EMAIL,
         )
 
         wrong_type_ban = get_ip_ban('wrongtype')
@@ -94,15 +94,14 @@ class GetBanTests(TestCase):
         valid_ban = Ban.objects.create(
             banned_value='125.0.0.*',
             check_type=Ban.IP,
-            expires_on=timezone.now() + timedelta(days=7)
+            expires_on=timezone.now() + timedelta(days=7),
         )
         self.assertEqual(get_ip_ban('125.0.0.1').pk, valid_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"""
@@ -114,7 +113,7 @@ class UserBansTests(TestCase):
         Ban.objects.create(
             banned_value='bob',
             user_message='User reason',
-            staff_message='Staff reason'
+            staff_message='Staff reason',
         )
 
         user_ban = get_user_ban(self.user)
@@ -129,7 +128,7 @@ class UserBansTests(TestCase):
             banned_value='bo*',
             user_message='User reason',
             staff_message='Staff reason',
-            expires_on=timezone.now() + timedelta(days=7)
+            expires_on=timezone.now() + timedelta(days=7),
         )
 
         user_ban = get_user_ban(self.user)
@@ -142,7 +141,7 @@ class UserBansTests(TestCase):
         """user is not caught by expired ban"""
         Ban.objects.create(
             banned_value='bo*',
-            expires_on=timezone.now() - timedelta(days=7)
+            expires_on=timezone.now() - timedelta(days=7),
         )
 
         self.assertIsNone(get_user_ban(self.user))
@@ -152,7 +151,7 @@ class UserBansTests(TestCase):
         """user is not caught by expired but checked ban"""
         Ban.objects.create(
             banned_value='bo*',
-            expires_on=timezone.now() - timedelta(days=7)
+            expires_on=timezone.now() - timedelta(days=7),
         )
         Ban.objects.update(is_checked=True)
 
@@ -177,7 +176,7 @@ class RequestIPBansTests(TestCase):
         Ban.objects.create(
             check_type=Ban.IP,
             banned_value='127.0.0.1',
-            user_message='User reason'
+            user_message='User reason',
         )
 
         ip_ban = get_request_ip_ban(FakeRequest())
@@ -194,7 +193,7 @@ class RequestIPBansTests(TestCase):
             check_type=Ban.IP,
             banned_value='127.0.0.1',
             user_message='User reason',
-            expires_on=timezone.now() + timedelta(days=7)
+            expires_on=timezone.now() + timedelta(days=7),
         )
 
         ip_ban = get_request_ip_ban(FakeRequest())
@@ -211,7 +210,7 @@ class RequestIPBansTests(TestCase):
             check_type=Ban.IP,
             banned_value='127.0.0.1',
             user_message='User reason',
-            expires_on=timezone.now() - timedelta(days=7)
+            expires_on=timezone.now() - timedelta(days=7),
         )
 
         ip_ban = get_request_ip_ban(FakeRequest())
@@ -224,8 +223,7 @@ 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')

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

@@ -1,8 +1,5 @@
-import json
-
 from django.test import TestCase
 from django.urls import reverse
-from django.utils.encoding import smart_str
 
 from misago.conf import settings
 
@@ -29,6 +26,6 @@ class AuthenticateAPITests(TestCase):
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['question'], 'Do you like pies?')
         self.assertEqual(response_json['help_text'], 'Type in "yes".')

+ 1 - 2
misago/users/tests/test_commands.py

@@ -9,12 +9,11 @@ UserModel = get_user_model()
 class CreateSuperUserTests(TestCase):
     def test_createsuperuser(self):
         """createsuperuser creates user account in perfect conditions"""
-
         opts = {
             'username': 'Boberson',
             'email': 'bob@test.com',
             'password': 'Pass.123',
-            'verbosity': 0
+            'verbosity': 0,
         }
 
         call_command('createsuperuser', **opts)

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

@@ -11,20 +11,22 @@ class CreateSuperuserTests(TestCase):
     def test_create_superuser(self):
         """command creates superuser"""
         out = StringIO()
+
         call_command(
             "createsuperuser",
             interactive=False,
             username="joe",
             email="joe@somewhere.org",
             password="Pass.123",
-            stdout=out
+            stdout=out,
         )
 
         new_user = UserModel.objects.order_by('-id')[:1][0]
 
         self.assertEqual(
             out.getvalue().splitlines()[-1].strip(),
-            'Superuser #%s has been created successfully.' % new_user.pk)
+            'Superuser #%s has been created successfully.' % new_user.pk,
+        )
 
         self.assertEqual(new_user.username, 'joe')
         self.assertEqual(new_user.email, 'joe@somewhere.org')

+ 4 - 8
misago/users/tests/test_credentialchange.py

@@ -20,8 +20,7 @@ class CredentialChangeTests(TestCase):
     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')
@@ -29,8 +28,7 @@ class CredentialChangeTests(TestCase):
     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.save()
@@ -41,8 +39,7 @@ class CredentialChangeTests(TestCase):
     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.save()
@@ -53,8 +50,7 @@ class CredentialChangeTests(TestCase):
     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)
         self.assertIsNone(email)

+ 3 - 4
misago/users/tests/test_decorators.py

@@ -39,7 +39,7 @@ class DenyBannedIPTests(UserTestCase):
         Ban.objects.create(
             check_type=Ban.IP,
             banned_value='83.*',
-            user_message="Ya got banned!"
+            user_message="Ya got banned!",
         )
 
         response = self.client.post(reverse('misago:request-activation'))
@@ -50,9 +50,8 @@ class DenyBannedIPTests(UserTestCase):
         Ban.objects.create(
             check_type=Ban.IP,
             banned_value='127.*',
-            user_message="Ya got banned!"
+            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)
+        self.assertContains(response, encode_json_html("<p>Ya got banned!</p>"), status_code=403)

+ 7 - 4
misago/users/tests/test_djangoadmin_auth.py

@@ -17,10 +17,13 @@ class DjangoAdminAuthTests(AdminTestCase):
         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,
-        })
+        response = self.client.post(
+            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'))

+ 33 - 20
misago/users/tests/test_forgottenpassword_views.py

@@ -36,12 +36,15 @@ class ForgottenPasswordViewsTests(UserTestCase):
         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,
-            }))
-        self.assertContains(
-            response, encode_json_html("<p>Nope!</p>"), status_code=403)
+            reverse(
+                '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"""
@@ -52,23 +55,29 @@ class ForgottenPasswordViewsTests(UserTestCase):
         self.login_user(self.get_authenticated_user())
 
         response = self.client.get(
-            reverse('misago:forgotten-password-change-form', kwargs={
-                'pk': test_user.pk,
-                'token': password_token,
-            }))
+            reverse(
+                'misago:forgotten-password-change-form',
+                kwargs={
+                    'pk': test_user.pk,
+                    'token': password_token,
+                },
+            )
+        )
         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')
 
-        password_token = make_password_change_token(test_user)
-
         response = self.client.get(
-            reverse('misago:forgotten-password-change-form', kwargs={
-                'pk': test_user.pk,
-                'token': 'abcdfghqsads',
-            }))
+            reverse(
+                'misago:forgotten-password-change-form',
+                kwargs={
+                    'pk': test_user.pk,
+                    'token': 'abcdfghqsads',
+                },
+            )
+        )
         self.assertContains(response, 'your link is invalid', status_code=400)
 
     def test_change_password_form(self):
@@ -78,8 +87,12 @@ class ForgottenPasswordViewsTests(UserTestCase):
         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,
-            }))
+            reverse(
+                'misago:forgotten-password-change-form',
+                kwargs={
+                    'pk': test_user.pk,
+                    'token': password_token,
+                },
+            )
+        )
         self.assertContains(response, password_token)

+ 7 - 5
misago/users/tests/test_invalidatebans.py

@@ -5,7 +5,6 @@ from django.core.management import call_command
 from django.test import TestCase
 from django.utils import timezone
 from django.utils.six import StringIO
-from django.utils.six.moves import range
 
 from misago.users import bans
 from misago.users.management.commands import invalidatebans
@@ -19,9 +18,9 @@ class InvalidateBansTests(TestCase):
     def test_expired_bans_handling(self):
         """expired bans are flagged as such"""
         # create 5 bans then update their valid date to past one
-        for i in range(5):
+        for _ in range(5):
             Ban.objects.create(banned_value="abcd")
-        expired_date = (timezone.now() - timedelta(days=10))
+        expired_date = timezone.now() - timedelta(days=10)
         Ban.objects.all().update(expires_on=expired_date, is_checked=True)
 
         self.assertEqual(Ban.objects.filter(is_checked=True).count(), 5)
@@ -58,8 +57,11 @@ class InvalidateBansTests(TestCase):
         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)
+        expired_date = timezone.now() - timedelta(days=10)
+        Ban.objects.all().update(
+            expires_on=expired_date,
+            is_checked=True,
+        )
         BanCache.objects.all().update(expires_on=expired_date)
 
         # invalidate expired ban cache

+ 35 - 11
misago/users/tests/test_lists_views.py

@@ -1,6 +1,5 @@
 from django.contrib.auth import get_user_model
 from django.urls import reverse
-from django.utils.six.moves import range
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
@@ -35,8 +34,7 @@ class UsersListLanderTests(UsersListTestCase):
         """lander returns redirect to valid page if user has permission"""
         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):
@@ -58,7 +56,11 @@ class ActivePostersTests(UsersListTestCase):
         # Create 50 test users and see if errors appeared
         for i in range(50):
             user = UserModel.objects.create_user(
-                'Bob%s' % i, 'm%s@te.com' % i, 'Pass.123', posts=12345)
+                'Bob%s' % i,
+                'm%s@te.com' % i,
+                'Pass.123',
+                posts=12345,
+            )
             post_thread(category, poster=user)
 
         build_active_posters_ranking()
@@ -70,14 +72,18 @@ class ActivePostersTests(UsersListTestCase):
 class UsersRankTests(UsersListTestCase):
     def test_ranks(self):
         """ranks lists are handled correctly"""
-        rank_user = UserModel.objects.create_user(
-            'Visible', 'visible@te.com', 'Pass.123')
+        rank_user = UserModel.objects.create_user('Visible', 'visible@te.com', 'Pass.123')
 
         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:
@@ -89,13 +95,22 @@ class UsersRankTests(UsersListTestCase):
     def test_disabled_users(self):
         """ranks lists excludes disabled accounts"""
         rank_user = UserModel.objects.create_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:
@@ -110,13 +125,22 @@ class UsersRankTests(UsersListTestCase):
         self.user.save()
 
         rank_user = UserModel.objects.create_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:

+ 33 - 17
misago/users/tests/test_options_views.py

@@ -12,9 +12,14 @@ class OptionsViewsTests(AuthenticatedUserTestCase):
 
     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'
-        }))
+        response = self.client.get(
+            reverse(
+                'misago:options-form',
+                kwargs={
+                    'form_name': 'some-fake-form',
+                },
+            )
+        )
         self.assertEqual(response.status_code, 200)
 
 
@@ -23,10 +28,10 @@ class ConfirmChangeEmailTests(AuthenticatedUserTestCase):
         super(ConfirmChangeEmailTests, self).setUp()
         link = '/api/users/%s/change-email/' % self.user.pk
 
-        response = self.client.post(link, data={
-            'new_email': 'n3w@email.com',
-            'password': self.USER_PASSWORD
-        })
+        response = self.client.post(
+            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()]:
@@ -37,9 +42,13 @@ class ConfirmChangeEmailTests(AuthenticatedUserTestCase):
     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)
 
@@ -58,10 +67,13 @@ class ConfirmChangePasswordTests(AuthenticatedUserTestCase):
         super(ConfirmChangePasswordTests, self).setUp()
         link = '/api/users/%s/change-password/' % self.user.pk
 
-        response = self.client.post(link, data={
-            'new_password': 'n3wp4ssword',
-            'password': self.USER_PASSWORD
-        })
+        response = self.client.post(
+            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()]:
@@ -72,9 +84,13 @@ class ConfirmChangePasswordTests(AuthenticatedUserTestCase):
     def test_invalid_token(self):
         """invalid token is rejected"""
         response = self.client.get(
-            reverse('misago:options-confirm-password-change', kwargs={
-                'token': 'invalid'
-            }))
+            reverse(
+                'misago:options-confirm-password-change',
+                kwargs={
+                    'token': 'invalid',
+                },
+            )
+        )
 
         self.assertContains(response, "Change confirmation link is invalid.", status_code=400)
 

+ 53 - 30
misago/users/tests/test_profile_views.py

@@ -1,6 +1,5 @@
 from django.contrib.auth import get_user_model
 from django.urls import reverse
-from django.utils.six.moves import range
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
@@ -15,18 +14,18 @@ UserModel = get_user_model()
 class UserProfileViewsTests(AuthenticatedUserTestCase):
     def setUp(self):
         super(UserProfileViewsTests, self).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')
 
     def test_outdated_slugs(self):
-        """user profile view redirects to valid slig"""
-        invalid_kwargs = {'slug': 'baww', 'pk': self.user.pk}
-        response = self.client.get(reverse('misago:user-posts',
-                                           kwargs=invalid_kwargs))
+        """user profile view redirects to valid slug"""
+        response = self.client.get(
+            reverse('misago:user-posts', kwargs={
+                'slug': 'baww',
+                'pk': self.user.pk,
+            })
+        )
 
         self.assertEqual(response.status_code, 301)
 
@@ -61,7 +60,10 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
         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)
@@ -83,7 +85,10 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
         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)
@@ -99,8 +104,10 @@ 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.')
@@ -111,16 +118,20 @@ 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.')
@@ -131,16 +142,20 @@ 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_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.')
 
@@ -149,8 +164,10 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
         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")
@@ -165,16 +182,20 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
         test_user = UserModel.objects.create_user("Bob", "bob@bob.com", 'pass.123')
         link_kwargs = {'slug': test_user.slug, 'pk': test_user.pk}
 
-        response = self.client.get(reverse('misago:user-ban',
-                                           kwargs=link_kwargs))
+        response = self.client.get(reverse(
+            'misago:user-ban',
+            kwargs=link_kwargs,
+        ))
         self.assertEqual(response.status_code, 404)
 
         override_acl(self.user, {
             'can_see_ban_details': 1,
         })
 
-        response = self.client.get(reverse('misago:user-ban',
-                                           kwargs=link_kwargs))
+        response = self.client.get(reverse(
+            'misago:user-ban',
+            kwargs=link_kwargs,
+        ))
         self.assertEqual(response.status_code, 404)
 
         override_acl(self.user, {
@@ -186,11 +207,13 @@ class UserProfileViewsTests(AuthenticatedUserTestCase):
             banned_value=test_user.username,
             user_message="User m3ss4ge.",
             staff_message="Staff m3ss4ge.",
-            is_checked=True
+            is_checked=True,
         )
 
-        response = self.client.get(
-            reverse('misago:user-ban', kwargs=link_kwargs))
+        response = self.client.get(reverse(
+            'misago:user-ban',
+            kwargs=link_kwargs,
+        ))
 
         self.assertEqual(response.status_code, 200)
         self.assertContains(response, 'User m3ss4ge')

+ 86 - 33
misago/users/tests/test_rankadmin_views.py

@@ -8,8 +8,7 @@ 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'))
@@ -27,8 +26,7 @@ class RankAdminViewsTests(AdminTestCase):
         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(
@@ -40,7 +38,8 @@ class RankAdminViewsTests(AdminTestCase):
                 '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'))
@@ -68,24 +67,35 @@ class RankAdminViewsTests(AdminTestCase):
                 'style': 'test',
                 'is_tab': '1',
                 'roles': [test_role_a.pk, test_role_c.pk],
-            })
+            },
+        )
 
         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}),
+            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')
@@ -109,13 +119,19 @@ class RankAdminViewsTests(AdminTestCase):
                 'title': 'Test Title',
                 'style': 'test',
                 'is_tab': '1',
-            })
+            },
+        )
 
         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')
@@ -131,13 +147,19 @@ class RankAdminViewsTests(AdminTestCase):
                 'title': 'Test Title',
                 'style': 'test',
                 'is_tab': '1',
-            })
+            },
+        )
 
         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')
@@ -153,18 +175,29 @@ class RankAdminViewsTests(AdminTestCase):
                 'title': 'Test Title',
                 'style': 'test',
                 'is_tab': '1',
-            })
+            },
+        )
 
         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
@@ -181,12 +214,19 @@ class RankAdminViewsTests(AdminTestCase):
                 'title': 'Test Title',
                 'style': 'test',
                 'is_tab': '1',
-            })
+            },
+        )
 
         test_rank = Rank.objects.get(slug='test-rank')
 
-        response = self.client.get(reverse('misago:admin:users:ranks:users',
-                                           kwargs={'pk': test_rank.pk}))
+        response = self.client.get(
+            reverse(
+                'misago:admin:users:ranks:users',
+                kwargs={
+                    'pk': test_rank.pk,
+                },
+            )
+        )
         self.assertEqual(response.status_code, 302)
 
     def test_delete_view(self):
@@ -199,13 +239,19 @@ class RankAdminViewsTests(AdminTestCase):
                 'title': 'Test Title',
                 'style': 'test',
                 'is_tab': '1',
-            })
+            },
+        )
 
         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'))
@@ -228,7 +274,8 @@ class RankAdminViewsTests(AdminTestCase):
                 '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.")
@@ -242,16 +289,22 @@ class RankAdminViewsTests(AdminTestCase):
                 'style': 'test',
                 'is_tab': '1',
                 'roles': [test_role_a.pk],
-            })
+            }
+        )
 
         test_rank = Rank.objects.get(slug='test-rank')
 
         response = self.client.post(
-            reverse('misago:admin:users:ranks:edit',
-                    kwargs={'pk': test_rank.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.")

+ 1 - 2
misago/users/tests/test_realip_middleware.py

@@ -24,5 +24,4 @@ class RealIPMiddlewareTests(TestCase):
         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'])

+ 32 - 20
misago/users/tests/test_rest_permissions.py

@@ -11,9 +11,11 @@ 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)
 
     def test_api_allows_authenticated(self):
@@ -21,9 +23,11 @@ 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):
@@ -31,13 +35,15 @@ class UnbannedOnlyTests(UserTestCase):
         Ban.objects.create(
             check_type=Ban.IP,
             banned_value='127.*',
-            user_message='Ya got banned!'
+            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)
 
 
@@ -51,9 +57,11 @@ 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)
 
     def test_api_allows_authenticated(self):
@@ -61,9 +69,11 @@ 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):
@@ -71,11 +81,13 @@ class UnbannedAnonOnlyTests(UserTestCase):
         Ban.objects.create(
             check_type=Ban.IP,
             banned_value='127.*',
-            user_message='Ya got banned!'
+            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)

+ 11 - 7
misago/users/tests/test_search.py

@@ -16,9 +16,7 @@ class SearchApiTests(AuthenticatedUserTestCase):
 
     def test_no_permission(self):
         """api respects permission to search users"""
-        override_acl(self.user, {
-            'can_search_users': 0
-        })
+        override_acl(self.user, {'can_search_users': 0})
 
         response = self.client.get(self.api_link)
         self.assertEqual(response.status_code, 200)
@@ -105,7 +103,11 @@ class SearchApiTests(AuthenticatedUserTestCase):
     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)
         self.assertEqual(response.status_code, 200)
@@ -138,6 +140,8 @@ class SearchProviderApiTests(SearchApiTests):
     def setUp(self):
         super(SearchProviderApiTests, self).setUp()
 
-        self.api_link = reverse('misago:api:search', kwargs={
-            'search_provider': 'users'
-        })
+        self.api_link = reverse(
+            'misago:api:search', kwargs={
+                'search_provider': 'users',
+            }
+        )

+ 1 - 2
misago/users/tests/test_signatures.py

@@ -25,8 +25,7 @@ class SignaturesTests(TestCase):
         self.assertEqual(test_user.signature_parsed, '')
         self.assertEqual(test_user.signature_checksum, '')
 
-        signatures.set_user_signature(
-            MockRequest(), test_user, 'Hello, world!')
+        signatures.set_user_signature(MockRequest(), test_user, 'Hello, world!')
 
         self.assertEqual(test_user.signature, 'Hello, world!')
         self.assertEqual(test_user.signature_parsed, '<p>Hello, world!</p>')

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

@@ -1,7 +1,4 @@
-import json
-
 from django.urls import reverse
-from django.utils.encoding import smart_str
 
 from misago.users.testutils import AuthenticatedUserTestCase, SuperUserTestCase, UserTestCase
 
@@ -37,7 +34,7 @@ class UserTestCaseTests(UserTestCase):
         response = self.client.get('/api/auth/')
         self.assertEqual(response.status_code, 200)
 
-        user_json = json.loads(smart_str(response.content))
+        user_json = response.json()
         self.assertEqual(user_json['id'], user.id)
 
     def test_login_superuser(self):
@@ -48,7 +45,7 @@ class UserTestCaseTests(UserTestCase):
         response = self.client.get('/api/auth/')
         self.assertEqual(response.status_code, 200)
 
-        user_json = json.loads(smart_str(response.content))
+        user_json = response.json()
         self.assertEqual(user_json['id'], user.id)
 
     def test_logout_user(self):
@@ -60,7 +57,7 @@ class UserTestCaseTests(UserTestCase):
         response = self.client.get('/api/auth/')
         self.assertEqual(response.status_code, 200)
 
-        user_json = json.loads(smart_str(response.content))
+        user_json = response.json()
         self.assertIsNone(user_json['id'])
 
     def test_logout_superuser(self):
@@ -72,7 +69,7 @@ class UserTestCaseTests(UserTestCase):
         response = self.client.get('/api/auth/')
         self.assertEqual(response.status_code, 200)
 
-        user_json = json.loads(smart_str(response.content))
+        user_json = response.json()
         self.assertIsNone(user_json['id'])
 
 
@@ -99,5 +96,5 @@ class SuperUserTestCaseTests(SuperUserTestCase):
         response = self.client.get('/api/auth/')
         self.assertEqual(response.status_code, 200)
 
-        user_json = json.loads(smart_str(response.content))
+        user_json = response.json()
         self.assertEqual(user_json['id'], self.user.id)

+ 144 - 131
misago/users/tests/test_user_avatar_api.py

@@ -4,11 +4,8 @@ import os
 from path import Path
 
 from django.contrib.auth import get_user_model
-from django.urls import reverse
-from django.utils.encoding import smart_str
 
 from misago.acl.testutils import override_acl
-from misago.conf import settings
 from misago.users.avatars import gallery, store
 from misago.users.models import AvatarGallery
 from misago.users.testutils import AuthenticatedUserTestCase
@@ -21,9 +18,8 @@ UserModel = get_user_model()
 
 
 class UserAvatarTests(AuthenticatedUserTestCase):
-    """
-    tests for user avatar RPC (/api/users/1/avatar/)
-    """
+    """tests for user avatar RPC (/api/users/1/avatar/)"""
+
     def setUp(self):
         super(UserAvatarTests, self).setUp()
         self.link = '/api/users/%s/avatar/' % self.user.pk
@@ -80,13 +76,14 @@ class UserAvatarTests(AuthenticatedUserTestCase):
 
     def test_other_user_avatar(self):
         """requests to api error if user tries to access other user"""
-        self.logout_user();
+        self.logout_user()
 
         response = self.client.get(self.link)
         self.assertContains(response, "You have to sign in", status_code=403)
 
-        self.login_user(UserModel.objects.create_user(
-            "BobUser", "bob@bob.com", self.USER_PASSWORD))
+        self.login_user(
+            UserModel.objects.create_user("BobUser", "bob@bob.com", self.USER_PASSWORD)
+        )
 
         response = self.client.get(self.link)
         self.assertContains(response, "can't change other users avatars", status_code=403)
@@ -126,30 +123,33 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.assertContains(response, "No file was sent.", status_code=400)
 
         with open(TEST_AVATAR_PATH, 'rb') as avatar:
-            response = self.client.post(self.link, data={
-                'avatar': 'upload',
-                'image': 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.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)
         self.assertTrue(avatar.exists())
         self.assertTrue(avatar.isfile())
 
-        response = self.client.post(self.link, json.dumps({
-            'avatar': 'crop_tmp',
-            'crop': {
-                'offset': {
-                    'x': 0, 'y': 0
+        response = self.client.post(
+            self.link,
+            json.dumps({
+                'avatar': 'crop_tmp',
+                'crop': {
+                    'offset': {
+                        'x': 0,
+                        'y': 0
+                    },
+                    'zoom': 1,
                 },
-                'zoom': 1
-            }
-        }), content_type="application/json")
+            }),
+            content_type="application/json",
+        )
 
         response_json = response.json()
         self.assertEqual(response.status_code, 200)
@@ -161,30 +161,39 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.assertTrue(avatar.exists())
         self.assertTrue(avatar.isfile())
 
-        response = self.client.post(self.link, json.dumps({
-            'avatar': 'crop_tmp',
-            'crop': {
-                'offset': {
-                    'x': 0, 'y': 0
+        response = self.client.post(
+            self.link,
+            json.dumps({
+                'avatar': 'crop_tmp',
+                'crop': {
+                    'offset': {
+                        'x': 0,
+                        'y': 0
+                    },
+                    'zoom': 1,
                 },
-                'zoom': 1
-            }
-        }), content_type="application/json")
+            }),
+            content_type="application/json",
+        )
         self.assertContains(response, "This avatar type is not allowed.", status_code=400)
 
-        response = self.client.post(self.link, json.dumps({
-            'avatar': 'crop_src',
-            'crop': {
-                'offset': {
-                    'x': 0, 'y': 0
+        response = self.client.post(
+            self.link,
+            json.dumps({
+                'avatar': 'crop_src',
+                'crop': {
+                    'offset': {
+                        'x': 0,
+                        'y': 0
+                    },
+                    'zoom': 1,
                 },
-                'zoom': 1
-            }
-        }), content_type="application/json")
+            }),
+            content_type="application/json",
+        )
         self.assertContains(response, "Avatar was re-cropped.")
 
         # delete user avatars, test if it deletes src and tmp
-        user = self.get_current_user()
         store.delete_avatar(self.get_current_user())
 
         self.assertTrue(self.get_current_user().avatar_src.path)
@@ -198,13 +207,9 @@ 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.assertContains(
-            response, "This avatar type is not allowed.", status_code=400)
+        self.assertContains(response, "This avatar type is not allowed.", status_code=400)
 
     def test_gallery_image_validation(self):
         """gallery validates image to set"""
@@ -214,16 +219,22 @@ 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.assertContains(response, "Incorrect image.", status_code=400)
 
         # invalid id is handled
-        response = self.client.post(self.link, data={
-            'avatar': 'galleries',
-            'image': 'asdsadsadsa'
-        })
+        response = self.client.post(
+            self.link,
+            data={
+                'avatar': 'galleries',
+                'image': 'asdsadsadsa',
+            },
+        )
         self.assertContains(response, "Incorrect image.", status_code=400)
 
         # nonexistant image is handled
@@ -234,21 +245,19 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.assertTrue(options['galleries'])
 
         test_avatar = options['galleries'][0]['images'][0]['id']
-        response = self.client.post(self.link, data={
-            'avatar': 'galleries',
-            'image': test_avatar + 5000
-        })
+        response = self.client.post(
+            self.link,
+            data={
+                'avatar': 'galleries',
+                'image': test_avatar + 5000,
+            },
+        )
         self.assertContains(response, "Incorrect image.", status_code=400)
 
         # 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.assertContains(response, "Incorrect image.", status_code=400)
 
     def test_gallery_set_valid_avatar(self):
@@ -262,23 +271,24 @@ class UserAvatarTests(AuthenticatedUserTestCase):
         self.assertTrue(options['galleries'])
 
         test_avatar = options['galleries'][0]['images'][0]['id']
-        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.assertContains(response, "Avatar from gallery was set.")
 
 
 class UserAvatarModerationTests(AuthenticatedUserTestCase):
-    """
-    tests for moderate user avatar RPC (/api/users/1/moderate-avatar/)
-    """
+    """tests for moderate user avatar RPC (/api/users/1/moderate-avatar/)"""
+
     def setUp(self):
         super(UserAvatarModerationTests, self).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-avatar/' % self.other_user.pk
 
@@ -301,53 +311,56 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         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
+        )
 
         override_acl(self.user, {
             'can_moderate_avatars': 1,
         })
 
-        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.",
-        }),
-        content_type="application/json")
+        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.",
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)
 
         other_user = UserModel.objects.get(pk=self.other_user.pk)
 
         options = response.json()
         self.assertEqual(other_user.is_avatar_locked, True)
-        self.assertEqual(
-            other_user.avatar_lock_user_message, "Test user message.")
-        self.assertEqual(
-            other_user.avatar_lock_staff_message, "Test staff message.")
+        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_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
+        )
 
         override_acl(self.user, {
             'can_moderate_avatars': 1,
         })
 
-        response = self.client.post(self.link, json.dumps({
-            'is_avatar_locked': False,
-            'avatar_lock_user_message': None,
-            'avatar_lock_staff_message': None,
-        }),
-        content_type="application/json")
+        response = self.client.post(
+            self.link,
+            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)
 
         other_user = UserModel.objects.get(pk=self.other_user.pk)
@@ -356,25 +369,26 @@ 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_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
+        )
 
         override_acl(self.user, {
             'can_moderate_avatars': 1,
         })
 
-        response = self.client.post(self.link, json.dumps({
-            'is_avatar_locked': True,
-            'avatar_lock_user_message': '',
-            'avatar_lock_staff_message': '',
-        }),
-        content_type="application/json")
+        response = self.client.post(
+            self.link,
+            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 = UserModel.objects.get(pk=self.other_user.pk)
@@ -383,23 +397,24 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         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
+        )
 
         override_acl(self.user, {
             'can_moderate_avatars': 1,
         })
 
-        response = self.client.post(self.link, json.dumps({
-            'is_avatar_locked': False,
-        }),
-        content_type="application/json")
+        response = self.client.post(
+            self.link,
+            json.dumps({
+                'is_avatar_locked': False,
+            }),
+            content_type="application/json",
+        )
         self.assertEqual(response.status_code, 200)
 
         other_user = UserModel.objects.get(pk=self.other_user.pk)
@@ -408,14 +423,12 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
         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
+        )
 
     def test_moderate_own_avatar(self):
         """moderate own avatar"""
@@ -423,5 +436,5 @@ class UserAvatarModerationTests(AuthenticatedUserTestCase):
             'can_moderate_avatars': 1,
         })
 
-        response = self.client.get( '/api/users/%s/moderate-avatar/' % self.user.pk)
+        response = self.client.get('/api/users/%s/moderate-avatar/' % self.user.pk)
         self.assertEqual(response.status_code, 200)

+ 53 - 40
misago/users/tests/test_user_changeemail_api.py

@@ -9,9 +9,8 @@ UserModel = get_user_model()
 
 
 class UserChangeEmailTests(AuthenticatedUserTestCase):
-    """
-    tests for user change email RPC (/api/users/1/change-email/)
-    """
+    """tests for user change email RPC (/api/users/1/change-email/)"""
+
     def setUp(self):
         super(UserChangeEmailTests, self).setUp()
         self.link = '/api/users/%s/change-email/' % self.user.pk
@@ -26,67 +25,76 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
         response = self.client.post(self.link, data={})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'new_email': [
-                "This field is required."
-            ],
-            'password': [
-                "This field is required."
-            ],
-        })
+        self.assertEqual(
+            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'
-        })
+        response = self.client.post(
+            self.link,
+            data={
+                'new_email': 'new@email.com',
+                'password': 'Lor3mIpsum',
+            },
+        )
         self.assertContains(response, 'password is invalid', status_code=400)
 
     def test_invalid_input(self):
         """api errors correctly for invalid input"""
-        response = self.client.post(self.link, data={
-            'new_email': '',
-            'password': self.USER_PASSWORD
-        })
+        response = self.client.post(
+            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."
-            ],
+            'new_email': ["This field may not be blank."],
         })
 
-        response = self.client.post(self.link, data={
-            'new_email': 'newmail',
-            'password': self.USER_PASSWORD
-        })
+        response = self.client.post(
+            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."
-            ],
+            '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')
 
-        response = self.client.post(self.link, data={
-            'new_email': 'new@email.com',
-            'password': self.USER_PASSWORD
-        })
+        response = self.client.post(
+            self.link,
+            data={
+                'new_email': 'new@email.com',
+                'password': self.USER_PASSWORD,
+            },
+        )
         self.assertContains(response, 'not available', status_code=400)
 
     def test_change_email(self):
         """api allows users to change their e-mail addresses"""
         new_email = 'new@email.com'
 
-        response = self.client.post(self.link, data={
-            'new_email': new_email,
-            'password': self.USER_PASSWORD
-        })
+        response = self.client.post(
+            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)
@@ -97,9 +105,14 @@ class UserChangeEmailTests(AuthenticatedUserTestCase):
         else:
             self.fail("E-mail sent didn't contain confirmation url")
 
-        response = self.client.get(reverse('misago:options-confirm-email-change', kwargs={
-            'token': token
-        }))
+        response = self.client.get(
+            reverse(
+                'misago:options-confirm-email-change',
+                kwargs={
+                    'token': token,
+                },
+            )
+        )
 
         self.assertEqual(response.status_code, 200)
 

+ 49 - 42
misago/users/tests/test_user_changepassword_api.py

@@ -1,4 +1,3 @@
-from django.contrib.auth import get_user_model
 from django.core import mail
 from django.urls import reverse
 
@@ -6,9 +5,8 @@ from misago.users.testutils import AuthenticatedUserTestCase
 
 
 class UserChangePasswordTests(AuthenticatedUserTestCase):
-    """
-    tests for user change password RPC (/api/users/1/change-password/)
-    """
+    """tests for user change password RPC (/api/users/1/change-password/)"""
+
     def setUp(self):
         super(UserChangePasswordTests, self).setUp()
         self.link = '/api/users/%s/change-password/' % self.user.pk
@@ -23,65 +21,72 @@ class UserChangePasswordTests(AuthenticatedUserTestCase):
         response = self.client.post(self.link, data={})
 
         self.assertEqual(response.status_code, 400)
-        self.assertEqual(response.json(), {
-            'new_password': [
-                "This field is required."
-            ],
-            'password': [
-                "This field is required."
-            ],
-        })
+        self.assertEqual(
+            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'
-        })
+        response = self.client.post(
+            self.link,
+            data={
+                'new_password': 'N3wP@55w0rd',
+                'password': 'Lor3mIpsum',
+            },
+        )
 
         self.assertEqual(response.status_code, 400)
         self.assertEqual(response.json(), {
-            'password': [
-                "Entered password is invalid."
-            ],
+            '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
-        })
+        response = self.client.post(
+            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."
-            ],
+            '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
-        })
+        response = self.client.post(
+            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."
-            ],
-        })
+        self.assertEqual(
+            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'
 
-        response = self.client.post(self.link, data={
-            'new_password': new_password,
-            'password': self.USER_PASSWORD
-        })
+        response = self.client.post(
+            self.link,
+            data={
+                'new_password': new_password,
+                'password': self.USER_PASSWORD,
+            },
+        )
         self.assertEqual(response.status_code, 200)
 
         self.assertIn('Confirm password change', mail.outbox[0].subject)
@@ -92,9 +97,11 @@ class UserChangePasswordTests(AuthenticatedUserTestCase):
         else:
             self.fail("E-mail sent didn't contain confirmation url")
 
-        response = self.client.get(reverse('misago:options-confirm-password-change', kwargs={
-            'token': token
-        }))
+        response = self.client.get(
+            reverse('misago:options-confirm-password-change', kwargs={
+                'token': token,
+            })
+        )
 
         self.assertEqual(response.status_code, 200)
 

+ 68 - 49
misago/users/tests/test_user_create_api.py

@@ -11,9 +11,8 @@ UserModel = get_user_model()
 
 
 class UserCreateTests(UserTestCase):
-    """
-    tests for new user registration (POST to /api/users/)
-    """
+    """tests for new user registration (POST to /api/users/)"""
+
     def setUp(self):
         super(UserCreateTests, self).setUp()
         self.api_link = '/api/users/'
@@ -40,43 +39,48 @@ class UserCreateTests(UserTestCase):
         """api validates usernames"""
         user = self.get_authenticated_user()
 
-        response = self.client.post(self.api_link, data={
-            'username': user.username,
-            'email': 'loremipsum@dolor.met',
-            'password': 'LoremP4ssword'
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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."
-            ]
+            'username': ["This username is not available."],
         })
 
     def test_registration_validates_email(self):
         """api validates usernames"""
         user = self.get_authenticated_user()
 
-        response = self.client.post(self.api_link, data={
-            'username': 'totallyNew',
-            'email': user.email,
-            'password': 'LoremP4ssword'
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                '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."
-            ]
+            'email': ["This e-mail address is not available."],
         })
 
     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'
-        })
+        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',
+            },
+        )
 
         self.assertContains(response, "password is too short", status_code=400)
         self.assertContains(response, "password is entirely numeric", status_code=400)
@@ -84,21 +88,27 @@ class UserCreateTests(UserTestCase):
 
     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'
-        })
+        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',
+            },
+        )
 
         self.assertContains(response, "password is too similar to the username", status_code=400)
 
     def test_registration_calls_validate_new_registration(self):
         """api uses validate_new_registration 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': 'pas123'
-        })
+        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',
+            },
+        )
 
         self.assertContains(response, "email is not allowed", status_code=400)
 
@@ -106,11 +116,14 @@ class UserCreateTests(UserTestCase):
         """api creates active and signed in user on POST"""
         settings.override_setting('account_activation', 'none')
 
-        response = self.client.post(self.api_link, data={
-            'username': 'Bob',
-            'email': 'bob@bob.com',
-            'password': 'pass123'
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                'username': 'Bob',
+                'email': 'bob@bob.com',
+                'password': 'pass123',
+            },
+        )
 
         self.assertContains(response, 'active')
         self.assertContains(response, 'Bob')
@@ -130,11 +143,14 @@ class UserCreateTests(UserTestCase):
         """api creates inactive user on POST"""
         settings.override_setting('account_activation', 'user')
 
-        response = self.client.post(self.api_link, data={
-            'username': 'Bob',
-            'email': 'bob@bob.com',
-            'password': 'pass123'
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                'username': 'Bob',
+                'email': 'bob@bob.com',
+                'password': 'pass123',
+            },
+        )
 
         self.assertContains(response, 'user')
         self.assertContains(response, 'Bob')
@@ -149,11 +165,14 @@ class UserCreateTests(UserTestCase):
         """api creates admin activated user on POST"""
         settings.override_setting('account_activation', 'admin')
 
-        response = self.client.post(self.api_link, data={
-            'username': 'Bob',
-            'email': 'bob@bob.com',
-            'password': 'pass123'
-        })
+        response = self.client.post(
+            self.api_link,
+            data={
+                'username': 'Bob',
+                'email': 'bob@bob.com',
+                'password': 'pass123',
+            },
+        )
 
         self.assertContains(response, 'admin')
         self.assertContains(response, 'Bob')

+ 66 - 14
misago/users/tests/test_user_feeds_api.py

@@ -8,17 +8,29 @@ class UserThreadsApiTests(ThreadsApiTestCase):
     def setUp(self):
         super(UserThreadsApiTests, self).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 +50,11 @@ class UserThreadsApiTests(ThreadsApiTestCase):
 
     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)
@@ -46,7 +62,10 @@ class UserThreadsApiTests(ThreadsApiTestCase):
 
     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)
@@ -58,7 +77,10 @@ class UserThreadsApiTests(ThreadsApiTestCase):
 
     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()
 
@@ -72,17 +94,29 @@ class UserPostsApiTests(ThreadsApiTestCase):
     def setUp(self):
         super(UserPostsApiTests, self).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)
 
@@ -94,7 +128,11 @@ class UserPostsApiTests(ThreadsApiTestCase):
 
     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)
@@ -102,7 +140,11 @@ class UserPostsApiTests(ThreadsApiTestCase):
 
     def test_user_hidden_post(self):
         """hidden posts don't show in feeds at all"""
-        post = 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)
@@ -110,7 +152,11 @@ class UserPostsApiTests(ThreadsApiTestCase):
 
     def test_user_unapproved_post(self):
         """unapproved posts don't show in feeds at all"""
-        post = 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)
@@ -129,7 +175,10 @@ class UserPostsApiTests(ThreadsApiTestCase):
 
     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)
@@ -153,7 +202,10 @@ class UserPostsApiTests(ThreadsApiTestCase):
 
     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()

+ 6 - 2
misago/users/tests/test_user_model.py

@@ -8,8 +8,12 @@ from misago.users.models import User
 class UserManagerTests(TestCase):
     def test_create_user(self):
         """create_user created new user account successfully"""
-        user = User.objects.create_user('Bob', 'bob@test.com', 'Pass.123',
-                                        set_default_avatar=True)
+        user = User.objects.create_user(
+            'Bob',
+            'bob@test.com',
+            'Pass.123',
+            set_default_avatar=True,
+        )
 
         db_user = User.objects.get(id=user.pk)
 

+ 27 - 27
misago/users/tests/test_user_signature_api.py

@@ -1,17 +1,10 @@
-import json
-
-from django.contrib.auth import get_user_model
-from django.utils.encoding import smart_str
-
 from misago.acl.testutils import override_acl
-from misago.conf import settings
 from misago.users.testutils import AuthenticatedUserTestCase
 
 
 class UserSignatureTests(AuthenticatedUserTestCase):
-    """
-    tests for user signature RPC (POST to /api/users/1/signature/)
-    """
+    """tests for user signature RPC (POST to /api/users/1/signature/)"""
+
     def setUp(self):
         super(UserSignatureTests, self).setUp()
         self.link = '/api/users/%s/signature/' % self.user.pk
@@ -50,8 +43,7 @@ class UserSignatureTests(AuthenticatedUserTestCase):
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertFalse(response_json['signature'])
+        self.assertFalse(response.json()['signature'])
 
     def test_post_empty_signature(self):
         """empty POST empties user signature"""
@@ -62,11 +54,15 @@ class UserSignatureTests(AuthenticatedUserTestCase):
         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)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertFalse(response_json['signature'])
+        self.assertFalse(response.json()['signature'])
 
     def test_post_too_long_signature(self):
         """too long new signature errors"""
@@ -77,9 +73,12 @@ class UserSignatureTests(AuthenticatedUserTestCase):
         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.assertContains(response, 'too long', status_code=400)
 
     def test_post_good_signature(self):
@@ -91,18 +90,19 @@ class UserSignatureTests(AuthenticatedUserTestCase):
         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)
 
-        response_json = json.loads(smart_str(response.content))
-        self.assertEqual(response_json['signature']['html'],
-                         '<p>Hello, <strong>bros</strong>!</p>')
-        self.assertEqual(response_json['signature']['plain'],
-                         'Hello, **bros**!')
+        self.assertEqual(
+            response.json()['signature']['html'], '<p>Hello, <strong>bros</strong>!</p>'
+        )
+        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>')

+ 66 - 53
misago/users/tests/test_user_username_api.py

@@ -1,8 +1,6 @@
 import json
 
 from django.contrib.auth import get_user_model
-from django.utils.encoding import smart_str
-from django.utils.six.moves import range
 
 from misago.acl.testutils import override_acl
 from misago.conf import settings
@@ -13,9 +11,8 @@ UserModel = get_user_model()
 
 
 class UserUsernameTests(AuthenticatedUserTestCase):
-    """
-    tests for user change name RPC (POST to /api/users/1/username/)
-    """
+    """tests for user change name RPC (POST to /api/users/1/username/)"""
+
     def setUp(self):
         super(UserUsernameTests, self).setUp()
         self.link = '/api/users/%s/username/' % self.user.pk
@@ -25,13 +22,11 @@ class UserUsernameTests(AuthenticatedUserTestCase):
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
 
         self.assertIsNotNone(response_json['changes_left'])
-        self.assertEqual(response_json['length_min'],
-                         settings.username_length_min)
-        self.assertEqual(response_json['length_max'],
-                         settings.username_length_max)
+        self.assertEqual(response_json['length_min'], settings.username_length_min)
+        self.assertEqual(response_json['length_max'], settings.username_length_max)
         self.assertIsNone(response_json['next_on'])
 
         for i in range(response_json['changes_left']):
@@ -40,7 +35,7 @@ class UserUsernameTests(AuthenticatedUserTestCase):
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         self.assertEqual(response_json['changes_left'], 0)
         self.assertIsNotNone(response_json['next_on'])
 
@@ -49,17 +44,20 @@ class UserUsernameTests(AuthenticatedUserTestCase):
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         for i in range(response_json['changes_left']):
             self.user.set_username('NewName%s' % i, self.user)
 
         response = self.client.get(self.link)
-        response_json = json.loads(smart_str(response.content))
+        response_json = response.json()
         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.assertContains(response, 'change your username now', status_code=400)
         self.assertTrue(self.user.username != 'Pointless')
@@ -72,44 +70,48 @@ class UserUsernameTests(AuthenticatedUserTestCase):
 
     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.assertContains(response, 'can only contain latin', status_code=400)
 
     def test_change_username(self):
         """api changes username and records change"""
         response = self.client.get(self.link)
-        changes_left = json.loads(smart_str(response.content))['changes_left']
+        changes_left = response.json()['changes_left']
 
-        username = self.user.username
+        old_username = self.user.username
         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 = json.loads(smart_str(response.content))['options']
+        options = response.json()['options']
         self.assertEqual(changes_left, options['changes_left'] + 1)
 
         self.reload_user()
         self.assertEqual(self.user.username, new_username)
+        self.assertTrue(self.user.username != old_username)
 
-        self.assertEqual(self.user.namechanges.last().new_username,
-                         new_username)
+        self.assertEqual(self.user.namechanges.last().new_username, new_username)
 
 
 class UserUsernameModerationTests(AuthenticatedUserTestCase):
-    """
-    tests for moderate username RPC (/api/users/1/moderate-username/)
-    """
+    """tests for moderate username RPC (/api/users/1/moderate-username/)"""
+
     def setUp(self):
         super(UserUsernameModerationTests, self).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
 
@@ -138,20 +140,21 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
 
-        options = json.loads(smart_str(response.content))
-        self.assertEqual(options['length_min'],
-                         settings.username_length_min)
-        self.assertEqual(options['length_max'],
-                         settings.username_length_max)
+        options = response.json()
+        self.assertEqual(options['length_min'], settings.username_length_min)
+        self.assertEqual(options['length_max'], settings.username_length_max)
 
         override_acl(self.user, {
             'can_rename_users': 1,
         })
 
-        response = self.client.post(self.link, json.dumps({
+        response = self.client.post(
+            self.link,
+            json.dumps({
                 'username': '',
             }),
-            content_type="application/json")
+            content_type='application/json',
+        )
 
         self.assertContains(response, "Enter new username", status_code=400)
 
@@ -159,37 +162,48 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             'can_rename_users': 1,
         })
 
-        response = self.client.post(self.link, json.dumps({
+        response = self.client.post(
+            self.link,
+            json.dumps({
                 'username': '$$$',
             }),
-            content_type="application/json")
+            content_type='application/json',
+        )
 
-        self.assertContains(response,
+        self.assertContains(
+            response,
             "Username can only contain latin alphabet letters and digits.",
-            status_code=400)
+            status_code=400
+        )
 
         override_acl(self.user, {
             'can_rename_users': 1,
         })
 
-        response = self.client.post(self.link, json.dumps({
+        response = self.client.post(
+            self.link,
+            json.dumps({
                 'username': 'a',
             }),
-            content_type="application/json")
+            content_type='application/json',
+        )
 
         self.assertEqual(response.status_code, 400)
-        self.assertContains(response,
-            "Username must be at least 3 characters long.",
-            status_code=400)
+        self.assertContains(
+            response, "Username must be at least 3 characters long.", status_code=400
+        )
 
         override_acl(self.user, {
             'can_rename_users': 1,
         })
 
-        response = self.client.post(self.link, json.dumps({
+        response = self.client.post(
+            self.link,
+            json.dumps({
                 'username': 'BobBoberson',
             }),
-            content_type="application/json")
+            content_type='application/json',
+        )
 
         self.assertEqual(response.status_code, 200)
 
@@ -198,7 +212,7 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
         self.assertEqual('BobBoberson', other_user.username)
         self.assertEqual('bobboberson', other_user.slug)
 
-        options = json.loads(smart_str(response.content))
+        options = response.json()
         self.assertEqual(options['username'], other_user.username)
         self.assertEqual(options['slug'], other_user.slug)
 
@@ -208,6 +222,5 @@ class UserUsernameModerationTests(AuthenticatedUserTestCase):
             'can_rename_users': 1,
         })
 
-        response = self.client.get(
-            '/api/users/%s/moderate-username/' % self.user.pk)
+        response = self.client.get('/api/users/%s/moderate-username/' % self.user.pk)
         self.assertEqual(response.status_code, 200)

+ 265 - 239
misago/users/tests/test_useradmin_views.py

@@ -1,11 +1,7 @@
-import json
-
 from django.contrib.auth import get_user_model
 from django.core import mail
 from django.urls import reverse
 from django.utils import six
-from django.utils.encoding import smart_str
-from django.utils.six.moves import range
 
 from misago.acl.models import Role
 from misago.admin.testutils import AdminTestCase
@@ -28,8 +24,7 @@ class UserAdminViewsTests(AdminTestCase):
 
     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'])
@@ -38,8 +33,7 @@ class UserAdminViewsTests(AdminTestCase):
 
     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']
@@ -86,17 +80,23 @@ class UserAdminViewsTests(AdminTestCase):
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'pass123',
-                requires_activation=1
+                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})
+            data={
+                'action': 'activate',
+                'selected_items': user_pks,
+            }
+        )
         self.assertEqual(response.status_code, 302)
 
-        inactive_qs = UserModel.objects.filter(id__in=user_pks,
-                                          requires_activation=1)
+        inactive_qs = UserModel.objects.filter(
+            id__in=user_pks,
+            requires_activation=1,
+        )
         self.assertEqual(inactive_qs.count(), 0)
         self.assertIn("has been activated", mail.outbox[0].subject)
 
@@ -108,13 +108,17 @@ class UserAdminViewsTests(AdminTestCase):
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'pass123',
-                requires_activation=1
+                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})
+            data={
+                'action': 'ban',
+                'selected_items': user_pks,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
         response = self.client.post(
@@ -122,12 +126,10 @@ class UserAdminViewsTests(AdminTestCase):
             data={
                 'action': 'ban',
                 'selected_items': user_pks,
-                'ban_type': [
-                    'usernames', 'emails', 'domains',
-                    'ip', 'ip_first', 'ip_two'
-                ],
-                'finalize': ''
-            })
+                'ban_type': ['usernames', 'emails', 'domains', 'ip', 'ip_first', 'ip_two'],
+                'finalize': '',
+            },
+        )
         self.assertEqual(response.status_code, 302)
         self.assertEqual(Ban.objects.count(), 24)
 
@@ -139,13 +141,17 @@ class UserAdminViewsTests(AdminTestCase):
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'pass123',
-                requires_activation=1
+                requires_activation=1,
             )
             user_pks.append(test_user.pk)
 
         response = self.client.post(
             reverse('misago:admin:users:accounts:index'),
-            data={'action': 'delete_accounts', 'selected_items': user_pks})
+            data={
+                'action': 'delete_accounts',
+                'selected_items': user_pks,
+            }
+        )
         self.assertEqual(response.status_code, 302)
         self.assertEqual(UserModel.objects.count(), 1)
 
@@ -157,34 +163,39 @@ class UserAdminViewsTests(AdminTestCase):
                 'Bob%s' % i,
                 'bob%s@test.com' % i,
                 'pass123',
-                requires_activation=1
+                requires_activation=1,
             )
             user_pks.append(test_user.pk)
 
         response = self.client.post(
             reverse('misago:admin:users:accounts:index'),
-            data={'action': 'delete_accounts', 'selected_items': user_pks})
+            data={
+                'action': 'delete_accounts',
+                'selected_items': user_pks,
+            }
+        )
         self.assertEqual(response.status_code, 302)
         self.assertEqual(UserModel.objects.count(), 1)
 
     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')
 
-        response = self.client.post(reverse('misago:admin:users:accounts:new'),
+        response = self.client.post(
+            reverse('misago:admin:users:accounts:new'),
             data={
                 'username': 'Bawww',
                 'rank': six.text_type(default_rank.pk),
                 'roles': six.text_type(authenticated_role.pk),
                 'email': 'reg@stered.com',
                 'new_password': 'pass123',
-                'staff_level': '0'
-            })
+                'staff_level': '0',
+            }
+        )
         self.assertEqual(response.status_code, 302)
 
         UserModel.objects.get_by_username('Bawww')
@@ -193,28 +204,34 @@ class UserAdminViewsTests(AdminTestCase):
     def test_edit_view(self):
         """edit user view changes account"""
         test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
-        test_link = reverse('misago:admin:users:accounts:edit',
-                            kwargs={'pk': test_user.pk})
+        test_link = reverse(
+            'misago:admin:users:accounts:edit', kwargs={
+                'pk': test_user.pk,
+            }
+        )
 
         response = self.client.get(test_link)
         self.assertEqual(response.status_code, 200)
 
-        response = self.client.post(test_link, data={
-            'username': 'Bawww',
-            'rank': six.text_type(test_user.rank_id),
-            'roles': six.text_type(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',
-        })
+        response = self.client.post(
+            test_link,
+            data={
+                'username': 'Bawww',
+                'rank': six.text_type(test_user.rank_id),
+                'roles': six.text_type(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 = UserModel.objects.get(pk=test_user.pk)
@@ -232,27 +249,33 @@ class UserAdminViewsTests(AdminTestCase):
         This is regression test for issue #640
         """
         test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
-        test_link = reverse('misago:admin:users:accounts:edit',
-                            kwargs={'pk': test_user.pk})
+        test_link = reverse(
+            'misago:admin:users:accounts:edit', kwargs={
+                'pk': test_user.pk,
+            }
+        )
 
         response = self.client.get(test_link)
         self.assertEqual(response.status_code, 200)
 
-        response = self.client.post(test_link, data={
-            'username': 'Bob',
-            'rank': six.text_type(test_user.rank_id),
-            'roles': six.text_type(test_user.roles.all()[0].pk),
-            'email': 'reg@stered.com',
-            'new_password': 'pass123',
-            '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',
-        })
+        response = self.client.post(
+            test_link,
+            data={
+                'username': 'Bob',
+                'rank': six.text_type(test_user.rank_id),
+                'roles': six.text_type(test_user.roles.all()[0].pk),
+                'email': 'reg@stered.com',
+                'new_password': 'pass123',
+                '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 = UserModel.objects.get(pk=test_user.pk)
@@ -263,30 +286,36 @@ class UserAdminViewsTests(AdminTestCase):
     def test_edit_make_admin(self):
         """edit user view allows super admin to make other user admin"""
         test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
-        test_link = reverse('misago:admin:users:accounts:edit',
-                            kwargs={'pk': test_user.pk})
+        test_link = reverse(
+            'misago:admin:users:accounts:edit', kwargs={
+                'pk': test_user.pk,
+            }
+        )
 
         response = self.client.get(test_link)
         self.assertContains(response, 'id="id_is_staff_1"')
         self.assertContains(response, 'id="id_is_superuser_1"')
 
-        response = self.client.post(test_link, data={
-            'username': 'Bawww',
-            'rank': six.text_type(test_user.rank_id),
-            'roles': six.text_type(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',
-        })
+        response = self.client.post(
+            test_link,
+            data={
+                'username': 'Bawww',
+                'rank': six.text_type(test_user.rank_id),
+                'roles': six.text_type(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',
+            }
+        )
         self.assertEqual(response.status_code, 302)
 
         updated_user = UserModel.objects.get(pk=test_user.pk)
@@ -296,30 +325,36 @@ class UserAdminViewsTests(AdminTestCase):
     def test_edit_make_superadmin_admin(self):
         """edit user view allows super admin to make other user super admin"""
         test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
-        test_link = reverse('misago:admin:users:accounts:edit',
-                            kwargs={'pk': test_user.pk})
+        test_link = reverse(
+            'misago:admin:users:accounts:edit', kwargs={
+                'pk': test_user.pk,
+            }
+        )
 
         response = self.client.get(test_link)
         self.assertContains(response, 'id="id_is_staff_1"')
         self.assertContains(response, 'id="id_is_superuser_1"')
 
-        response = self.client.post(test_link, data={
-            'username': 'Bawww',
-            'rank': six.text_type(test_user.rank_id),
-            'roles': six.text_type(test_user.roles.all()[0].pk),
-            'email': 'reg@stered.com',
-            'new_password': 'pass123',
-            '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',
-        })
+        response = self.client.post(
+            test_link,
+            data={
+                'username': 'Bawww',
+                'rank': six.text_type(test_user.rank_id),
+                'roles': six.text_type(test_user.roles.all()[0].pk),
+                'email': 'reg@stered.com',
+                'new_password': 'pass123',
+                '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)
 
         updated_user = UserModel.objects.get(pk=test_user.pk)
@@ -332,30 +367,36 @@ class UserAdminViewsTests(AdminTestCase):
         self.user.save()
 
         test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
-        test_link = reverse('misago:admin:users:accounts:edit',
-                            kwargs={'pk': test_user.pk})
+        test_link = reverse(
+            'misago:admin:users:accounts:edit', kwargs={
+                'pk': test_user.pk,
+            }
+        )
 
         response = self.client.get(test_link)
         self.assertNotContains(response, 'id="id_is_staff_1"')
         self.assertNotContains(response, 'id="id_is_superuser_1"')
 
-        response = self.client.post(test_link, data={
-            'username': 'Bawww',
-            'rank': six.text_type(test_user.rank_id),
-            'roles': six.text_type(test_user.roles.all()[0].pk),
-            'email': 'reg@stered.com',
-            'new_password': 'pass123',
-            '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',
-        })
+        response = self.client.post(
+            test_link,
+            data={
+                'username': 'Bawww',
+                'rank': six.text_type(test_user.rank_id),
+                'roles': six.text_type(test_user.roles.all()[0].pk),
+                'email': 'reg@stered.com',
+                'new_password': 'pass123',
+                '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)
 
         updated_user = UserModel.objects.get(pk=test_user.pk)
@@ -368,32 +409,38 @@ class UserAdminViewsTests(AdminTestCase):
         self.user.save()
 
         test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
-        test_link = reverse('misago:admin:users:accounts:edit',
-                            kwargs={'pk': test_user.pk})
+        test_link = reverse(
+            'misago:admin:users:accounts:edit', kwargs={
+                'pk': test_user.pk,
+            }
+        )
 
         response = self.client.get(test_link)
         self.assertContains(response, 'id="id_is_active_1"')
         self.assertContains(response, 'id="id_is_active_staff_message"')
 
-        response = self.client.post(test_link, data={
-            'username': 'Bawww',
-            'rank': six.text_type(test_user.rank_id),
-            'roles': six.text_type(test_user.roles.all()[0].pk),
-            'email': 'reg@stered.com',
-            'new_password': 'pass123',
-            '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!"
-        })
+        response = self.client.post(
+            test_link,
+            data={
+                'username': 'Bawww',
+                'rank': six.text_type(test_user.rank_id),
+                'roles': six.text_type(test_user.roles.all()[0].pk),
+                'email': 'reg@stered.com',
+                'new_password': 'pass123',
+                '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)
 
         updated_user = UserModel.objects.get(pk=test_user.pk)
@@ -410,32 +457,38 @@ class UserAdminViewsTests(AdminTestCase):
         test_user.is_staff = True
         test_user.save()
 
-        test_link = reverse('misago:admin:users:accounts:edit',
-                            kwargs={'pk': test_user.pk})
+        test_link = reverse(
+            'misago:admin:users:accounts:edit', kwargs={
+                'pk': test_user.pk,
+            }
+        )
 
         response = self.client.get(test_link)
         self.assertContains(response, 'id="id_is_active_1"')
         self.assertContains(response, 'id="id_is_active_staff_message"')
 
-        response = self.client.post(test_link, data={
-            'username': 'Bawww',
-            'rank': six.text_type(test_user.rank_id),
-            'roles': six.text_type(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': '0',
-            'is_active_staff_message': "Disabled in test!"
-        })
+        response = self.client.post(
+            test_link,
+            data={
+                'username': 'Bawww',
+                'rank': six.text_type(test_user.rank_id),
+                'roles': six.text_type(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': '0',
+                'is_active_staff_message': "Disabled in test!"
+            }
+        )
         self.assertEqual(response.status_code, 302)
 
         updated_user = UserModel.objects.get(pk=test_user.pk)
@@ -452,135 +505,108 @@ class UserAdminViewsTests(AdminTestCase):
         test_user.is_staff = True
         test_user.save()
 
-        test_link = reverse('misago:admin:users:accounts:edit',
-                            kwargs={'pk': test_user.pk})
+        test_link = reverse(
+            'misago:admin:users:accounts:edit', kwargs={
+                'pk': test_user.pk,
+            }
+        )
 
         response = self.client.get(test_link)
         self.assertNotContains(response, 'id="id_is_active_1"')
         self.assertNotContains(response, 'id="id_is_active_staff_message"')
 
-        response = self.client.post(test_link, data={
-            'username': 'Bawww',
-            'rank': six.text_type(test_user.rank_id),
-            'roles': six.text_type(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': '0',
-            'is_active_staff_message': "Disabled in test!"
-        })
+        response = self.client.post(
+            test_link,
+            data={
+                'username': 'Bawww',
+                'rank': six.text_type(test_user.rank_id),
+                'roles': six.text_type(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': '0',
+                'is_active_staff_message': "Disabled in test!"
+            }
+        )
         self.assertEqual(response.status_code, 302)
 
         updated_user = UserModel.objects.get(pk=test_user.pk)
         self.assertTrue(updated_user.is_active)
         self.assertFalse(updated_user.is_active_staff_message)
 
-    def test_edit_superuser_disable_admin(self):
-        """edit user view allows superuser to disable admin"""
-        self.user.is_superuser = True
-        self.user.save()
-
-        test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
-
-        test_user.is_staff = True
-        test_user.save()
-
-        test_link = reverse('misago:admin:users:accounts:edit',
-                            kwargs={'pk': test_user.pk})
-
-        response = self.client.get(test_link)
-        self.assertContains(response, 'id="id_is_active_1"')
-        self.assertContains(response, 'id="id_is_active_staff_message"')
-
-        response = self.client.post(test_link, data={
-            'username': 'Bawww',
-            'rank': six.text_type(test_user.rank_id),
-            'roles': six.text_type(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': '0',
-            'is_active_staff_message': "Disabled in test!"
-        })
-        self.assertEqual(response.status_code, 302)
-
-        updated_user = UserModel.objects.get(pk=test_user.pk)
-        self.assertFalse(updated_user.is_active)
-        self.assertEqual(updated_user.is_active_staff_message, "Disabled in test!")
-
     def test_delete_threads_view(self):
         """delete user threads view deletes threads"""
         test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
-        test_link = reverse('misago:admin:users:accounts:delete-threads',
-                            kwargs={'pk': test_user.pk})
+        test_link = reverse(
+            'misago:admin:users:accounts:delete-threads', kwargs={
+                'pk': test_user.pk,
+            }
+        )
 
         category = Category.objects.all_categories()[:1][0]
-        [post_thread(category, poster=test_user) for i in range(10)]
+        [post_thread(category, poster=test_user) for _ in range(10)]
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 200)
 
-        response_dict = json.loads(smart_str(response.content))
+        response_dict = response.json()
         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 = json.loads(smart_str(response.content))
+        response_dict = response.json()
         self.assertEqual(response_dict['deleted_count'], 0)
         self.assertTrue(response_dict['is_completed'])
 
     def test_delete_posts_view(self):
         """delete user posts view deletes posts"""
         test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
-        test_link = reverse('misago:admin:users:accounts:delete-posts',
-                            kwargs={'pk': test_user.pk})
+        test_link = reverse(
+            'misago:admin:users:accounts:delete-posts', kwargs={
+                'pk': test_user.pk,
+            }
+        )
 
         category = Category.objects.all_categories()[:1][0]
         thread = post_thread(category)
-        [reply_thread(thread, poster=test_user) for i in range(10)]
+        [reply_thread(thread, poster=test_user) for _ in range(10)]
 
         response = self.client.post(test_link, **self.AJAX_HEADER)
         self.assertEqual(response.status_code, 200)
 
-        response_dict = json.loads(smart_str(response.content))
+        response_dict = response.json()
         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 = json.loads(smart_str(response.content))
+        response_dict = response.json()
         self.assertEqual(response_dict['deleted_count'], 0)
         self.assertTrue(response_dict['is_completed'])
 
     def test_delete_account_view(self):
         """delete user account view deletes user account"""
         test_user = UserModel.objects.create_user('Bob', 'bob@test.com', 'pass123')
-        test_link = reverse('misago:admin:users:accounts:delete-account',
-                            kwargs={'pk': test_user.pk})
+        test_link = reverse(
+            '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 = json.loads(smart_str(response.content))
+        response_dict = response.json()
         self.assertTrue(response_dict['is_completed'])

+ 3 - 8
misago/users/tests/test_usernamechanges_api.py

@@ -1,5 +1,3 @@
-from django.contrib.auth import get_user_model
-
 from misago.acl.testutils import override_acl
 from misago.users.testutils import AuthenticatedUserTestCase
 
@@ -43,14 +41,12 @@ class UsernameChangesApiTests(AuthenticatedUserTestCase):
 
         override_acl(self.user, {'can_see_users_name_history': False})
 
-        response = self.client.get(
-            '%s?user=%s&search=new' % (self.link, self.user.pk))
+        response = self.client.get('%s?user=%s&search=new' % (self.link, self.user.pk))
 
         self.assertEqual(response.status_code, 200)
         self.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.assertContains(response, '[]')
@@ -59,8 +55,7 @@ class UsernameChangesApiTests(AuthenticatedUserTestCase):
         """list denies permission for other user (or all) if no access"""
         override_acl(self.user, {'can_see_users_name_history': False})
 
-        response = self.client.get(
-            '%s?user=%s' % (self.link, self.user.pk + 1))
+        response = self.client.get('%s?user=%s' % (self.link, self.user.pk + 1))
         self.assertContains(response, "don't have permission to", status_code=403)
 
         response = self.client.get(self.link)

+ 179 - 156
misago/users/tests/test_users_api.py

@@ -7,7 +7,6 @@ from django.utils.encoding import smart_str
 
 from misago.acl.testutils import override_acl
 from misago.categories.models import Category
-from misago.conf import settings
 from misago.core import threadstore
 from misago.core.cache import cache
 from misago.threads.models import Post, Thread
@@ -21,9 +20,8 @@ UserModel = get_user_model()
 
 
 class ActivePostersListTests(AuthenticatedUserTestCase):
-    """
-    tests for active posters list (GET /users/?list=active)
-    """
+    """tests for active posters list (GET /users/?list=active)"""
+
     def setUp(self):
         super(ActivePostersListTests, self).setUp()
         self.link = '/api/users/?list=active'
@@ -69,9 +67,8 @@ class ActivePostersListTests(AuthenticatedUserTestCase):
 
 
 class FollowersListTests(AuthenticatedUserTestCase):
-    """
-    tests for generic list (GET /users/) filtered by followers
-    """
+    """tests for generic list (GET /users/) filtered by followers"""
+
     def setUp(self):
         super(FollowersListTests, self).setUp()
         self.link = '/api/users/%s/followers/'
@@ -89,7 +86,10 @@ class FollowersListTests(AuthenticatedUserTestCase):
     def test_filled_list(self):
         """user with followers returns 200"""
         test_follower = UserModel.objects.create_user(
-            "TestFollower", "test@follower.com", self.USER_PASSWORD)
+            "TestFollower",
+            "test@follower.com",
+            self.USER_PASSWORD,
+        )
         self.user.followed_by.add(test_follower)
 
         response = self.client.get(self.link % self.user.pk)
@@ -99,7 +99,10 @@ class FollowersListTests(AuthenticatedUserTestCase):
     def test_filled_list_search(self):
         """followers list is searchable"""
         test_follower = UserModel.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
@@ -110,9 +113,8 @@ class FollowersListTests(AuthenticatedUserTestCase):
 
 
 class FollowsListTests(AuthenticatedUserTestCase):
-    """
-    tests for generic list (GET /users/) filtered by follows
-    """
+    """tests for generic list (GET /users/) filtered by follows"""
+
     def setUp(self):
         super(FollowsListTests, self).setUp()
         self.link = '/api/users/%s/follows/'
@@ -130,7 +132,10 @@ class FollowsListTests(AuthenticatedUserTestCase):
     def test_filled_list(self):
         """user with follows returns 200"""
         test_follower = UserModel.objects.create_user(
-            "TestFollower", "test@follower.com", self.USER_PASSWORD)
+            "TestFollower",
+            "test@follower.com",
+            self.USER_PASSWORD,
+        )
         self.user.follows.add(test_follower)
 
         response = self.client.get(self.link % self.user.pk)
@@ -140,7 +145,10 @@ class FollowsListTests(AuthenticatedUserTestCase):
     def test_filled_list_search(self):
         """follows list is searchable"""
         test_follower = UserModel.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
@@ -151,9 +159,8 @@ class FollowsListTests(AuthenticatedUserTestCase):
 
 
 class RankListTests(AuthenticatedUserTestCase):
-    """
-    tests for generic list (GET /users/) filtered by rank
-    """
+    """tests for generic list (GET /users/) filtered by rank"""
+
     def setUp(self):
         super(RankListTests, self).setUp()
         self.link = '/api/users/?rank=%s'
@@ -168,7 +175,7 @@ class RankListTests(AuthenticatedUserTestCase):
         test_rank = Rank.objects.create(
             name="Test rank",
             slug="test-rank",
-            is_tab=True
+            is_tab=True,
         )
 
         response = self.client.get(self.link % test_rank.pk)
@@ -203,12 +210,15 @@ class RankListTests(AuthenticatedUserTestCase):
         test_rank = Rank.objects.create(
             name="Test rank",
             slug="test-rank",
-            is_tab=True
+            is_tab=True,
         )
 
         test_user = UserModel.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)
@@ -223,9 +233,8 @@ class RankListTests(AuthenticatedUserTestCase):
 
 
 class SearchNamesListTests(AuthenticatedUserTestCase):
-    """
-    tests for generic list (GET /users/) filtered by username disallowing searches
-    """
+    """tests for generic list (GET /users/) filtered by username disallowing searches"""
+
     def setUp(self):
         super(SearchNamesListTests, self).setUp()
         self.link = '/api/users/?&name='
@@ -246,9 +255,11 @@ class UserRetrieveTests(AuthenticatedUserTestCase):
         super(UserRetrieveTests, self).setUp()
 
         self.test_user = UserModel.objects.create_user('Tyrael', 't123@test.com', 'pass123')
-        self.link = reverse('misago:api:user-detail', kwargs={
-            'pk': self.test_user.pk
-        })
+        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"""
@@ -274,9 +285,8 @@ class UserRetrieveTests(AuthenticatedUserTestCase):
 
 
 class UserForumOptionsTests(AuthenticatedUserTestCase):
-    """
-    tests for user forum options RPC (POST to /api/users/1/forum-options/)
-    """
+    """tests for user forum options RPC (POST to /api/users/1/forum-options/)"""
+
     def setUp(self):
         super(UserForumOptionsTests, self).setUp()
         self.link = '/api/users/%s/forum-options/' % self.user.pk
@@ -286,79 +296,95 @@ class UserForumOptionsTests(AuthenticatedUserTestCase):
         response = self.client.post(self.link)
 
         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.',
-            ],
-        })
+        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.',
+                ],
+            }
+        )
 
     def test_change_forum_invalid_ranges(self):
         """api validates ranges for fields"""
-        response = self.client.post(self.link, data={
-            'limits_private_thread_invites_to': 541,
-            'subscribe_to_started_threads': 44,
-            'subscribe_to_replied_threads': 321
-        })
+        response = self.client.post(
+            self.link,
+            data={
+                '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.',
-            ],
-        })
+        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.',
+                ],
+            }
+        )
 
     def test_change_forum_options(self):
         """forum options are changed"""
-        response = self.client.post(self.link, data={
-            'limits_private_thread_invites_to': 1,
-            'subscribe_to_started_threads': 2,
-            'subscribe_to_replied_threads': 1
-        })
+        response = self.client.post(
+            self.link,
+            data={
+                'limits_private_thread_invites_to': 1,
+                'subscribe_to_started_threads': 2,
+                'subscribe_to_replied_threads': 1,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
-        self.reload_user();
+        self.reload_user()
 
         self.assertFalse(self.user.is_hiding_presence)
         self.assertEqual(self.user.limits_private_thread_invites_to, 1)
         self.assertEqual(self.user.subscribe_to_started_threads, 2)
         self.assertEqual(self.user.subscribe_to_replied_threads, 1)
 
-        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
-        })
+        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,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
-        self.reload_user();
+        self.reload_user()
 
         self.assertTrue(self.user.is_hiding_presence)
         self.assertEqual(self.user.limits_private_thread_invites_to, 1)
         self.assertEqual(self.user.subscribe_to_started_threads, 2)
         self.assertEqual(self.user.subscribe_to_replied_threads, 1)
 
-        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
-        })
+        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,
+            }
+        )
         self.assertEqual(response.status_code, 200)
 
-        self.reload_user();
+        self.reload_user()
 
         self.assertFalse(self.user.is_hiding_presence)
         self.assertEqual(self.user.limits_private_thread_invites_to, 1)
@@ -367,14 +393,12 @@ class UserForumOptionsTests(AuthenticatedUserTestCase):
 
 
 class UserFollowTests(AuthenticatedUserTestCase):
-    """
-    tests for user follow RPC (POST to /api/users/1/follow/)
-    """
+    """tests for user follow RPC (POST to /api/users/1/follow/)"""
+
     def setUp(self):
         super(UserFollowTests, self).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/follow/' % self.other_user.pk
 
@@ -404,7 +428,6 @@ class UserFollowTests(AuthenticatedUserTestCase):
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 200)
 
-
         user = UserModel.objects.get(pk=self.user.pk)
         self.assertEqual(user.followers, 0)
         self.assertEqual(user.following, 1)
@@ -434,31 +457,25 @@ class UserFollowTests(AuthenticatedUserTestCase):
 
 
 class UserBanTests(AuthenticatedUserTestCase):
-    """
-    tests for ban endpoint (GET to /api/users/1/ban/)
-    """
+    """tests for ban endpoint (GET to /api/users/1/ban/)"""
+
     def setUp(self):
         super(UserBanTests, self).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/ban/' % self.other_user.pk
 
     def test_no_permission(self):
         """user has no permission to access ban"""
-        override_acl(self.user, {
-            'can_see_ban_details': 0
-        })
+        override_acl(self.user, {'can_see_ban_details': 0})
 
         response = self.client.get(self.link)
         self.assertContains(response, "can't see users bans details", status_code=403)
 
     def test_no_ban(self):
         """api returns empty json"""
-        override_acl(self.user, {
-            'can_see_ban_details': 1
-        })
+        override_acl(self.user, {'can_see_ban_details': 1})
 
         response = self.client.get(self.link)
         self.assertEqual(response.status_code, 200)
@@ -466,33 +483,29 @@ class UserBanTests(AuthenticatedUserTestCase):
 
     def test_ban_details(self):
         """api returns ban json"""
-        override_acl(self.user, {
-            'can_see_ban_details': 1
-        })
+        override_acl(self.user, {'can_see_ban_details': 1})
 
         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 = json.loads(smart_str(response.content))
+        ban_json = response.json()
         self.assertEqual(ban_json['user_message']['plain'], 'Nope!')
         self.assertEqual(ban_json['user_message']['html'], '<p>Nope!</p>')
 
 
 class UserDeleteTests(AuthenticatedUserTestCase):
-    """
-    tests for user delete RPC (POST to /api/users/1/delete/)
-    """
+    """tests for user delete RPC (POST to /api/users/1/delete/)"""
+
     def setUp(self):
         super(UserDeleteTests, self).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/delete/' % self.other_user.pk
 
@@ -508,24 +521,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
     def test_delete_no_permission(self):
         """raises 403 error when no permission to delete"""
-        override_acl(self.user, {
-            'can_delete_users_newer_than': 0,
-            'can_delete_users_with_less_posts_than': 0,
-        })
-
-        response = self.client.post(self.link)
-        self.assertEqual(response.status_code, 403)
-        self.assertContains(response, "can't delete users", status_code=403)
-
-    def test_delete_too_many_posts(self):
-        """raises 403 error when user has too many posts"""
-        override_acl(self.user, {
-            'can_delete_users_newer_than': 0,
-            'can_delete_users_with_less_posts_than': 5,
-        })
-
-        self.other_user.posts = 6
-        self.other_user.save()
+        override_acl(
+            self.user, {
+                'can_delete_users_newer_than': 0,
+                'can_delete_users_with_less_posts_than': 0,
+            }
+        )
 
         response = self.client.post(self.link)
         self.assertEqual(response.status_code, 403)
@@ -533,10 +534,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
     def test_delete_too_many_posts(self):
         """raises 403 error when user has too many posts"""
-        override_acl(self.user, {
-            'can_delete_users_newer_than': 0,
-            'can_delete_users_with_less_posts_than': 5,
-        })
+        override_acl(
+            self.user, {
+                'can_delete_users_newer_than': 0,
+                'can_delete_users_with_less_posts_than': 5,
+            }
+        )
 
         self.other_user.posts = 6
         self.other_user.save()
@@ -548,10 +551,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
     def test_delete_too_old_member(self):
         """raises 403 error when user is too old"""
-        override_acl(self.user, {
-            'can_delete_users_newer_than': 5,
-            'can_delete_users_with_less_posts_than': 0,
-        })
+        override_acl(
+            self.user, {
+                'can_delete_users_newer_than': 5,
+                'can_delete_users_with_less_posts_than': 0,
+            }
+        )
 
         self.other_user.joined_on -= timedelta(days=6)
         self.other_user.save()
@@ -563,20 +568,24 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
     def test_delete_self(self):
         """raises 403 error when attempting to delete oneself"""
-        override_acl(self.user, {
-            'can_delete_users_newer_than': 10,
-            'can_delete_users_with_less_posts_than': 10,
-        })
+        override_acl(
+            self.user, {
+                'can_delete_users_newer_than': 10,
+                'can_delete_users_with_less_posts_than': 10,
+            }
+        )
 
         response = self.client.post('/api/users/%s/delete/' % self.user.pk)
         self.assertContains(response, "can't delete yourself", status_code=403)
 
     def test_delete_admin(self):
         """raises 403 error when attempting to delete admin"""
-        override_acl(self.user, {
-            'can_delete_users_newer_than': 10,
-            'can_delete_users_with_less_posts_than': 10,
-        })
+        override_acl(
+            self.user, {
+                'can_delete_users_newer_than': 10,
+                'can_delete_users_with_less_posts_than': 10,
+            }
+        )
 
         self.other_user.is_staff = True
         self.other_user.save()
@@ -586,10 +595,12 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
     def test_delete_superadmin(self):
         """raises 403 error when attempting to delete superadmin"""
-        override_acl(self.user, {
-            'can_delete_users_newer_than': 10,
-            'can_delete_users_with_less_posts_than': 10,
-        })
+        override_acl(
+            self.user, {
+                'can_delete_users_newer_than': 10,
+                'can_delete_users_with_less_posts_than': 10,
+            }
+        )
 
         self.other_user.is_superuser = True
         self.other_user.save()
@@ -599,14 +610,20 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
     def test_delete_with_content(self):
         """returns 200 and deletes user with content"""
-        override_acl(self.user, {
-            'can_delete_users_newer_than': 10,
-            'can_delete_users_with_less_posts_than': 10,
-        })
+        override_acl(
+            self.user, {
+                'can_delete_users_newer_than': 10,
+                'can_delete_users_with_less_posts_than': 10,
+            }
+        )
 
-        response = self.client.post(self.link, json.dumps({
-            'with_content': True
-        }), content_type="application/json")
+        response = self.client.post(
+            self.link,
+            json.dumps({
+                'with_content': True
+            }),
+            content_type='application/json',
+        )
         self.assertEqual(response.status_code, 200)
 
         with self.assertRaises(UserModel.DoesNotExist):
@@ -617,14 +634,20 @@ class UserDeleteTests(AuthenticatedUserTestCase):
 
     def test_delete_without_content(self):
         """returns 200 and deletes user without content"""
-        override_acl(self.user, {
-            'can_delete_users_newer_than': 10,
-            'can_delete_users_with_less_posts_than': 10,
-        })
+        override_acl(
+            self.user, {
+                'can_delete_users_newer_than': 10,
+                'can_delete_users_with_less_posts_than': 10,
+            }
+        )
 
-        response = self.client.post(self.link, json.dumps({
-            'with_content': False
-        }), content_type="application/json")
+        response = self.client.post(
+            self.link,
+            json.dumps({
+                'with_content': False
+            }),
+            content_type='application/json',
+        )
         self.assertEqual(response.status_code, 200)
 
         with self.assertRaises(UserModel.DoesNotExist):

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

@@ -7,10 +7,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(u'łóć@test.com'),
-                         hash_email(u'ŁÓĆ@tEst.cOm'))
+        self.assertEqual(hash_email(u'łóć@test.com'), hash_email(u'ŁÓĆ@tEst.cOm'))

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

@@ -16,8 +16,7 @@ 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"""
@@ -34,7 +33,7 @@ class ValidateEmailBannedTests(TestCase):
     def setUp(self):
         Ban.objects.create(
             check_type=Ban.EMAIL,
-            banned_value="ban@test.com"
+            banned_value="ban@test.com",
         )
 
     def test_unbanned_name(self):
@@ -65,14 +64,12 @@ class ValidateUsernameTests(TestCase):
 
 class ValidateUsernameAvailableTests(TestCase):
     def setUp(self):
-        self.test_user = UserModel.objects.create_user(
-            'EricTheFish', 'eric@test.com', 'pass123')
+        self.test_user = UserModel.objects.create_user('EricTheFish', 'eric@test.com', 'pass123')
 
     def test_valid_name(self):
         """validate_username_available allows available names"""
         validate_username_available('BobBoberson')
-        validate_username_available(
-            self.test_user.username, exclude=self.test_user)
+        validate_username_available(self.test_user.username, exclude=self.test_user)
 
     def test_invalid_name(self):
         """validate_username_available disallows unvailable names"""
@@ -84,7 +81,7 @@ class ValidateUsernameBannedTests(TestCase):
     def setUp(self):
         Ban.objects.create(
             check_type=Ban.USERNAME,
-            banned_value="Bob"
+            banned_value="Bob",
         )
 
     def test_unbanned_name(self):

+ 3 - 3
misago/users/testutils.py

@@ -22,12 +22,12 @@ class UserTestCase(MisagoTestCase):
         return AnonymousUser()
 
     def get_authenticated_user(self):
-        return UserModel.objects.create_user(
-            "TestUser", "test@user.com", self.USER_PASSWORD)
+        return UserModel.objects.create_user("TestUser", "test@user.com", self.USER_PASSWORD)
 
     def get_superuser(self):
         return UserModel.objects.create_superuser(
-            "TestSuperUser", "test@superuser.com", self.USER_PASSWORD)
+            "TestSuperUser", "test@superuser.com", self.USER_PASSWORD
+        )
 
     def login_user(self, user, password=None):
         self.client.force_login(user)

+ 13 - 21
misago/users/tokens.py

@@ -1,12 +1,3 @@
-import base64
-from hashlib import sha256
-from time import time
-
-from django.conf import settings
-from django.utils import six
-from django.utils.encoding import force_bytes
-
-
 """
 Token creation
 
@@ -16,6 +7,15 @@ Token is base encoded string containing three values:
 - hash unique for current state of user model
 - token checksum for discovering manipulations
 """
+import base64
+from hashlib import sha256
+from time import time
+
+from django.conf import settings
+from django.utils import six
+from django.utils.encoding import force_bytes
+
+
 def make(user, token_type):
     user_hash = _make_hash(user, token_type)
     creation_day = _days_since_epoch()
@@ -45,17 +45,16 @@ def is_valid(user, token_type, token):
 
 
 def _make_hash(user, token_type):
-    seeds = (
+    seeds = [
         user.pk,
         user.email,
         user.password,
         user.last_login.replace(microsecond=0, tzinfo=None),
         token_type,
         settings.SECRET_KEY,
-    )
+    ]
 
-    return sha256(force_bytes(
-        '+'.join([six.text_type(s) for s in seeds]))).hexdigest()[:8]
+    return sha256(force_bytes('+'.join([six.text_type(s) for s in seeds]))).hexdigest()[:8]
 
 
 def _days_since_epoch():
@@ -63,13 +62,9 @@ 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]
 
 
-"""
-Convenience functions for activation token
-"""
 ACTIVATION_TOKEN = 'activation'
 
 
@@ -81,9 +76,6 @@ def is_activation_token_valid(user, token):
     return is_valid(user, ACTIVATION_TOKEN, token)
 
 
-"""
-Convenience functions for password change token
-"""
 PASSWORD_CHANGE_TOKEN = 'change_password'
 
 

+ 53 - 29
misago/users/urls/__init__.py

@@ -4,57 +4,81 @@ from misago.core.views import home_redirect
 
 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'^activation/(?P<pk>\d+)/(?P<token>[a-zA-Z0-9]+)/$', activation.activate_by_token, name='activate-by-token'),
-
+    url(
+        r'^activation/(?P<pk>\d+)/(?P<token>[a-zA-Z0-9]+)/$',
+        activation.activate_by_token,
+        name='activate-by-token'
+    ),
     url(r'^forgotten-password/$', forgottenpassword.request_reset, name='forgotten-password'),
-    url(r'^forgotten-password/(?P<pk>\d+)/(?P<token>[a-zA-Z0-9]+)/$', forgottenpassword.reset_password_form, name='forgotten-password-change-form'),
+    url(
+        r'^forgotten-password/(?P<pk>\d+)/(?P<token>[a-zA-Z0-9]+)/$',
+        forgottenpassword.reset_password_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/change-username/$', options.index, name='usercp-change-username'),
     url(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'),
-    url(r'^options/change-password/(?P<token>[a-zA-Z0-9]+)/$', options.confirm_password_change, name='options-confirm-password-change'),
+    url(
+        r'^options/change-email/(?P<token>[a-zA-Z0-9]+)/$',
+        options.confirm_email_change,
+        name='options-confirm-email-change'
+    ),
+    url(
+        r'^options/change-password/(?P<token>[a-zA-Z0-9]+)/$',
+        options.confirm_password_change,
+        name='options-confirm-password-change'
+    ),
 ]
 
-
 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'),
-    ]))
+    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'
+            ),
+        ])
+    )
 ]
 
-
 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'^username-history/$', profile.UserUsernameHistoryView.as_view(), name='username-history'),
-        url(r'^ban-details/$', profile.UserBanView.as_view(), name='user-ban'),
-    ]))
+    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'^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'),

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

@@ -12,12 +12,14 @@ urlpatterns = [
     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/change-password/(?P<pk>\d+)/(?P<token>[a-zA-Z0-9]+)/$', auth.change_forgotten_password, name='change-forgotten-password'),
-
+    url(
+        r'^auth/change-password/(?P<pk>\d+)/(?P<token>[a-zA-Z0-9]+)/$',
+        auth.change_forgotten_password,
+        name='change-forgotten-password'
+    ),
     url(r'^captcha-question/$', captcha.question, name='captcha-question'),
 ]
 
-
 router = MisagoApiRouter()
 router.register(r'ranks', RanksViewSet)
 router.register(r'users', UserViewSet)

+ 15 - 27
misago/users/validators.py

@@ -21,9 +21,7 @@ USERNAME_RE = re.compile(r'^[0-9a-z]+$', re.IGNORECASE)
 UserModel = get_user_model()
 
 
-"""
-Email validators
-"""
+# E-mail validators
 def validate_email_available(value, exclude=None):
     try:
         user = UserModel.objects.get_by_email(value)
@@ -50,9 +48,7 @@ def validate_email(value, exclude=None):
     validate_email_banned(value)
 
 
-"""
-Username validators
-"""
+# Username validators
 def validate_username_available(value, exclude=None):
     try:
         user = UserModel.objects.get_by_username(value)
@@ -74,8 +70,7 @@ 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(value):
@@ -83,17 +78,17 @@ def validate_username_length(value):
         message = ungettext(
             "Username must be at least %(limit_value)s character long.",
             "Username must be at least %(limit_value)s characters long.",
-            settings.username_length_min)
-        message = message % {'limit_value': settings.username_length_min}
-        raise ValidationError(message)
+            settings.username_length_min
+        )
+        raise ValidationError(message % {'limit_value': settings.username_length_min})
 
     if len(value) > settings.username_length_max:
         message = ungettext(
             "Username cannot be longer than %(limit_value)s characters.",
             "Username cannot be longer than %(limit_value)s characters.",
-            settings.username_length_max)
-        message = message % {'limit_value': settings.username_length_max}
-        raise ValidationError(message)
+            settings.username_length_max
+        )
+        raise ValidationError(message % {'limit_value': settings.username_length_max})
 
 
 def validate_username(value, exclude=None):
@@ -104,9 +99,7 @@ def validate_username(value, exclude=None):
     validate_username_banned(value)
 
 
-"""
-New account validators
-"""
+# New account validators
 SFS_API_URL = u'http://api.stopforumspam.org/api?email=%(email)s&ip=%(ip)s&f=json&confidence'  # noqa
 
 
@@ -117,10 +110,7 @@ def validate_with_sfs(request, form, cleaned_data):
 
 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()
 
@@ -133,7 +123,7 @@ def _real_validate_with_sfs(ip, email):
         if api_score > settings.MISAGO_STOP_FORUM_SPAM_MIN_CONFIDENCE:
             raise ValidationError(_("Data entered was found in spammers database."))
     except requests.exceptions.RequestException:
-        pass # todo: log those somewhere
+        pass  # todo: log those somewhere
 
 
 def validate_gmail_email(request, form, cleaned_data):
@@ -146,12 +136,10 @@ def validate_gmail_email(request, form, cleaned_data):
         form.add_error('email', ValidationError(_("This email is not allowed.")))
 
 
-"""
-Registration validation
-"""
-def load_registration_validators(validators_list):
+# Registration validation
+def load_registration_validators(validators):
     loaded_validators = []
-    for path in validators_list:
+    for path in validators:
         module = import_module('.'.join(path.split('.')[:-1]))
         loaded_validators.append(getattr(module, path.split('.')[-1]))
     return loaded_validators

+ 3 - 2
misago/users/viewmodels/activeposters.py

@@ -17,14 +17,15 @@ class ActivePosters(object):
         return {
             'tracked_period': self.tracked_period,
             'results': ScoredUserSerializer(self.users, many=True).data,
-            'count': self.count
+            'count': self.count,
         }
 
     def get_template_context(self):
         return {
             'tracked_period': self.tracked_period,
             'users': self.users,
-            'users_count': self.count
+            'users_count': self.count,
         }
 
+
 ScoredUserSerializer = UserCardSerializer.extend_fields('meta')

+ 3 - 4
misago/users/viewmodels/followers.py

@@ -1,6 +1,7 @@
+from django.http import Http404
+
 from misago.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
-from django.http import Http404
 from misago.users.online.utils import make_users_status_aware
 from misago.users.serializers import UserCardSerializer
 
@@ -29,9 +30,7 @@ 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
 

+ 2 - 3
misago/users/viewmodels/posts.py

@@ -6,10 +6,9 @@ from .threads import UserThreads
 
 class UserPosts(UserThreads):
     def get_threads_queryset(self, request, threads_categories, profile):
-        return exclude_invisible_threads(
-            request.user, threads_categories, Thread.objects)
+        return exclude_invisible_threads(request.user, 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')
+            thread_id__in=threads_queryset.values('id'),
         )

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

@@ -1,6 +1,5 @@
 from misago.conf import settings
 from misago.core.shortcuts import paginate, pagination_dict
-from django.http import Http404
 from misago.users.online.utils import make_users_status_aware
 from misago.users.serializers import UserCardSerializer
 
@@ -8,7 +7,10 @@ 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)
@@ -20,9 +22,7 @@ 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
 

+ 10 - 15
misago/users/viewmodels/threads.py

@@ -13,19 +13,17 @@ 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(
+        posts_queryset = self.get_posts_queryset(request.user, profile, threads_queryset).filter(
             is_event=False,
             is_hidden=False,
-            is_unapproved=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)
 
         posts = list(list_page.object_list)
@@ -34,8 +32,7 @@ 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(request.user, threads)
         add_acl(request.user, posts)
@@ -52,18 +49,16 @@ class UserThreads(object):
         self.paginator = paginator
 
     def get_threads_queryset(self, request, threads_categories, profile):
-        return exclude_invisible_threads(
-            request.user, threads_categories, profile.thread_set)
+        return exclude_invisible_threads(request.user, 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')
+            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)
@@ -73,7 +68,7 @@ class UserThreads(object):
     def get_template_context(self):
         return {
             'posts': self.posts,
-            'paginator': self.paginator
+            'paginator': self.paginator,
         }
 
 

+ 24 - 16
misago/users/views/activation.py

@@ -3,9 +3,7 @@ from django.shortcuts import get_object_or_404, render
 from django.urls import reverse
 from django.utils.translation import ugettext as _
 
-from misago.conf import settings
 from misago.core.exceptions import Banned
-from misago.core.mail import mail_user
 from misago.users.bans import get_user_ban
 from misago.users.decorators import deny_authenticated, deny_banned_ips
 from misago.users.tokens import is_activation_token_valid
@@ -19,13 +17,14 @@ def activation_view(f):
     @deny_banned_ips
     def decorator(*args, **kwargs):
         return f(*args, **kwargs)
+
     return decorator
 
 
 @activation_view
 def request_activation(request):
     request.frontend_context.update({
-        'SEND_ACTIVATION_API': reverse('misago:api:send-activation')
+        'SEND_ACTIVATION_API': reverse('misago:api:send-activation'),
     })
     return render(request, 'misago/activation/request.html')
 
@@ -45,32 +44,41 @@ def activate_by_token(request, pk, token):
     try:
         if not inactive_user.requires_activation:
             message = _("%(user)s, your account is already active.")
-            message = message % {'user': inactive_user.username}
-            raise ActivationStopped(message)
+            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.")
-            message = message % {'user': inactive_user.username}
-            raise ActivationError(message)
+            message = _(
+                "%(user)s, your activation link is invalid. "
+                "Try again or request new activation link."
+            )
+            raise ActivationError(message % {'user': inactive_user.username})
 
         ban = get_user_ban(inactive_user)
         if ban:
             raise Banned(ban)
     except ActivationStopped as e:
         return render(request, 'misago/activation/stopped.html', {
-                'message': e.args[0],
-            })
+            'message': e.args[0],
+        })
     except ActivationError as e:
-        return render(request, 'misago/activation/error.html', {
+        return render(
+            request,
+            'misago/activation/error.html',
+            {
                 'message': e.args[0],
-            }, status=400)
+            },
+            status=400,
+        )
 
     inactive_user.requires_activation = UserModel.ACTIVATION_NONE
     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},
-        })
+    return render(
+        request, 'misago/activation/done.html', {
+            'message': message % {
+                'user': inactive_user.username,
+            },
+        }
+    )

+ 8 - 10
misago/users/views/admin/bans.py

@@ -20,23 +20,21 @@ class BanAdmin(generic.AdminBaseMixin):
 
 class BansList(BanAdmin, generic.ListView):
     items_per_page = 30
-    ordering = (
+    ordering = [
         ('-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?')
-        },
-    )
+    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()

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

@@ -26,7 +26,7 @@ class RankAdmin(generic.AdminBaseMixin):
 
 
 class RanksList(RankAdmin, generic.ListView):
-    ordering = (('order', None),)
+    ordering = (('order', None), )
 
 
 class NewRank(RankAdmin, generic.ModelFormView):

+ 41 - 48
misago/users/views/admin/users.py

@@ -15,7 +15,7 @@ from misago.threads.models import Thread
 from misago.users.avatars.dynamic import set_avatar as set_dynamic_avatar
 from misago.users.forms.admin import (
     BanUsersForm, EditUserForm, EditUserFormFactory, NewUserForm, SearchUsersForm)
-from misago.users.models import Ban, User
+from misago.users.models import Ban
 from misago.users.signatures import set_user_signature
 
 
@@ -41,7 +41,8 @@ class UserAdmin(generic.AdminBaseMixin):
             add_admin_fields = request.user.pk != target.pk
 
         return EditUserFormFactory(
-            self.form, target,
+            self.form,
+            target,
             add_is_active_fields=add_is_active_fields,
             add_admin_fields=add_admin_fields,
         )
@@ -49,14 +50,14 @@ class UserAdmin(generic.AdminBaseMixin):
 
 class UsersList(UserAdmin, generic.ListView):
     items_per_page = 24
-    ordering = (
+    ordering = [
         ('-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')
     mass_actions = [
@@ -80,11 +81,12 @@ class UsersList(UserAdmin, generic.ListView):
             '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."),
+            'confirmation': _(
+                "Are you sure you want to delete selected users? "
+                "This will also delete all content associated with their accounts."
+            ),
             'is_atomic': False,
-        }
+        },
     ]
 
     def get_queryset(self):
@@ -109,15 +111,11 @@ class UsersList(UserAdmin, generic.ListView):
             queryset.update(requires_activation=UserModel.ACTIVATION_NONE)
 
             subject = _("Your account on %(forum_name)s forums has been activated")
-            mail_subject = subject % {
-                'forum_name': settings.forum_name
-            }
+            mail_subject = subject % {'forum_name': settings.forum_name}
 
-            mail_users(request, inactive_users, mail_subject,
-                       'misago/emails/activation/by_admin')
+            mail_users(request, inactive_users, mail_subject, 'misago/emails/activation/by_admin')
 
-            message = _("Selected users accounts have been activated.")
-            messages.success(request, message)
+            messages.success(request, _("Selected users accounts have been activated."))
 
     def action_ban(self, request, users):
         users = users.order_by('slug')
@@ -137,7 +135,7 @@ class UsersList(UserAdmin, generic.ListView):
                 ban_kwargs = {
                     'user_message': cleaned_data.get('user_message'),
                     'staff_message': cleaned_data.get('staff_message'),
-                    'expires_on': cleaned_data.get('expires_on')
+                    'expires_on': cleaned_data.get('expires_on'),
                 }
 
                 for user in users:
@@ -172,38 +170,35 @@ class UsersList(UserAdmin, generic.ListView):
                             if ban == 'ip_first':
                                 formats = (bits[0], ip_separator)
                             if ban == 'ip_two':
-                                formats = (
-                                    bits[0], ip_separator,
-                                    bits[1], ip_separator
-                                )
+                                formats = (bits[0], ip_separator, bits[1], ip_separator)
                             banned_value = '%s*' % (''.join(formats))
 
                         if banned_value not in banned_values:
                             ban_kwargs.update({
                                 'check_type': check_type,
-                                'banned_value': banned_value
+                                'banned_value': banned_value,
                             })
                             Ban.objects.create(**ban_kwargs)
                             banned_values.append(banned_value)
 
-
                 Ban.objects.invalidate_cache()
-                message = _("Selected users have been banned.")
-                messages.success(request, message)
+                messages.success(request, _("Selected users have been banned."))
                 return None
 
         return self.render(
-            request, template='misago/admin/users/ban.html', context={
+            request,
+            template='misago/admin/users/ban.html',
+            context={
                 'users': users,
                 'form': form,
-            })
+            }
+        )
 
     def action_delete_accounts(self, request, users):
         for user in users:
             if user.is_staff or user.is_superuser:
-                message = _("%(user)s is admin and can't be deleted.")
-                mesage = message % {'user': user.username}
-                raise generic.MassActionError(mesage)
+                message = _("%(user)s is admin and can't be deleted.") % {'user': user.username}
+                raise generic.MassActionError(message)
 
         for user in users:
             user.delete()
@@ -212,22 +207,21 @@ class UsersList(UserAdmin, generic.ListView):
         messages.success(request, message)
 
     def action_delete_all(self, request, users):
-        return self.render(
-            request, template='misago/admin/users/delete.html', context={
-                'users': users,
-            })
-
         for user in users:
             if user.is_staff or user.is_superuser:
-                message = _("%(user)s is admin and can't be deleted.")
-                mesage = message % {'user': user.username}
-                raise generic.MassActionError(mesage)
+                message = _("%(user)s is admin and can't be deleted.") % {'user': user.username}
+                raise generic.MassActionError(message)
 
         for user in users:
             user.delete(delete_content=True)
 
-        message = _("Selected users and their content has been deleted.")
-        messages.success(request, message)
+        messages.success(request, _("Selected users and their content has been deleted."))
+
+        return self.render(
+            request, template='misago/admin/users/delete.html', context={
+                'users': users,
+            }
+        )
 
 
 class NewUser(UserAdmin, generic.ModelFormView):
@@ -255,8 +249,7 @@ class NewUser(UserAdmin, generic.ModelFormView):
         new_user.update_acl_key()
         new_user.save()
 
-        messages.success(
-            request, self.message_submit % {'user': target.username})
+        messages.success(request, self.message_submit % {'user': target.username})
         return redirect('misago:admin:users:accounts:edit', pk=new_user.pk)
 
 
@@ -273,8 +266,7 @@ class EditUser(UserAdmin, generic.ModelFormView):
     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)
+            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'])
@@ -310,8 +302,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):
@@ -326,7 +317,9 @@ class DeletionStep(UserAdmin, generic.ButtonView):
 
     def execute_step(self, user):
         raise NotImplementedError(
-            "execute_step method should return dict with number of deleted_count and is_completed keys")
+            "execute_step method should return dict with "
+            "number of deleted_count and is_completed keys"
+        )
 
     def button_action(self, request, target):
         return JsonResponse(self.execute_step(target))
@@ -354,7 +347,7 @@ class DeleteThreadsStep(DeletionStep):
 
         return {
             'deleted_count': deleted_threads,
-            'is_completed': is_completed
+            'is_completed': is_completed,
         }
 
 
@@ -387,7 +380,7 @@ class DeletePostsStep(DeletionStep):
 
         return {
             'deleted_count': deleted_posts,
-            'is_completed': is_completed
+            'is_completed': is_completed,
         }
 
 

+ 1 - 2
misago/users/views/auth.py

@@ -15,8 +15,7 @@ def login(request):
     if request.method == 'POST':
         redirect_to = request.POST.get('redirect_to')
         if redirect_to:
-            is_redirect_safe = is_safe_url(
-                url=redirect_to, host=request.get_host())
+            is_redirect_safe = is_safe_url(url=redirect_to, host=request.get_host())
             if is_redirect_safe:
                 redirect_to_path = urlparse(redirect_to).path
                 return redirect(redirect_to_path)

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

@@ -13,6 +13,7 @@ def reset_view(f):
     @deny_banned_ips
     def decorator(*args, **kwargs):
         return f(*args, **kwargs)
+
     return decorator
 
 
@@ -33,31 +34,30 @@ 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.")
-            message = message % {'user': requesting_user.username}
-            raise ResetError(message)
+        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.")
-            message = message % {'user': requesting_user.username}
-            raise ResetError(message)
+            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)
         if ban:
             raise Banned(ban)
     except ResetError as e:
-        return render(request, 'misago/forgottenpassword/error.html', {
-            'message': e.args[0],
-        }, status=400)
-
-    api_url = reverse('misago:api:change-forgotten-password', kwargs={
-        'pk': pk,
-        'token': token,
-    })
+        return render(
+            request, 'misago/forgottenpassword/error.html', {
+                'message': e.args[0],
+            }, status=400
+        )
+
+    api_url = reverse(
+        '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')

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

@@ -1,5 +1,4 @@
-from django.shortcuts import render
-from django.shortcuts import get_object_or_404, redirect
+from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.utils import six
 from django.views import View
@@ -33,9 +32,7 @@ class ListView(View):
         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
-                }),
+                'reversed_link': reverse('misago:users-rank', kwargs={'slug': rank.slug}),
                 'is_active': active_rank.pk == rank.pk if active_rank else None
             })
 

+ 18 - 13
misago/users/views/options.py

@@ -1,7 +1,6 @@
 from django.contrib.auth import update_session_auth_hash
 from django.db import IntegrityError
 from django.shortcuts import render
-from django.urls import reverse
 from django.utils import six
 from django.utils.translation import ugettext as _
 
@@ -20,9 +19,7 @@ def index(request, *args, **kwargs):
             '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')
 
@@ -36,9 +33,9 @@ def confirm_change_view(f):
     def decorator(request, token):
         try:
             return f(request, token)
-        except ChangeError as e:
-            return render(request, 'misago/options/credentials_error.html',
-                status=400)
+        except ChangeError:
+            return render(request, 'misago/options/credentials_error.html', status=400)
+
     return decorator
 
 
@@ -55,9 +52,13 @@ def confirm_email_change(request, token):
         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},
-        })
+    return render(
+        request, 'misago/options/credentials_changed.html', {
+            'message': message % {
+                'user': request.user.username,
+            },
+        }
+    )
 
 
 @confirm_change_view
@@ -71,6 +72,10 @@ def confirm_password_change(request, token):
     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},
-        })
+    return render(
+        request, 'misago/options/credentials_changed.html', {
+            'message': message % {
+                'user': request.user.username,
+            },
+        }
+    )

+ 11 - 19
misago/users/views/profile.py

@@ -1,7 +1,6 @@
 from django.contrib.auth import get_user_model
 from django.http import Http404
-from django.shortcuts import render
-from django.shortcuts import get_object_or_404, redirect
+from django.shortcuts import get_object_or_404, redirect, render
 from django.utils import six
 from django.views import View
 
@@ -10,8 +9,7 @@ from misago.core.shortcuts import paginate, pagination_dict, validate_slug
 from misago.users.bans import get_user_ban
 from misago.users.online.utils import get_user_status
 from misago.users.pages import user_profile
-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
 
 
@@ -38,8 +36,7 @@ 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)
 
@@ -70,7 +67,8 @@ class ProfileView(View):
             })
 
         request.frontend_context['PROFILE'] = UserProfileSerializer(
-            profile, context={'user': request.user}).data
+            profile, context={'user': request.user}
+        ).data
 
         if not profile.is_active:
             request.frontend_context['PROFILE']['is_active'] = False
@@ -104,11 +102,7 @@ class LandingView(ProfileView):
     def get(self, request, *args, **kwargs):
         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):
@@ -161,9 +155,7 @@ class UserUsernameHistoryView(ProfileView):
         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
 
@@ -187,7 +179,7 @@ class UserBanView(ProfileView):
 
 
 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', 'status', 'absolute_url',
-    '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', 'status', 'absolute_url', 'api_url'
+)

+ 38 - 0
pycodestyle.py

@@ -0,0 +1,38 @@
+"""
+Code style cleanups done after yapf
+"""
+import argparse
+import codecs
+import os
+
+from extras import fixdictsformatting
+
+
+CLEANUPS = [
+    fixdictsformatting,
+]
+
+
+def walk_directory(root, dirs, files):
+    for filename in files:
+        if filename.lower().endswith('.py'):
+            with codecs.open(os.path.join(root, filename), 'r', 'utf-8') as f:
+                filesource = f.read()
+
+            org_source = filesource
+
+            for cleanup in CLEANUPS:
+                filesource = cleanup.fix_formatting(filesource)
+
+            if org_source != filesource:
+                print 'afterclean: %s' % os.path.join(root, filename)
+                with codecs.open(os.path.join(root, filename), 'w', 'utf-8') as f:
+                    f.write(filesource)
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser()
+    parser.add_argument('path', nargs='?', default='./')
+
+    for args in os.walk(parser.parse_args().path):
+        walk_directory(*args)